diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..4fceb86ff8 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +CHROME_PATH= \ No newline at end of file diff --git a/.github/workflows/build-project.yml b/.github/workflows/build-project.yml index 0b757a2427..0f42887b75 100644 --- a/.github/workflows/build-project.yml +++ b/.github/workflows/build-project.yml @@ -15,7 +15,7 @@ on: jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 45 steps: diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index b66cdd2c0c..1ed2d46c59 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -3,7 +3,7 @@ on: pull_request jobs: test-and-build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4.1.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 776922f085..65e3305e5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4.1.4 diff --git a/.gitignore b/.gitignore index 10dd2cb905..03d149d023 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ node_modules/ /lerna-debug.log .nx/cache + +# environment variables +.env \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 43efde8599..576c8e5fff 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/openscd": "0.36.0", - "packages/core": "0.1.3", - ".": "0.36.0" + "packages/openscd": "0.37.0", + "packages/core": "0.1.4", + ".": "0.38.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 80601e883e..cb4ff1e881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [0.38.0](https://github.com/openscd/open-scd/compare/v0.37.1...v0.38.0) (2025-02-26) + + +### Features + +* change release please Workflow trigger ([#1624](https://github.com/openscd/open-scd/issues/1624)) ([53f24cb](https://github.com/openscd/open-scd/commit/53f24cbc9b2be8407aa1420b5665d2a09e9051ea)) +* update release please action ([#1635](https://github.com/openscd/open-scd/issues/1635)) ([06b8356](https://github.com/openscd/open-scd/commit/06b8356485940f2841b01293de5bea2a6fa1399d)) + + +### Bug Fixes + +* inconsistent plugin activation behaviour caused by refactoring ([#1626](https://github.com/openscd/open-scd/issues/1626)) ([00c4dc0](https://github.com/openscd/open-scd/commit/00c4dc06f6d0cf1c39e4822a5b21d650d698785e)) +* update release please version to 0.37.2 ([#1632](https://github.com/openscd/open-scd/issues/1632)) ([a3d6d2f](https://github.com/openscd/open-scd/commit/a3d6d2f68952e98d62375b037b5b36bca63f325a)) + +## [0.37.1](https://github.com/openscd/open-scd/compare/v0.37.0...v0.37.1) (2025-02-04) + + +### Features + +* Programatic Plugin Activation ([#1611](https://github.com/openscd/open-scd/issues/1611)) ([d3b2a0a](https://github.com/openscd/open-scd/commit/d3b2a0a7b2d08d0ce5484567ebfe6c6d4e548c5e)) + +## [0.37.0](https://github.com/openscd/open-scd/compare/v0.36.0...v0.37.0) (2025-01-27) + + +### Features + +* Edit api v3 ([#1615](https://github.com/openscd/open-scd/issues/1615)) ([ce39e2b](https://github.com/openscd/open-scd/commit/ce39e2b7bfcda40659f36e40659b1efd571f2a53)) +* **monorepo:** Add contributing guide ([#1588](https://github.com/openscd/open-scd/issues/1588)) ([dd692a8](https://github.com/openscd/open-scd/commit/dd692a8d9784aaf5f8509fdad5298293195d1465)) + ## [0.36.0](https://github.com/openscd/open-scd/compare/v0.35.0...v0.36.0) (2024-11-14) diff --git a/packages/core/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 52% rename from packages/core/CONTRIBUTING.md rename to CONTRIBUTING.md index d2fbd61b22..15ef579bae 100644 --- a/packages/core/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,63 +1,71 @@ -# Contributing to OpenSCD Core +# OpenSCD Contributing Guide -Thanks for taking the time to contribute to the OpenSCD project! +Hi! We're really excited that you're interested in contributing to OpenSCD! Before submitting your contribution, please read through the following guide. -The easiest way to get in touch is to join the `#open-scd` channel kindly hosted -on [the LF Energy Slack server](https://slack.lfenergy.org/). If you say "hi" -there we will be more than happy to help you find your way around this project. +The easiest way to get in touch is to join us on the [↗ Zulip Chat](https://openscd.zulipchat.com/join/k3cyur3wx526tvafkjwubhjn/). +If you say "hi" there we will be more than happy to help you find your way around this project. ## Non-Code Contributions You don't need to be a software developer to contribute to this effort! Apart from contributions in the form of code we are also very thankful for -- [bug reports]( -https://github.com/openscd/open-scd-core/issues?q=is%3Aopen+label%3Abug) +- [bug reports](https://github.com/openscd/open-scd/issues?q=is%3Aopen+type%3ABug) alerting us of errors in the `open-scd` component or its `foundation` library functions, -- [ideas for enhancements]( -https://github.com/openscd/open-scd-core/discussions/categories/ideas) +- [ideas for enhancements](https://github.com/openscd/open-scd/issues?q=is%3Aopen+type%3AFeature) to `open-scd` or its `foundation` library, -- [contributions to discussions]( -https://github.com/openscd/open-scd-core/discussions) - we're having about which direction the project should take, and - [improvements to our wiki](https://github.com/openscd/open-scd/wiki) which contains knowledge about how to use both OpenSCD and SCL in general. ## Code Contributions -> The following is a set of guidelines for contributing to -> [OpenSCD Core](https://github.com/openscd/open-scd-core#readme), not a list of -> strict rules. Use your best judgment and feel free to propose changes to this -> document in a pull request. +> [!NOTE] +> The following is a set of guidelines for contributing to [OpenSCD](https://github.com/openscd/open-scd#readme), not a list of strict rules. +> Use your best judgment and feel free to propose changes to this document in a pull request. -### Code Structure +### Repo Setup -The OpenSCD Core project's [NPM package declaration file]( -https://github.com/openscd/open-scd-core/blob/main/package.json) -lists two entry points that can be referred to by package users: +To develop locally, fork the OpenSCD repository and clone it in your local machine. The OpenSCD repo is a [↗ monorepo](https://en.wikipedia.org/wiki/Monorepo) using pnpm workspaces. The package manager used to install and link dependencies must be [↗ npm](https://docs.npmjs.com/cli/using-npm/workspaces). -```json - "exports": { - ".": "dist/foundation.js", - "/open-scd.js": "dist/open-scd.js" - }, -``` +To find out more about the development of each packages, such as the base distribution or the plugins, please refer to their respective READMEs: +- [open-scd](packages/openscd/README.md): provides the base distribution available on [openscd.github.io](https://openscd.github.io) +- [core](packages/core/README.md): provides the agreed api of OpenSCD Core + + +To develop, follow these steps : + +1. Install [↗ Node.js](https://nodejs.org/en/download/package-manager) + +> [!IMPORTANT] +> `Node.js` version should be set to `20.x.x` as there are incompatibilities with higher version + +2. Run `npm ci` in OpenSCD's root folder. + +3. Run `npm run build` in OpenSCD's root folder. + +4. Run `npm start` in OpenSCD's root folder. -`foundation.ts` defines a host of types, utility functions, and constants which -we hope will be useful for writing plugins that edit SCL files. +> [!NOTE] +> If you run in the following error : +> `Lerna (powered by Nx) Daemon process terminated and closed the connection` +> Rerun `npm start` and it should work as expected -`open-scd.ts` defines a custom element ``, a [web component]( -https://developer.mozilla.org/en-US/docs/Web/Web_Components) -implemented as a [LitElement](https://lit.dev/docs) extended with our own -[Mixins](https://lit.dev/docs/composition/mixins). +To test, follow these steps : + +1. Install a compatible compatible [↗ playright](https://playwright.dev/docs/browsers#introduction) browser +2. Run `npx playwright install` in OpenSCD's root folder, to install a compatible `playright` browser libraries + +> [!NOTE] +> If you are using `chromium`, you might need to add : +> `CHROME_PATH=path-to-your-chromium-app` in your .env file in OpenSCD's root folder, see `.env.example`. ### Commit Messages * Use the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format for commit messages. - - > A commit should contain only one single change, so you should always be - > able to find a fitting type. + + > [!NOTE] + > A commit should contain only one single change, so you should always be able to find a fitting type. * Use the present tense ("feat: add feature" not "feat: added feature") * Use the imperative mood ("fix: move cursor to..." not "fix: moves cursor to...") @@ -66,84 +74,55 @@ implemented as a [LitElement](https://lit.dev/docs) extended with our own ### Contributing Workflow and Branching Strategy -We like to receive code contributions through the [Forking Workflow]( -https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow), -which means every contributor maintains their own independent fork and sends -pull requests directly from their own copy of the repo. This enables -contributors to work as independently as possible, with the only point of -coordination happening when a maintainer merges the incoming pull request. +We like to receive code contributions through the [Forking Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow), which means every contributor maintains their own independent fork and sends pull requests directly from their own copy of the repo. This enables contributors to work as independently as possible, with the only point of coordination happening when a maintainer merges the incoming pull request. -A pull request should generally only ever contain at most one `fix` or `feat` -commit, and never both. If you have several different bugs to fix or features to -introduce, please create a separate pull request for each one. If a single bug -fix or feature took you several commits to achieve, please squash those commits -into one using an interactive rebase (see the great tutorial linked under -"Forking Workflow" above) before submitting your pull request. +A pull request should generally only ever contain at most one `fix` or `feat` commit, and never both. If you have several different bugs to fix or features to introduce, please create a separate pull request for each one. If a single bug fix or feature took you several commits to achieve, please squash those commits into one using an interactive rebase (see the great tutorial linked under "Forking Workflow" above) before submitting your pull request. -Please make sure that all CI checks are passing before marking your pull request -"Ready for review". +Please make sure that all CI checks are passing before marking your pull request "Ready for review". ### Filenames -If a file defines a custom element, it should always be named after its tag name -(e.g. `my-component.ts`). Otherwise, files should generally be named after the -most important symbol they export (e.g. `MyClass.ts`). +If a file defines a custom element, it should always be named after its tag name (e.g. `my-component.ts`). Otherwise, files should generally be named after the most important symbol they export (e.g. `MyClass.ts`). ### Code Style and Linting -We use eslint and prettier for formatting and linting. Both are run as part of -a `husky` pre-commit hook defined in `package.json`. Nonetheless, we recommend -you use your editor's or IED's eslint and prettier plugins for continuous -formatting and linting while writing the code in order to avoid any surprises. +We use eslint and prettier for formatting and linting. We recommend you use your editor's or IED's eslint and prettier plugins for continuous formatting and linting while writing the code in order to avoid any surprises. -Apart from the rules the linter and formatter enforce, we adopt the following -guidelines taken from the terse but broad [Deno Style Guide]( -https://deno.land/manual/contributing/style_guide) with some minor adjustments: +Apart from the rules the linter and formatter enforce, we adopt the following guidelines taken from the terse but broad [Deno Style Guide](https://deno.land/manual/contributing/style_guide) with some minor adjustments: #### TODO Comments -In general, don't commit TODO or FIXME comments. Their significance tends to get -lost in the mists of time and they cause more confusion than anything else. +In general, don't commit TODO or FIXME comments. Their significance tends to get lost in the mists of time and they cause more confusion than anything else. -If you are tempted to write a FIXME comment, please consider fixing the code -immediately instead. If this is absolutely not possible, create a bug issue -referencing your pull request which introduces the bug. +If you are tempted to write a FIXME comment, please consider fixing the code immediately instead. If this is absolutely not possible, create a bug issue referencing your pull request which introduces the bug. -If you are tempted to write a TODO comment, please consider opening an issue -describing the changes to be made instead. +If you are tempted to write a TODO comment, please consider opening an issue describing the changes to be made instead. -If you still find it helpful to introduce a TODO comment, please include an -issue or at least the author's github username in parentheses. Example: +If you still find it helpful to introduce a TODO comment, please include an issue or at least the author's github username in parentheses. Example: ```ts // TODO(ry): Add tests. // TODO(#123): Support Windows. // FIXME(#349): Sometimes panics. ``` - #### Exported functions: max 2 args, put the rest into an options object. When designing function interfaces, stick to the following rules. -1. A function that is part of the public API takes 0-2 required arguments, plus - (if necessary) an options object (so max 3 total). +1. A function that is part of the public API takes 0-2 required arguments, plus (if necessary) an options object (so max 3 total). 2. Optional parameters should generally go into the options object. - An optional parameter that's not in an options object might be acceptable if - there is only one, and it seems inconceivable that we would add more optional - parameters in the future. + An optional parameter that's not in an options object might be acceptable if there is only one, and it seems inconceivable that we would add more optional parameters in the future. 3. The 'options' argument is the only argument that is a regular 'Object'. - Other arguments can be objects, but they must be distinguishable from a - 'plain' Object runtime, by having either: + Other arguments can be objects, but they must be distinguishable from a 'plain' Object runtime, by having either: - - a distinguishing prototype (e.g. `Array`, `Map`, `Date`, `class MyThing`). - - a well-known symbol property (e.g. an iterable with `Symbol.iterator`). + - a distinguishing prototype (e.g. `Array`, `Map`, `Date`, `class MyThing`). + - a well-known symbol property (e.g. an iterable with `Symbol.iterator`). - This allows the API to evolve in a backwards compatible way, even when the - position of the options object changes. + This allows the API to evolve in a backwards compatible way, even when the position of the options object changes. ```ts, ignore // BAD: optional parameters not part of options object. (#2) @@ -230,12 +209,9 @@ export interface PWrite { } export function pwrite(options: PWrite) {} ``` - #### Export all interfaces that are used as parameters to an exported member -Whenever you are using interfaces that are included in the parameters or return -type of an exported member, you should export the interface that is used. Here -is an example: +Whenever you are using interfaces that are included in the parameters or return type of an exported member, you should export the interface that is used. Here is an example: ```ts, ignore // my-file.ts @@ -252,7 +228,6 @@ export function createPerson(name: string, age: number): Person { export { createPerson } from "./my-file.js"; export type { Person } from "./my-file.js"; ``` - #### Minimize dependencies; do not make circular imports. Try not to introduce external dependencies if you can avoid doing so. @@ -260,14 +235,11 @@ In particular, be careful not to introduce circular imports. #### If a filename starts with an underscore: `_foo.ts`, do not link to it. -There may be situations where an internal module is necessary but its API is not -meant to be stable or linked to. In this case prefix it with an underscore. By -convention, only files in its own directory should import it. +There may be situations where an internal module is necessary but its API is not meant to be stable or linked to. In this case prefix it with an underscore. By convention, only files in its own directory should import it. #### Use JSDoc for exported symbols. -We strive for complete documentation. Every exported symbol ideally should have -a documentation line. +We strive for complete documentation. Every exported symbol ideally should have a documentation line. If possible, use a single line for the JSDoc. Example: @@ -278,10 +250,7 @@ export function foo() { } ``` -It is important that documentation is easily human-readable, but there is also a -need to provide additional styling information to ensure generated documentation -is more rich text. Therefore JSDoc should generally follow markdown markup to -enrich the text. +It is important that documentation is easily human-readable, but there is also a need to provide additional styling information to ensure generated documentation is more rich text. Therefore JSDoc should generally follow markdown markup to enrich the text. While markdown supports HTML tags, it is forbidden in JSDoc blocks. @@ -292,10 +261,8 @@ For example: /** Import something from the `foundation` module. */ ``` -Do not document function arguments unless they are non-obvious of their intent -(though if they are non-obvious intent, the API should be considered anyways). -Therefore `@param` should generally not be used. If `@param` is used, it should -not include the `type` as TypeScript is already strongly-typed. +Do not document function arguments unless they are non-obvious of their intent (though if they are non-obvious intent, the API should be considered anyways). +Therefore `@param` should generally not be used. If `@param` is used, it should not include the `type` as TypeScript is already strongly-typed. ```ts /** @@ -304,8 +271,7 @@ not include the `type` as TypeScript is already strongly-typed. */ ``` -Vertical spacing should be minimized whenever possible. Therefore, single-line -comments should be written as: +Vertical spacing should be minimized whenever possible. Therefore, single-line comments should be written as: ```ts /** This is a good single-line JSDoc. */ @@ -331,14 +297,11 @@ Code examples should utilize markdown format, like so: ```` Code examples should not contain additional comments and must not be indented. -It is already inside a comment. If it needs further comments, it is not a good -example. +It is already inside a comment. If it needs further comments, it is not a good example. #### Resolve linting problems using directives -Currently, the building process uses `eslint` to lint the code. If the task -requires code that is non-conformant to linter use `eslint-disable-next-line -` directive to suppress the warning. +Currently, the building process uses `eslint` to lint the code. If the task requires code that is non-conformant to linter use `eslint-disable-next-line` directive to suppress the warning. ```typescript /** Constructor type for defining `LitElement` mixins. */ @@ -346,18 +309,15 @@ requires code that is non-conformant to linter use `eslint-disable-next-line export type LitElementConstructor = new (...args: any[]) => LitElement; ``` -This ensures the continuous integration process doesn't fail due to linting -problems, but it should be used scarcely. +This ensures the continuous integration process doesn't fail due to linting problems, but it should be used scarcely. #### Each module should come with a test module. -Every module with public functionality `foo.ts` should come with a test module -`foo.spec.ts`. This file should be a sibling to the tested module. +Every module with public functionality `foo.ts` should come with a test module `foo.spec.ts`. This file should be a sibling to the tested module. #### Top-level functions should not use arrow syntax. -Top-level functions should use the `function` keyword. Arrow syntax should be -limited to closures. +Top-level functions should use the `function` keyword. Arrow syntax should be limited to closures. Bad: @@ -374,13 +334,9 @@ export function foo(): string { return "bar"; } ``` - #### Prefer `#` over `private` -We prefer the private fields (`#`) syntax over `private` keyword of TypeScript -in the standard modules codebase. The private fields make the properties and -methods private even at runtime. On the other hand, `private` keyword of -TypeScript guarantee it private only at compile time and the fields are publicly +We prefer the private fields (`#`) syntax over `private` keyword of TypeScript in the standard modules codebase. The private fields make the properties and methods private even at runtime. On the other hand, `private` keyword of TypeScript guarantee it private only at compile time and the fields are publicly accessible at runtime. Good: @@ -399,4 +355,4 @@ class MyClass { private foo = 1; private bar() {} } -``` +``` \ No newline at end of file diff --git a/docs/core-api/edit-api.md b/docs/core-api/edit-api.md index e5e3a459f9..3029221682 100644 --- a/docs/core-api/edit-api.md +++ b/docs/core-api/edit-api.md @@ -1,6 +1,6 @@ -# Edit Event API +# Edit Event API v2 -Open SCD offers an API for editing the scd document which can be used with [Html Custom Events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent). The main Open SCD components listens to events of the type `oscd-edit`, applies the changes to the `doc` and updates the `editCount` property. +Open SCD offers an API for editing the scd document which can be used with [Html Custom Events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent). The main Open SCD components listens to events of the type `oscd-edit-v2`, applies the changes to the `doc` and updates the `editCount` property. The edits to the `doc` will be done in place, e.g. the `doc` changes but will keep the same reference. If your plugin needs to react to changes in the doc, you should listen to changes in the `editCount` property. @@ -8,6 +8,116 @@ The edits to the `doc` will be done in place, e.g. the `doc` changes but will ke Open SCD core exports a factory function for edit events, so you do not have to build them manually. +```ts +function newEditEventV2( + edit: E, + options?: EditEventOptionsV2 +): EditEventV2 + +type EditV2 = InsertV2 | SetAttributesV2 | SetTextContentV2 | RemoveV2 | EditV2[]; + +interface EditEventOptionsV2 = { + title?: string; + squash?: boolean; + createHistoryEntry?: boolean; +}; +``` + +### EditEventOptionsV2 + +* `title` set a title to be shown in the history. +* `squash` squash edit with previous history entry, this is useful if you want to create multiple edits based on an user action, but need the updated `doc` before applying each edit. Defaults to `false`. +* `createHistoryEntry` decides whether a history for the `edit` should be created. Defaults to `true`. + +### Insert + +Insert events can be used to add new nodes or move existing nodes in the document. Since a node can only have one parent, using an insert on an existing node will replace it's previous parent with the new parent, essentially moving the node to a different position in the xml tree. + +If the reference is not `null`, the node will be inserted before the reference node. The reference has to be a child node of the parent. And if the reference is `null` the node will be added as the last child of the parent. + +```ts +interface InsertV2 { + parent: Node; + node: Node; + reference: Node | null; +} +``` + +### Remove + +This event will remove the node from the document. + +```ts +interface RemoveV2 { + node: Node; +} +``` + +### SetAttributes + +Sets attributes for the element, can set both regular and namespaced attributes. + +```ts +interface SetAttributesV2 { + element: Element; + attributes: Partial>; + attributesNS: Partial>>>; +} +``` + +To set a namespaced attribute see the following example. Here we are setting the attribute `exa:type` for the namespace `https://example.com` to `secondary`. + +```ts +const setNamespacedAttributes: SetAttributesV2 = { + element, + attributes: {}, + attributesNS: { + "https://example.com": { + "exa:type": "secondary" + } + } +} +``` + +### SetTextContent + +Sets the text content of the element, removes any other children. To remove text content you can pass `null` as value for `textContent`. + +```ts +interface SetTextContentV2 { + element: Element; + textContent: string; +} +``` + +### Complex edits + +Complex edits can be used to apply multiple edits as a single event. This will create a single entry in the history. You can create complex edit events by passing an array of edit events to the `newEditEventV2` factory function. + +```ts +import { newEditEventV2 } from '@openscd/core'; + +const complexEditEvent = newEditEventV2([ insert, update, remove ]); + +someComponent.dispatchEvent(complexEditEvent); + +``` + +## History + +All edit events with the option `createHistoryEntry` will create a history log entry and can be undone and redone through the history addon. + + +# Archives + +## Edit Event API v1 (deprecated) + +The edit event API v1 is still available and listens to events of the type `oscd-edit`. + +## Event factory + +Open SCD core exports a factory function for edit events, so you do not have to build them manually. + ```ts function newEditEvent( edit: E, @@ -158,7 +268,7 @@ With open SCD version **v0.36.0** and higher some editor action features are no --- -# Archives - Editor Action API (deprecated) +## Editor Action API (deprecated) ### Event factory diff --git a/docs/decisions/0003-extract-plugins.md b/docs/decisions/0003-extract-plugins.md new file mode 100644 index 0000000000..230710cd8a --- /dev/null +++ b/docs/decisions/0003-extract-plugins.md @@ -0,0 +1,36 @@ +# ADR-0003 - Externalize OpenSCD core plugins + +Date: 2024-11-19 + +## Status + +Approved + +## Context + +For a better expandability we would like to extract all plugins in a new plugins repository. + +## Decision + +Following the architectural decision in [OpenSCD Theming](./../0001-ADR-Theming.md) we will extract all OpenSCD Core plugins to an external repository. +Doing so OpenSCD Core will be streamlined and a clean interface and structure for plugins will be provided for custom extensions. +Before extracting this plugins a shared UI-Components module will be extracted. This UI-Components provide reusable UI-Components based on [NX](https://nx.dev/) for faster development for OpenSCD Core and custom plugins. This new repository will be created as mono repository to facility the plugins development and simplify the release and deployment process. + +Plugins will be moved to repository [OpenSCD official Plugins](https://github.com/openscd/oscd-official-plugins) and the release strategy is defined [here](./0004-openscd-release-and-deploy-strategy.md). +As final task the current documentation will be added with a new section `How to add new and custom OpenSCD plugins` to support developers to follow the concept. + +## Consequences + +- Clean Code in OpenSCD Core +- Clear architectural structure of plugins + +- Building OpenSCD is more then building a simple repository +- Clear path must be defined how to extend OpenSCD with custom plugins (full software cycle till deployment) +- Release process for OpenSCD Core and OpenSCD official plugins + +## Agreed procedure + +- move the plugins without any components abstraction to the external plugins repository + - copy all required dependencies regardless of code duplication +- integrate the plugins as submodules within OpenSCD core in the pipeline +- later on we can extract step by step for each plugin UI-Components diff --git a/docs/decisions/0004-openscd-release-and-deployment-strategy.md b/docs/decisions/0004-openscd-release-and-deployment-strategy.md new file mode 100644 index 0000000000..1c186a4d5c --- /dev/null +++ b/docs/decisions/0004-openscd-release-and-deployment-strategy.md @@ -0,0 +1,32 @@ +# ADR-0004 - Technical solution for releasing and deployments + +Date: 2024-11-19 + +## Status + +Approved + +## Context + +Based on the [decision](./0003-extract-plugins.md) to externalize plugins in proper plugins repository a new release and deployment strategy needs to be defined. +This plugins repository is solved as mono repository. + +## Decision + +### Release process + +Since OpenSCD is based on [NX](https://nx.dev/) the release strategy needs to rely on NX as well and must allow single releases of sub modules within this mono repository. +Such feature is provided by [NX release](https://nx.dev/recipes/nx-release) specially when using the [NX independently release feature](https://nx.dev/recipes/nx-release/release-projects-independently). + +A possible release command would look like: +``` + nx release --projects=plugin-1,plugin-3 +``` + +## Consequences + +- Process needs to be documented so that all developers can easily follow it +- The building of complete OpenSCD Editor, OpenSCD Core + OpenSCD plugins, depends now on two repositories +- Custom OpenSCD eg. CoMPAS OpenSCD will be cleaner and more code can be reused +- Similar Look & Feel of plugins if shared UI-Components are used +- Faster plugin development and integration into OpenSCD Core diff --git a/package-lock.json b/package-lock.json index dfafdf242d..748600063f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23656,7 +23656,7 @@ }, "packages/core": { "name": "@openscd/core", - "version": "0.1.2", + "version": "0.1.4", "license": "Apache-2.0", "dependencies": { "@lit/localize": "^0.11.4", diff --git a/packages/compas-open-scd/package.json b/packages/compas-open-scd/package.json index 41d0b6d49f..3e7683c641 100644 --- a/packages/compas-open-scd/package.json +++ b/packages/compas-open-scd/package.json @@ -1,6 +1,6 @@ { "name": "compas-open-scd", - "version": "0.36.0-4", + "version": "0.38.0-1", "repository": "https://github.com/openscd/open-scd.git", "description": "OpenSCD CoMPAS Edition", "directory": "packages/compas-open-scd", @@ -71,7 +71,8 @@ "build:test": "npm run test && npm run build && cp .nojekyll build/", "build": "npm run doc && npm run build:only && cp .nojekyll build/", "build:only": "snowpack build && workbox generateSW workbox-config.cjs", - "start": "snowpack dev" + "__comment:start": "snowpack dev fails if the lit package is cached. I don't know why, but we have to delete it before starting", + "start": "npx rimraf node_modules/.cache/snowpack/build/lit@2.8.0 && snowpack dev" }, "devDependencies": { "@commitlint/cli": "^13.1.0", diff --git a/packages/compas-open-scd/public/js/plugins.js b/packages/compas-open-scd/public/js/plugins.js index db1aacbc63..da63bd3acf 100644 --- a/packages/compas-open-scd/public/js/plugins.js +++ b/packages/compas-open-scd/public/js/plugins.js @@ -3,126 +3,143 @@ export const officialPlugins = [ name: 'IED', src: '/plugins/src/editors/IED.js', icon: 'developer_board', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Substation', src: '/plugins/src/editors/Substation.js', icon: 'margin', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Single Line Diagram', src: '/plugins/src/editors/SingleLineDiagram.js', icon: 'edit', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Message Binding (GOOSE)', src: '/plugins/src/editors/GooseSubscriberMessageBinding.js', icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Data Binding (GOOSE)', src: '/plugins/src/editors/GooseSubscriberDataBinding.js', icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Later Binding (GOOSE)', src: '/plugins/src/editors/GooseSubscriberLaterBinding.js', icon: 'link', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Message Binding (SMV)', src: '/plugins/src/editors/SMVSubscriberMessageBinding.js', icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Data Binding (SMV)', src: '/plugins/src/editors/SMVSubscriberDataBinding.js', icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscriber Later Binding (SMV)', src: '/plugins/src/editors/SMVSubscriberLaterBinding.js', icon: 'link', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Communication', src: '/plugins/src/editors/Communication.js', icon: 'settings_ethernet', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: '104', src: '/plugins/src/editors/Protocol104.js', icon: 'settings_ethernet', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Templates', src: '/plugins/src/editors/Templates.js', icon: 'copy_all', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'CoMPAS Versions', src: '/src/compas-editors/CompasVersions.js', icon: 'copy_all', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Publisher', src: '/external-plugins/oscd-publisher/oscd-publisher.js', icon: 'publish', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Communication Explorer', src: '/external-plugins/oscd-plugins/communication-explorer/0.0.31/index.js', icon: 'lan', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true, }, { name: 'Cleanup', src: '/plugins/src/editors/Cleanup.js', icon: 'cleaning_services', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true, }, { name: 'Subscribe (Later Binding)', src: '/external-plugins/oscd-subscriber-later-binding/oscd-subscriber-later-binding.js', icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', + requireDoc: true }, { name: 'Open project', src: '/src/menu/CompasOpen.js', icon: 'folder_open', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'top', @@ -131,7 +148,7 @@ export const officialPlugins = [ name: 'New project', src: '/plugins/src/menu/NewProject.js', icon: 'create_new_folder', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'top', @@ -140,7 +157,7 @@ export const officialPlugins = [ name: 'Project from CIM', src: '/src/menu/CompasCimMapping.js', icon: 'input', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'top', @@ -149,7 +166,7 @@ export const officialPlugins = [ name: 'Import from API', src: '/src/menu/CompasImportFromApi.js', icon: 'cloud_download', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: false, position: 'top', @@ -158,7 +175,7 @@ export const officialPlugins = [ name: 'Save project', src: '/src/menu/CompasSave.js', icon: 'save', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'top', @@ -167,7 +184,7 @@ export const officialPlugins = [ name: 'Save project as', src: '/src/menu/CompasSaveAs.js', icon: 'save', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'top', @@ -176,7 +193,7 @@ export const officialPlugins = [ name: 'Save as version', src: '/src/menu/CompasSaveAsVersion.js', icon: 'save', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'top', @@ -185,28 +202,28 @@ export const officialPlugins = [ name: '[WIP] Validate using OCL', src: '/src/validators/CompasValidateSchema.js', icon: 'rule_folder', - default: false, - kind: 'validator', + activeByDefault: false, + kind: 'validator' }, { name: 'Validate Schema', src: '/plugins/src/validators/ValidateSchema.js', icon: 'rule_folder', - default: true, + activeByDefault: true, kind: 'validator', }, { name: 'Validate Templates', src: '/plugins/src/validators/ValidateTemplates.js', icon: 'rule_folder', - default: true, + activeByDefault: true, kind: 'validator', }, { name: 'Import IEDs', src: '/src/menu/CompasImportIEDs.js', icon: 'snippet_folder', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -215,7 +232,7 @@ export const officialPlugins = [ name: 'Create Virtual IED', src: '/plugins/src/menu/VirtualTemplateIED.js', icon: 'developer_board', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -223,7 +240,7 @@ export const officialPlugins = [ { name: 'Subscriber Update', src: '/plugins/src/menu/SubscriberInfo.js', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -231,7 +248,7 @@ export const officialPlugins = [ { name: 'Update desc (ABB)', src: '/plugins/src/menu/UpdateDescriptionABB.js', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -239,7 +256,7 @@ export const officialPlugins = [ { name: 'Update desc (SEL)', src: '/plugins/src/menu/UpdateDescriptionSEL.js', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -248,7 +265,7 @@ export const officialPlugins = [ name: 'Merge Project', src: '/src/menu/CompasMerge.js', icon: 'merge_type', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -257,7 +274,7 @@ export const officialPlugins = [ name: 'Update Substation', src: '/src/menu/CompasUpdateSubstation.js', icon: 'merge_type', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -266,7 +283,7 @@ export const officialPlugins = [ name: 'Compare IED', src: '/src/menu/CompasCompareIED.js', icon: 'compare_arrows', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -275,7 +292,7 @@ export const officialPlugins = [ name: 'Auto Align SLD', src: '/src/menu/CompasAutoAlignment.js', icon: 'dashboard', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -284,7 +301,7 @@ export const officialPlugins = [ name: 'Export IED Params', src: '/src/menu/ExportIEDParams.js', icon: 'download', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -293,7 +310,7 @@ export const officialPlugins = [ name: 'Locamation VMU', src: '/src/menu/LocamationVMU.js', icon: 'edit_note', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -302,7 +319,7 @@ export const officialPlugins = [ name: 'Show SCL History', src: '/plugins/src/menu/SclHistory.js', icon: 'history_toggle_off', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'bottom', @@ -311,7 +328,7 @@ export const officialPlugins = [ name: 'CoMPAS Settings', src: '/src/menu/CompasSettings.js', icon: 'settings', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'bottom', @@ -320,7 +337,7 @@ export const officialPlugins = [ name: 'Help', src: '/plugins/src/menu/Help.js', icon: 'help', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'bottom', @@ -329,7 +346,7 @@ export const officialPlugins = [ name: 'Export Communication Section', src: '/plugins/src/menu/ExportCommunication.js', icon: 'sim_card_download', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -338,14 +355,15 @@ export const officialPlugins = [ name: 'Sitipe', src: '/src/compas-editors/Sitipe.js', icon: 'precision_manufacturing', - default: true, + activeByDefault: true, kind: 'editor', + requireDoc: true }, { name: 'Autogen Substation', src: '/src/compas-editors/autogen-substation.js', icon: 'playlist_add_circle', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -354,7 +372,7 @@ export const officialPlugins = [ name: 'Export IEC 104 CSV', src: '/plugins/src/menu/Export104.js', icon: 'sim_card_download', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', diff --git a/packages/compas-open-scd/src/addons/CompasLayout.ts b/packages/compas-open-scd/src/addons/CompasLayout.ts index 09f688e039..7db59a7689 100644 --- a/packages/compas-open-scd/src/addons/CompasLayout.ts +++ b/packages/compas-open-scd/src/addons/CompasLayout.ts @@ -16,15 +16,9 @@ import { Validator, MenuPlugin, pluginIcons, - newResetPluginsEvent, - newAddExternalPluginEvent, - newSetPluginsEvent, } from '@openscd/open-scd/src/open-scd.js'; import { Plugin, - PluginKind, - MenuPosition, - menuPosition } from '@openscd/open-scd/src/plugin.js' import { HistoryUIKind, @@ -37,11 +31,6 @@ import type { Drawer } from '@material/mwc-drawer'; import type { ActionDetail } from '@material/mwc-list'; import { List } from '@material/mwc-list'; import type { ListItem } from '@material/mwc-list/mwc-list-item'; -import type { Dialog } from '@material/mwc-dialog'; -import type { MultiSelectedEvent } from '@material/mwc-list/mwc-list-foundation.js'; -import type { Select } from '@material/mwc-select'; -import type { Switch } from '@material/mwc-switch'; -import type { TextField } from '@material/mwc-textfield'; import '@material/mwc-drawer'; import '@material/mwc-list'; @@ -49,9 +38,15 @@ import '@material/mwc-dialog'; import '@material/mwc-switch'; import '@material/mwc-select'; import '@material/mwc-textfield'; -import { EditCompletedEvent } from '@openscd/core'; import type { UserInfoEvent } from '../compas/foundation'; - +import { HistoryState } from '@openscd/open-scd/src/addons/History.js'; +import { OscdPluginManager } from '@openscd/open-scd/src/addons/plugin-manager/plugin-manager'; +import '@openscd/open-scd/src/addons/plugin-manager/plugin-manager'; +import { OscdCustomPluginDialog } from '@openscd/open-scd/src/addons/plugin-manager/custom-plugin-dialog'; +import '@openscd/open-scd/src/addons/plugin-manager/custom-plugin-dialog'; +import { nothing } from 'lit'; + +// TODO: What happens with this? export function compasOpenMenuEvent(): CustomEvent { return new CustomEvent('open-drawer', { bubbles: true, composed: true }); } @@ -59,65 +54,77 @@ export function compasOpenMenuEvent(): CustomEvent { @customElement('compas-layout') export class CompasLayout extends LitElement { /** The `XMLDocument` to be edited */ - @property({ attribute: false }) - doc: XMLDocument | null = null; + @property({ attribute: false }) doc: XMLDocument | null = null; /** The name of the current [[`doc`]] */ - @property({ type: String }) - docName = ''; + @property({ type: String }) docName = ''; /** Index of the last [[`EditorAction`]] applied. */ - @property({ type: Number }) - editCount = -1; + @property({ type: Number }) editCount = -1; /** The currently active editor tab. */ - @property({ type: Number }) - activeTab = 0; + @property({ type: Number }) activeTab = 0; /** The plugins to render the layout. */ - @property({ type: Array }) - plugins: Plugin[] = []; + @property({ type: Array }) plugins: Plugin[] = []; /** The open-scd host element */ - @property({ type: Object }) - host!: HTMLElement; + @property({ type: Object }) host!: HTMLElement; + + @property({ type: Object }) historyState!: HistoryState; - @property({ type: String }) - username: string | undefined; + @property({ type: String }) username: string | undefined; - @state() - validated: Promise = Promise.resolve(); + @state() validated: Promise = Promise.resolve(); - @state() - shouldValidate = false; + @state() shouldValidate = false; - @state() - redoCount = 0; + @query('#menu') menuUI!: Drawer; + @query('#pluginManager') pluginUI!: OscdPluginManager; + @query('#pluginList') pluginList!: List; + @query('#pluginAdd') pluginDownloadUI!: OscdCustomPluginDialog; - get canUndo(): boolean { - return this.editCount >= 0; + render(): TemplateResult { + return html` +
this.pluginDownloadUI.show()} + @oscd-activate-editor=${this.handleActivateEditorByEvent} + @oscd-run-menu=${this.handleRunMenuByEvent} + > + + ${this.renderHeader()} ${this.renderAside()} ${this.renderContent()} + ${this.renderLanding()} ${this.renderPlugging()} +
+ `; } - get canRedo(): boolean { - return this.redoCount > 0; + private renderPlugging(): TemplateResult { + return html` ${this.renderPluginUI()} ${this.renderDownloadUI()} `; + } + + /** Renders the "Add Custom Plug-in" UI*/ + protected renderDownloadUI(): TemplateResult { + return html` + + ` } - @query('#menu') - menuUI!: Drawer; - @query('#pluginManager') - pluginUI!: Dialog; - @query('#pluginList') - pluginList!: List; - @query('#pluginAdd') - pluginDownloadUI!: Dialog; + /** + * Renders the plug-in management UI (turning plug-ins on/off) + */ + protected renderPluginUI(): TemplateResult { + return html` + + ` + } // Computed properties get validators(): Plugin[] { return this.plugins.filter( - plugin => plugin.installed && plugin.kind === 'validator' + plugin => plugin.active && plugin.kind === 'validator' ); } get menuEntries(): Plugin[] { return this.plugins.filter( - plugin => plugin.installed && plugin.kind === 'menu' + plugin => plugin.active && plugin.kind === 'menu' ); } get topMenu(): Plugin[] { @@ -131,96 +138,10 @@ export class CompasLayout extends LitElement { } get menu(): (MenuItem | 'divider')[] { - const topMenu: (MenuItem | 'divider')[] = []; - const middleMenu: (MenuItem | 'divider')[] = []; - const bottomMenu: (MenuItem | 'divider')[] = []; - const validators: (MenuItem | 'divider')[] = []; - - this.topMenu.forEach(plugin => - topMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'top', - }) - ); - - this.middleMenu.forEach(plugin => - middleMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'middle', - }) - ); - - this.bottomMenu.forEach(plugin => - bottomMenu.push({ - icon: plugin.icon || pluginIcons['menu'], - name: plugin.name, - action: ae => { - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).run() - ) - ); - }, - disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, - kind: 'middle', - }) - ); - - this.validators.forEach(plugin => - validators.push({ - icon: plugin.icon || pluginIcons['validator'], - name: plugin.name, - action: ae => { - this.dispatchEvent(newEmptyIssuesEvent(plugin.src)); - - this.dispatchEvent( - newPendingStateEvent( - (( - (( - (ae.target).items[ae.detail.index].nextElementSibling - )) - )).validate() - ) - ); - }, - disabled: (): boolean => this.doc === null, - content: plugin.content, - kind: 'validator', - }) - ); + const topMenu = this.generateMenu(this.topMenu, 'top'); + const middleMenu = this.generateMenu(this.middleMenu, 'middle'); + const bottomMenu = this.generateMenu(this.bottomMenu, 'bottom'); + const validators = this.generateValidatorMenus(this.validators); if (middleMenu.length > 0) middleMenu.push('divider'); if (bottomMenu.length > 0) bottomMenu.push('divider'); @@ -236,8 +157,9 @@ export class CompasLayout extends LitElement { action: (): void => { this.dispatchEvent(newUndoEvent()); }, - disabled: (): boolean => !this.canUndo, + disabled: (): boolean => !this.historyState.canUndo, kind: 'static', + content: () => html``, }, { icon: 'redo', @@ -246,8 +168,9 @@ export class CompasLayout extends LitElement { action: (): void => { this.dispatchEvent(newRedoEvent()); }, - disabled: (): boolean => !this.canRedo, + disabled: (): boolean => !this.historyState.canRedo, kind: 'static', + content: () => html``, }, ...validators, { @@ -258,6 +181,7 @@ export class CompasLayout extends LitElement { this.dispatchEvent(newHistoryUIEvent(true, HistoryUIKind.log)); }, kind: 'static', + content: () => html``, }, { icon: 'history', @@ -267,6 +191,7 @@ export class CompasLayout extends LitElement { this.dispatchEvent(newHistoryUIEvent(true, HistoryUIKind.history)); }, kind: 'static', + content: () => html``, }, { icon: 'rule', @@ -276,6 +201,7 @@ export class CompasLayout extends LitElement { this.dispatchEvent(newHistoryUIEvent(true, HistoryUIKind.diagnostic)); }, kind: 'static', + content: () => html``, }, 'divider', ...middleMenu, @@ -286,6 +212,7 @@ export class CompasLayout extends LitElement { this.dispatchEvent(newSettingsUIEvent(true)); }, kind: 'static', + content: () => html``, }, ...bottomMenu, { @@ -293,82 +220,35 @@ export class CompasLayout extends LitElement { name: 'plugins.heading', action: (): void => this.pluginUI.show(), kind: 'static', + content: () => html``, }, ]; } get editors(): Plugin[] { return this.plugins.filter( - plugin => plugin.installed && plugin.kind === 'editor' + plugin => plugin.active && plugin.kind === 'editor' ); } // Keyboard Shortcuts private handleKeyPress(e: KeyboardEvent): void { - let handled = false; - const ctrlAnd = (key: string) => - e.key === key && e.ctrlKey && (handled = true); - - if (ctrlAnd('m')) this.menuUI.open = !this.menuUI.open; - if (ctrlAnd('o')) - this.menuUI - .querySelector('mwc-list-item[iconid="folder_open"]') - ?.click(); - if (ctrlAnd('O')) - this.menuUI - .querySelector('mwc-list-item[iconid="create_new_folder"]') - ?.click(); - if (ctrlAnd('s')) - this.menuUI - .querySelector('mwc-list-item[iconid="save"]') - ?.click(); - if (ctrlAnd('P')) this.pluginUI.show(); - - if (handled) e.preventDefault(); - } - - private handleAddPlugin() { - const pluginSrcInput = ( - this.pluginDownloadUI.querySelector('#pluginSrcInput') - ); - const pluginNameInput = ( - this.pluginDownloadUI.querySelector('#pluginNameInput') - ); - const pluginKindList = ( - this.pluginDownloadUI.querySelector('#pluginKindList') - ); - const requireDoc = ( - this.pluginDownloadUI.querySelector('#requireDoc') - ); - const positionList = ( - this.pluginDownloadUI.querySelector('#menuPosition') - ); - - if ( - !( - pluginSrcInput.checkValidity() && - pluginNameInput.checkValidity() && - pluginKindList.selected && - requireDoc && - positionList.selected - ) - ) - return; - - this.dispatchEvent( - newAddExternalPluginEvent({ - src: pluginSrcInput.value, - name: pluginNameInput.value, - kind: (pluginKindList.selected).value, - requireDoc: requireDoc.checked, - position: positionList.value, - installed: true, - }) - ); - - this.requestUpdate(); - this.pluginUI.requestUpdate(); - this.pluginDownloadUI.close(); - } - connectedCallback(): void { super.connectedCallback(); this.host.addEventListener('close-drawer', async () => { @@ -333,7 +311,10 @@ export class OscdLayout extends LitElement { ); }, disabled: (): boolean => plugin.requireDoc! && this.doc === null, - content: plugin.content, + content: () => { + if(plugin.content){ return plugin.content(); } + return html``; + }, kind: kind, } }) @@ -358,21 +339,23 @@ export class OscdLayout extends LitElement { ); }, disabled: (): boolean => this.doc === null, - content: plugin.content, + content: plugin.content ?? (() => html``), kind: 'validator', } }); } private renderMenuItem(me: MenuItem | 'divider'): TemplateResult { - if (me === 'divider') { return html`
  • `; } - if (me.actionItem){ return html``; } + const hasActionItem = me !== 'divider' && me.actionItem; + if (isDivider(me)) { return html`
  • `; } + if (hasActionItem){ return html``; } return html` ${me.icon} ${get(me.name)} @@ -380,7 +363,7 @@ export class OscdLayout extends LitElement { ? html`${me.hint}` : ''} - ${me.content ?? ''} + ${me.content ? me.content() : nothing} `; } @@ -456,24 +439,32 @@ export class OscdLayout extends LitElement { } + private calcActiveEditors(){ + const hasActiveDoc = Boolean(this.doc); + + return this.editors + .filter(editor => { + // this is necessary because `requireDoc` can be undefined + // and that is not the same as false + const doesNotRequireDoc = editor.requireDoc === false + return doesNotRequireDoc || hasActiveDoc + }) + } + /** Renders the enabled editor plugins and a tab bar to switch between them*/ protected renderContent(): TemplateResult { - const hasActiveDoc = Boolean(this.doc); - const activeEditors = this.editors - .filter(editor => { - // this is necessary because `requireDoc` can be undefined - // and that is not the same as false - const doesNotRequireDoc = editor.requireDoc === false - return doesNotRequireDoc || hasActiveDoc - }) - .map(this.renderEditorTab) + const activeEditors = this.calcActiveEditors() + .map(this.renderEditorTab) const hasActiveEditors = activeEditors.length > 0; if(!hasActiveEditors){ return html``; } return html` - (this.activeTab = e.detail.index)}> + ${activeEditors} ${renderEditorContent(this.editors, this.activeTab, this.doc)} @@ -487,10 +478,39 @@ export class OscdLayout extends LitElement { const content = editor?.content; if(!content) { return html`` } - return html`${content}`; + return html`${content()}`; } } + private handleActivatedEditorTabByUser(e: CustomEvent): void { + const tabIndex = e.detail.index; + this.activateTab(tabIndex); + } + + private handleActivateEditorByEvent(e: CustomEvent<{name: string, src: string}>): void { + const {name, src} = e.detail; + const editors = this.calcActiveEditors() + const wantedEditorIndex = editors.findIndex(editor => editor.name === name || editor.src === src) + if(wantedEditorIndex < 0){ return; } // TODO: log error + + this.activateTab(wantedEditorIndex); + } + + private activateTab(index: number){ + this.activeTab = index; + } + + private handleRunMenuByEvent(e: CustomEvent<{name: string}>): void { + + // TODO: this is a workaround, fix it + this.menuUI.open = true; + const menuEntry = this.menuUI.querySelector(`[data-name="${e.detail.name}"]`) as HTMLElement + const menuElement = menuEntry.nextElementSibling + if(!menuElement){ return; } // TODO: log error + + (menuElement as unknown as MenuPlugin).run() + } + /** * Renders the landing buttons (open project and new project) * it no document loaded we display the menu item that are in the position @@ -533,224 +553,6 @@ export class OscdLayout extends LitElement { } } - /** Renders the "Add Custom Plug-in" UI*/ - // TODO: this should be its own isolated element - protected renderDownloadUI(): TemplateResult { - return html` - -
    -

    - ${get('plugins.add.warning')} -

    - - - ${get('plugins.editor')}${pluginIcons['editor']} - ${get('plugins.menu')}${pluginIcons['menu']} - - - ${get('plugins.validator')}${pluginIcons['validator']} - - -
    - - this.handleAddPlugin()} - > -
    - `; - } - - // Note: why is the type here if note used? - private renderPluginKind( - type: PluginKind | MenuPosition, - plugins: Plugin[] - ): TemplateResult { - return html` - ${plugins.map( plugin => html` - ) => { - if(e.detail.source !== 'interaction'){ - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - return false; - } - }} - hasMeta - left - > - - ${plugin.icon || pluginIcons[plugin.kind]} - - ${plugin.name} - - ` - )} - `; - } - - /** - * Renders the plug-in management UI (turning plug-ins on/off) - * TODO: this is big enough to be its own isolated element - */ - protected renderPluginUI(): TemplateResult { - return html` - - - this.dispatchEvent(newSetPluginsEvent(e.detail.index))} - > - ${get(`plugins.editor`)}${pluginIcons['editor']} -
  • - ${this.renderPluginKind( - 'editor', - this.plugins.filter(p => p.kind === 'editor') - )} - ${get(`plugins.menu`)}${pluginIcons['menu']} -
  • - ${this.renderPluginKind( - 'top', - this.plugins.filter(p => p.kind === 'menu' && p.position === 'top') - )} -
  • - ${this.renderPluginKind( - 'validator', - this.plugins.filter(p => p.kind === 'validator') - )} -
  • - ${this.renderPluginKind( - 'middle', - this.plugins.filter( - p => p.kind === 'menu' && p.position === 'middle' - ) - )} -
  • - ${this.renderPluginKind( - 'bottom', - this.plugins.filter( - p => p.kind === 'menu' && p.position === 'bottom' - ) - )} -
    - { - this.dispatchEvent(newResetPluginsEvent()); - this.requestUpdate(); - }} - style="--mdc-theme-primary: var(--mdc-theme-error)" - > - - - this.pluginDownloadUI.show()} - > - -
    - `; - } - - private renderPlugging(): TemplateResult { - return html` ${this.renderPluginUI()} ${this.renderDownloadUI()} `; - } @@ -850,3 +652,7 @@ export class OscdLayout extends LitElement { } `; } + +function isDivider(item: MenuItem | 'divider'): item is 'divider' { + return item === 'divider'; +} diff --git a/packages/openscd/src/addons/editor/edit-action-to-v1-converter.ts b/packages/openscd/src/addons/editor/edit-action-to-v1-converter.ts new file mode 100644 index 0000000000..8dadf86bed --- /dev/null +++ b/packages/openscd/src/addons/editor/edit-action-to-v1-converter.ts @@ -0,0 +1,130 @@ +import { + Create, + Delete, + EditorAction, + isCreate, + isDelete, + isMove, + isReplace, + isSimple, + isUpdate, + Move, + Replace, + SimpleAction, + Update +} from '@openscd/core/foundation/deprecated/editor.js'; +import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; +import { getReference, SCLTag } from '../../foundation.js'; + + +export function convertEditActiontoV1(action: EditorAction): Edit { + if (isSimple(action)) { + return convertSimpleAction(action); + } else { + return action.actions.map(convertSimpleAction); + } +} + +function convertSimpleAction(action: SimpleAction): Edit { + if (isCreate(action)) { + return convertCreate(action); + } else if (isDelete(action)) { + return convertDelete(action); + } else if (isUpdate(action)) { + return convertUpdate(action); + } else if (isMove(action)) { + return convertMove(action); + } else if (isReplace(action)) { + return convertReplace(action); + } + + throw new Error('Unknown action type'); +} + +function convertCreate(action: Create): Insert { + let reference: Node | null = null; + if ( + action.new.reference === undefined && + action.new.element instanceof Element && + action.new.parent instanceof Element + ) { + reference = getReference( + action.new.parent, + action.new.element.tagName + ); + } else { + reference = action.new.reference ?? null; + } + + return { + parent: action.new.parent, + node: action.new.element, + reference + }; +} + +function convertDelete(action: Delete): Remove { + return { + node: action.old.element + }; +} + +function convertUpdate(action: Update): UpdateV2 { + const oldAttributesToRemove: Record = {}; + Array.from(action.element.attributes).forEach(attr => { + oldAttributesToRemove[attr.name] = null; + }); + + const attributes = { + ...oldAttributesToRemove, + ...action.newAttributes + }; + + return { + element: action.element, + attributes + }; +} + +function convertMove(action: Move): Insert { + if (action.new.reference === undefined) { + action.new.reference = getReference( + action.new.parent, + action.old.element.tagName + ); + } + + return { + parent: action.new.parent, + node: action.old.element, + reference: action.new.reference ?? null + } +} + +function convertReplace(action: Replace): Edit { + const oldChildren = action.old.element.children; + // We have to clone the children, because otherwise undoing the action would remove the children from the old element, because append removes the old parent + const copiedChildren = Array.from(oldChildren).map(e => e.cloneNode(true)); + + const newNode = action.new.element.cloneNode(true) as Element; + newNode.append(...Array.from(copiedChildren)); + const parent = action.old.element.parentElement; + + if (!parent) { + throw new Error('Replace action called without parent in old element'); + } + + const reference = action.old.element.nextSibling; + + const remove: Remove = { node: action.old.element }; + const insert: Insert = { + parent, + node: newNode, + reference + }; + + return [ + remove, + insert + ]; +} diff --git a/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts index f33d76d27f..fd5909f4e5 100644 --- a/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts +++ b/packages/openscd/src/addons/editor/edit-v1-to-v2-converter.ts @@ -1,130 +1,36 @@ -import { - Create, - Delete, - EditorAction, - isCreate, - isDelete, - isMove, - isReplace, - isSimple, - isUpdate, - Move, - Replace, - SimpleAction, - Update -} from '@openscd/core/foundation/deprecated/editor.js'; -import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; -import { getReference, SCLTag } from '../../foundation.js'; - - -export function convertEditV1toV2(action: EditorAction): Edit { - if (isSimple(action)) { - return convertSimpleAction(action); - } else { - return action.actions.map(convertSimpleAction); - } -} - -function convertSimpleAction(action: SimpleAction): Edit { - if (isCreate(action)) { - return convertCreate(action); - } else if (isDelete(action)) { - return convertDelete(action); - } else if (isUpdate(action)) { - return convertUpdate(action); - } else if (isMove(action)) { - return convertMove(action); - } else if (isReplace(action)) { - return convertReplace(action); - } - - throw new Error('Unknown action type'); -} - -function convertCreate(action: Create): Insert { - let reference: Node | null = null; - if ( - action.new.reference === undefined && - action.new.element instanceof Element && - action.new.parent instanceof Element - ) { - reference = getReference( - action.new.parent, - action.new.element.tagName - ); +import { Edit, EditV2, isComplex, isInsert, isNamespaced, isRemove, isUpdate, Update } from '@openscd/core'; + +export function convertEditV1toV2(edit: Edit): EditV2 { + if (isComplex(edit)) { + return edit.map(convertEditV1toV2); + } else if (isRemove(edit)) { + return edit as EditV2; + } else if (isInsert(edit)) { + return edit as EditV2; + } else if (isUpdate(edit)) { + return convertUpdate(edit); } else { - reference = action.new.reference ?? null; + throw new Error('Unknown edit type'); } - - return { - parent: action.new.parent, - node: action.new.element, - reference - }; } -function convertDelete(action: Delete): Remove { - return { - node: action.old.element - }; -} - -function convertUpdate(action: Update): UpdateV2 { - const oldAttributesToRemove: Record = {}; - Array.from(action.element.attributes).forEach(attr => { - oldAttributesToRemove[attr.name] = null; +function convertUpdate(edit: Update): EditV2 { + const attributes: Partial> = {}; + const attributesNS: Partial< + Record>> + > = {}; + + Object.entries(edit.attributes).forEach(([key, value]) => { + if (isNamespaced(value!)) { + const ns = value.namespaceURI; + if (!ns) return; + + if (!attributesNS[ns]) { + attributesNS[ns] = {}; + } + attributesNS[ns]![key] = value.value; + } else attributes[key] = value; }); - const attributes = { - ...oldAttributesToRemove, - ...action.newAttributes - }; - - return { - element: action.element, - attributes - }; -} - -function convertMove(action: Move): Insert { - if (action.new.reference === undefined) { - action.new.reference = getReference( - action.new.parent, - action.old.element.tagName - ); - } - - return { - parent: action.new.parent, - node: action.old.element, - reference: action.new.reference ?? null - } -} - -function convertReplace(action: Replace): Edit { - const oldChildren = action.old.element.children; - // We have to clone the children, because otherwise undoing the action would remove the children from the old element, because append removes the old parent - const copiedChildren = Array.from(oldChildren).map(e => e.cloneNode(true)); - - const newNode = action.new.element.cloneNode(true) as Element; - newNode.append(...Array.from(copiedChildren)); - const parent = action.old.element.parentElement; - - if (!parent) { - throw new Error('Replace action called without parent in old element'); - } - - const reference = action.old.element.nextSibling; - - const remove: Remove = { node: action.old.element }; - const insert: Insert = { - parent, - node: newNode, - reference - }; - - return [ - remove, - insert - ]; + return { element: edit.element, attributes, attributesNS }; } diff --git a/packages/openscd/src/addons/plugin-manager/custom-plugin-dialog.ts b/packages/openscd/src/addons/plugin-manager/custom-plugin-dialog.ts new file mode 100644 index 0000000000..826b891db3 --- /dev/null +++ b/packages/openscd/src/addons/plugin-manager/custom-plugin-dialog.ts @@ -0,0 +1,207 @@ + +import { + customElement, + html, + LitElement, + query, + TemplateResult, + css, +} from 'lit-element'; +import { get } from 'lit-translate'; +import { + newAddExternalPluginEvent, + pluginIcons +} from '../../open-scd.js'; + +import { + MenuPosition, + menuPosition, + PluginKind +} from "../../plugin.js"; + +import type { Button } from '@material/mwc-button'; +import type { Dialog } from '@material/mwc-dialog'; +import { List } from '@material/mwc-list'; +import type { ListItem } from '@material/mwc-list/mwc-list-item'; +import type { Select } from '@material/mwc-select'; +import type { Switch } from '@material/mwc-switch'; +import type { TextField } from '@material/mwc-textfield'; + +import '@material/mwc-dialog'; +import '@material/mwc-drawer'; +import '@material/mwc-list'; +import '@material/mwc-select'; +import '@material/mwc-switch'; +import '@material/mwc-textfield'; + + +@customElement('oscd-custom-plugin-dialog') +export class OscdCustomPluginDialog extends LitElement { + + @query('#dialog') dialog!: Dialog + @query('#pluginSrcInput') pluginSrcInput!: TextField + @query('#pluginNameInput') pluginNameInput!: TextField + @query('#pluginKindList') pluginKindList!: List + @query('#requireDoc') requireDoc!: Switch + @query('#positionList') positionList!: Select + @query('#addButton') addButton!: Button + + render(): TemplateResult { + return html` + +
    +

    + ${get('plugins.add.warning')} +

    + + + + ${get('plugins.editor')} + + ${pluginIcons['editor']} + + + + ${get('plugins.menu')} + + ${pluginIcons['menu']} + + + + + + ${get('plugins.validator')} + + ${pluginIcons['validator']} + + + + + +
    + + + this.handleAddPlugin()}> + +
    + `; + } + + static styles = css` + + mwc-dialog { + --mdc-dialog-max-width: 98vw; + } + + mwc-dialog > form { + display: flex; + flex-direction: column; + } + + mwc-dialog > form > * { + display: block; + margin-top: 16px; + } + ` + + public close(){ + this.dialog.close() + } + + public show(){ + this.dialog.show() + } + + get open(){ + return this.dialog.open + } + + private handleAddPlugin() { + + if ( + !( + this.pluginSrcInput.checkValidity() && + this.pluginNameInput.checkValidity() && + this.pluginKindList.selected && + this.requireDoc && + this.positionList.selected + ) + ) + return; + + this.dispatchEvent( + newAddExternalPluginEvent({ + src: this.pluginSrcInput.value, + name: this.pluginNameInput.value, + kind: (this.pluginKindList.selected).value, + requireDoc: this.requireDoc.checked, + position: this.positionList.value, + active: true, + // this is an added plugin and will be remove by reset either way + activeByDefault: false, + }) + ); + + this.requestUpdate(); + this.dialog.close(); + } + +} + + diff --git a/packages/openscd/src/addons/plugin-manager/plugin-manager.ts b/packages/openscd/src/addons/plugin-manager/plugin-manager.ts new file mode 100644 index 0000000000..a90c8d63b8 --- /dev/null +++ b/packages/openscd/src/addons/plugin-manager/plugin-manager.ts @@ -0,0 +1,186 @@ +import { + customElement, + html, + LitElement, + property, + query, + TemplateResult, + css +} from 'lit-element'; +import { get } from 'lit-translate'; + +import type { ActionDetail } from '@material/mwc-list'; +import type { MultiSelectedEvent } from '@material/mwc-list/mwc-list-foundation.js'; +import type { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-list'; +import type {List} from '@material/mwc-list'; + +import { + newResetPluginsEvent, + newSetPluginsEvent, + pluginIcons +} from '../../open-scd.js'; + +import { + MenuPosition, + Plugin, +} from "../../plugin.js"; + +@customElement('oscd-plugin-manager') +export class OscdPluginManager extends LitElement { + + /** The plugins to render the layout. */ + @property({ type: Array }) plugins: Plugin[] = []; + @query('#plugin-manager-root') root!: Dialog + @query('#pluginList') pluginList!: List + + render(): TemplateResult { + return html` + + { + const selectedPlugins = this.pluginList.items + .filter((item, index) => e.detail.index.has(index)) + // @ts-expect-error: we add plugin to the list item + .map(item => item.plugin) as Plugin[] + + this.dispatchEvent(newSetPluginsEvent(selectedPlugins)) + }} + > + + ${get(`plugins.editor`)} + + ${pluginIcons['editor']} + + + +
  • + + ${this.generateEditorListItems()} + + + ${get(`plugins.menu`)} + + ${pluginIcons['menu']} + +
  • + + ${this.generateMenuListItems('top')} + +
  • + + ${this.generateValidatorListItems()} + +
  • + + ${this.generateMenuListItems('middle')} + +
  • + + ${this.generateMenuListItems('bottom')} + +
    + { + this.dispatchEvent(newResetPluginsEvent()); + this.requestUpdate(); + }} + style="--mdc-theme-primary: var(--mdc-theme-error)" + > + + + + this.dispatchOpenCustomPluginDialogEvent()}> + +
    + `; + } + + static styles = css` + mwc-dialog { + --mdc-dialog-max-width: 98vw; + } + ` + + public show(){ + this.root.show() + } + + + private generateEditorListItems(): TemplateResult[] { + return this.plugins + .filter(p => p.kind === 'editor') + .map(this.renderPluginListItem) + } + + private generateMenuListItems(position: MenuPosition): TemplateResult[] { + return this.plugins + .filter(p => p.kind === 'menu' && p.position === position) + .map(this.renderPluginListItem) + } + + private generateValidatorListItems(): TemplateResult[] { + return this.plugins + .filter(p => p.kind === 'validator') + .map(this.renderPluginListItem) + } + + private dispatchOpenCustomPluginDialogEvent(): void { + const event = new CustomEvent('open-plugin-download', { + bubbles: true, + composed: true, + }); + + this.dispatchEvent(event); + } + + private renderPluginListItem(plugin?: Plugin): TemplateResult { + if(!plugin){ return html`` } + + return html` + ) => { + if(e.detail.source !== 'interaction'){ + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + } + }} + hasMeta + left + > + + ${plugin.icon || pluginIcons[plugin.kind]} + + ${plugin.name} + + + `; + } + +} diff --git a/packages/openscd/src/foundation.ts b/packages/openscd/src/foundation.ts index b30182842f..d1f92fdc0f 100644 --- a/packages/openscd/src/foundation.ts +++ b/packages/openscd/src/foundation.ts @@ -338,7 +338,7 @@ function lNodeIdentity(e: Element): string { 'lnType', ].map(name => e.getAttribute(name)); if (iedName === 'None') - return `${identity(e.parentElement)}>(${lnClass} ${lnType})`; + return `${identity(e.parentElement)}>(${lnClass} ${lnType} ${lnInst})`; return `${iedName} ${ldInst || '(Client)'}/${prefix ?? ''} ${lnClass} ${ lnInst ?? '' }`; @@ -347,7 +347,7 @@ function lNodeIdentity(e: Element): string { function lNodeSelector(tagName: SCLTag, identity: string): string { if (identity.endsWith(')')) { const [parentIdentity, childIdentity] = pathParts(identity); - const [lnClass, lnType] = childIdentity + const [lnClass, lnType, lnInst] = childIdentity .substring(1, childIdentity.length - 1) .split(' '); @@ -360,7 +360,7 @@ function lNodeSelector(tagName: SCLTag, identity: string): string { return crossProduct( parentSelectors, ['>'], - [`${tagName}[iedName="None"][lnClass="${lnClass}"][lnType="${lnType}"]`] + [`${tagName}[iedName="None"][lnClass="${lnClass}"][lnType="${lnType}"][lnInst="${lnInst}"]`] ) .map(strings => strings.join('')) .join(','); diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index 2a7976c3aa..1e1aff301f 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -52,175 +52,46 @@ import { InstalledOfficialPlugin, MenuPosition, PluginKind, Plugin } from "./plu import { ConfigurePluginEvent, ConfigurePluginDetail, newConfigurePluginEvent } from './plugin.events.js'; import { newLogEvent } from '@openscd/core/foundation/deprecated/history'; -// HOSTING INTERFACES - -export interface MenuItem { - icon: string; - name: string; - hint?: string; - actionItem?: boolean; - action?: (event: CustomEvent) => void; - disabled?: () => boolean; - content?: TemplateResult; - kind: string; -} - -export interface Validator { - validate: () => Promise; -} - -export interface MenuPlugin { - run: () => Promise; -} - -export function newResetPluginsEvent(): CustomEvent { - return new CustomEvent('reset-plugins', { bubbles: true, composed: true }); -} - -export interface AddExternalPluginDetail { - plugin: Omit; -} - -export type AddExternalPluginEvent = CustomEvent; -export function newAddExternalPluginEvent( - plugin: Omit -): AddExternalPluginEvent { - return new CustomEvent('add-external-plugin', { - bubbles: true, - composed: true, - detail: { plugin }, - }); -} -export interface SetPluginsDetail { - indices: Set; -} - -export type SetPluginsEvent = CustomEvent; - -export function newSetPluginsEvent(indices: Set): SetPluginsEvent { - return new CustomEvent('set-plugins', { - bubbles: true, - composed: true, - detail: { indices }, - }); -} +/** The `` custom element is the main entry point of the + * Open Substation Configuration Designer. */ +// Change selector to avoid conflicts with open-scd from compas +@customElement('original-open-scd') +export class OpenSCD extends LitElement { -// PLUGGING INTERFACES -const pluginTags = new Map(); -/** - * Hashes `uri` using cyrb64 analogous to - * https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js . - * @returns a valid customElement tagName containing the URI hash. - */ -export function pluginTag(uri: string): string { - if (!pluginTags.has(uri)) { - let h1 = 0xdeadbeef, - h2 = 0x41c6ce57; - for (let i = 0, ch; i < uri.length; i++) { - ch = uri.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ - Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = - Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ - Math.imul(h1 ^ (h1 >>> 13), 3266489909); - pluginTags.set( - uri, - 'oscd-plugin' + - ((h2 >>> 0).toString(16).padStart(8, '0') + - (h1 >>> 0).toString(16).padStart(8, '0')) - ); + render(): TemplateResult { + return html` + + + + + this.setPlugins(e.detail.selectedPlugins) } + .host=${this} + .doc=${this.doc} + .docName=${this.docName} + .editCount=${this.historyState.editCount} + .historyState=${this.historyState} + .plugins=${this.storedPlugins} + > + + + + + + `; } - return pluginTags.get(uri)!; -} - -/** - * This is a template literal tag function. See: - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates - * - * Passes its arguments to LitElement's `html` tag after combining the first and - * last expressions with the first two and last two static strings. - * Throws unless the first and last expressions are identical strings. - * - * We need this to get around the expression location limitations documented in - * https://lit.dev/docs/templates/expressions/#expression-locations - * - * After upgrading to Lit 2 we can use their static HTML functions instead: - * https://lit.dev/docs/api/static-html/ - */ -export function staticTagHtml( - oldStrings: ReadonlyArray, - ...oldArgs: unknown[] -): TemplateResult { - const args = [...oldArgs]; - const firstArg = args.shift(); - const lastArg = args.pop(); - - if (firstArg !== lastArg) - throw new Error( - `Opening tag <${firstArg}> does not match closing tag .` - ); - - const strings = [...oldStrings] as string[] & { raw: string[] }; - const firstString = strings.shift(); - const secondString = strings.shift(); - - const lastString = strings.pop(); - const penultimateString = strings.pop(); - - strings.unshift(`${firstString}${firstArg}${secondString}`); - strings.push(`${penultimateString}${lastArg}${lastString}`); - - return html(strings, ...args); -} - -export function withoutContent

    ( - plugin: P -): P { - return { ...plugin, content: undefined }; -} -export const pluginIcons: Record = { - editor: 'tab', - menu: 'play_circle', - validator: 'rule_folder', - top: 'play_circle', - middle: 'play_circle', - bottom: 'play_circle', -}; -export const menuOrder: (PluginKind | MenuPosition)[] = [ - 'editor', - 'top', - 'validator', - 'middle', - 'bottom', -]; - -function menuCompare(a: Plugin, b: Plugin): -1 | 0 | 1 { - if (a.kind === b.kind && a.position === b.position) return 0; - const earlier = menuOrder.find(kind => - [a.kind, b.kind, a.position, b.position].includes(kind) - ); - return [a.kind, a.position].includes(earlier) ? -1 : 1; -} - -function compareNeedsDoc(a: Plugin, b: Plugin): -1 | 0 | 1 { - if (a.requireDoc === b.requireDoc) return 0; - return a.requireDoc ? 1 : -1; -} - -const loadedPlugins = new Set(); - -/** The `` custom element is the main entry point of the - * Open Substation Configuration Designer. */ -@customElement('open-scd-stop') -export class OpenSCD extends LitElement { @property({ attribute: false }) doc: XMLDocument | null = null; /** The name of the current [[`doc`]] */ @@ -233,7 +104,7 @@ export class OpenSCD extends LitElement { editCount: -1, canRedo: false, canUndo: false, - } + } /** Object containing all *.nsdoc files and a function extracting element's label form them*/ @property({ attribute: false }) @@ -251,6 +122,8 @@ export class OpenSCD extends LitElement { this.dispatchEvent(newPendingStateEvent(this.loadDoc(value))); } + @state() private storedPlugins: Plugin[] = []; + /** Loads and parses an `XMLDocument` after [[`src`]] has changed. */ private async loadDoc(src: string): Promise { const response = await fetch(src); @@ -310,57 +183,16 @@ export class OpenSCD extends LitElement { connectedCallback(): void { super.connectedCallback(); - this.addEventListener('reset-plugins', this.resetPlugins); - this.addEventListener('set-plugins', (e: SetPluginsEvent) => { - this.setPlugins(e.detail.indices); - }); - - this.updatePlugins(); - this.requestUpdate(); + this.loadPlugins() + // TODO: let Lit handle the event listeners, move to render() + this.addEventListener('reset-plugins', this.resetPlugins); this.addEventListener(historyStateEvent, (e: CustomEvent) => { this.historyState = e.detail; this.requestUpdate(); }); } - render(): TemplateResult { - return html` - - - - - - - - - - - `; - } - - private storePlugins(plugins: Array) { - localStorage.setItem( - 'plugins', - JSON.stringify(plugins.map(withoutContent)) - ); - this.requestUpdate(); - } /** * @@ -380,12 +212,12 @@ export class OpenSCD extends LitElement { const newPlugins = this.storedPlugins.filter( p => p.name !== name || p.kind !== kind ); - this.storePlugins(newPlugins); + this.updateStoredPlugins(newPlugins); } private addPlugin(plugin: Plugin) { const newPlugins = [...this.storedPlugins, plugin]; - this.storePlugins(newPlugins); + this.updateStoredPlugins(newPlugins); } /** @@ -413,30 +245,30 @@ export class OpenSCD extends LitElement { const newPlugins = [...storedPlugins] newPlugins.splice(pluginIndex, 1, changedPlugin) - this.storePlugins(newPlugins); + // this.storePlugins(newPlugins); + this.updateStoredPlugins(newPlugins); } private resetPlugins(): void { - this.storePlugins( - (builtinPlugins as Plugin[]).concat(this.parsedPlugins).map(plugin => { - return { - src: plugin.src, - installed: plugin.default ?? false, - official: true, - }; - }) - ); + const builtInPlugins = this.getBuiltInPlugins() + const allPlugins = [...builtInPlugins, ...this.parsedPlugins] + + const newPluginConfigs = allPlugins.map(plugin => { + return { + ...plugin, + active: plugin.activeByDefault ?? false, + } + }) + + this.storePlugins(newPluginConfigs) } /** * @prop {PluginSet} plugins - Set of plugins that are used by OpenSCD */ - @property({ type: Object }) - plugins: PluginSet = { menu: [], editor: [] }; - + @property({ type: Object }) plugins: PluginSet = { menu: [], editor: [] }; get parsedPlugins(): Plugin[] { - - const menuPlugins = this.plugins.menu.map((plugin: CorePlugin) => { + const menuPlugins: Plugin[] = this.plugins.menu.map((plugin) => { let newPosition: MenuPosition | undefined = plugin.position as MenuPosition; if(typeof plugin.position === 'number') { newPosition = undefined @@ -446,57 +278,66 @@ export class OpenSCD extends LitElement { ...plugin, position: newPosition, kind: 'menu' as PluginKind, - installed: plugin.active ?? false, + active: plugin.active ?? false, } }) - const editorPlugins = this.plugins.editor.map((plugin: CorePlugin) => ({ - ...plugin, - position: undefined, - kind: 'editor' as PluginKind, - installed: plugin.active ?? false, - })) + const editorPlugins: Plugin[] = this.plugins.editor.map((plugin) => { + const editorPlugin: Plugin = { + ...plugin, + position: undefined, + kind: 'editor' as PluginKind, + active: plugin.active ?? false, + } + return editorPlugin + }) const allPlugnis = [...menuPlugins, ...editorPlugins] return allPlugnis } - private get sortedStoredPlugins(): Plugin[] { - const mergedPlugins = this.storedPlugins.map(plugin => { - if (!plugin.official){ return plugin }; - const officialPlugin = (builtinPlugins as Plugin[]) - .concat(this.parsedPlugins) - .find(needle => needle.src === plugin.src); + private updateStoredPlugins(newPlugins: Plugin[]) { + // + // Generate content of each plugin + // + const plugins = newPlugins.map(plugin => { + const isInstalled = plugin.src && plugin.active + if(!isInstalled) { return plugin } + + return this.addContent(plugin) + }) + + // + // Merge built-in plugins + // + const mergedPlugins = plugins.map(plugin => { + const isBuiltIn = !plugin?.official + if (!isBuiltIn){ return plugin }; + + const builtInPlugin = [...this.getBuiltInPlugins(), ...this.parsedPlugins] + .find(p => p.src === plugin.src); return { - ...officialPlugin, + ...builtInPlugin, ...plugin, }; }) - - - return mergedPlugins - .sort(compareNeedsDoc) - .sort(menuCompare); + this.storePlugins(mergedPlugins); } - private get storedPlugins(): Plugin[] { + private storePlugins(plugins: Plugin[]) { + this.storedPlugins = plugins + const pluginConfigs = JSON.stringify(plugins.map(withoutContent)) + localStorage.setItem('plugins', pluginConfigs); + } + private getPluginConfigsFromLocalStorage(): Plugin[] { const pluginsConfigStr = localStorage.getItem('plugins') ?? '[]' - const storedPlugins = JSON.parse(pluginsConfigStr) as Plugin[] - - const plugins = storedPlugins.map(plugin => { - const isInstalled = plugin.src && plugin.installed - if(!isInstalled) { return plugin } - - return this.addContent(plugin) - }) - - return plugins - + return JSON.parse(pluginsConfigStr) as Plugin[] } + protected get locale(): string { return navigator.language || 'en-US'; } @@ -511,44 +352,47 @@ export class OpenSCD extends LitElement { return docs; } - private setPlugins(indices: Set) { - const newPlugins = this.sortedStoredPlugins.map((plugin, index) => { + private setPlugins(selectedPlugins: Plugin[]) { + + const newPlugins: Plugin[] = this.storedPlugins.map((storedPlugin) => { + const isSelected = selectedPlugins.some( (selectedPlugin) => { + return selectedPlugin.name === storedPlugin.name + && selectedPlugin.src === storedPlugin.src + }) return { - ...plugin, - installed: indices.has(index) - }; - }); - this.storePlugins(newPlugins); + ...storedPlugin, + active: isSelected + } + }) + + this.updateStoredPlugins(newPlugins); } - private updatePlugins() { - - const stored: Plugin[] = this.storedPlugins; - const officialStored = stored.filter(p => p.official); - const newOfficial: Array = ( - builtinPlugins as Plugin[] - ) - .concat(this.parsedPlugins) - .filter(p => !officialStored.find(o => o.src === p.src)) - .map(plugin => { - return { - src: plugin.src, - installed: plugin.default ?? false, - official: true as const, - }; - }); + private loadPlugins(){ + const localPluginConfigs = this.getPluginConfigsFromLocalStorage() - const oldOfficial = officialStored.filter( - p => - !(builtinPlugins as Plugin[]) - .concat(this.parsedPlugins) - .find(o => p.src === o.src) - ); - const newPlugins: Array = stored.filter( - p => !oldOfficial.find(o => p.src === o.src) - ); - newOfficial.map(p => newPlugins.push(p)); - this.storePlugins(newPlugins); + const overwritesOfBultInPlugins = localPluginConfigs.filter((p) => { + return this.getBuiltInPlugins().some(b => b.src === p.src) + }) + + const userInstalledPlugins = localPluginConfigs.filter((p) => { + return !this.getBuiltInPlugins().some(b => b.src === p.src) + }) + const mergedBuiltInPlugins = this.getBuiltInPlugins().map((builtInPlugin) => { + let overwrite = overwritesOfBultInPlugins.find(p => p.src === builtInPlugin.src) + + const mergedPlugin: Plugin = { + ...builtInPlugin, + ...overwrite, + active: overwrite?.active ?? builtInPlugin.activeByDefault, + } + + return mergedPlugin + }) + + const mergedPlugins = [...mergedBuiltInPlugins, ...userInstalledPlugins] + + this.updateStoredPlugins(mergedPlugins) } private async addExternalPlugin( @@ -561,20 +405,27 @@ export class OpenSCD extends LitElement { this.storePlugins(newPlugins); } + protected getBuiltInPlugins(): CorePlugin[] { + return builtinPlugins + } + private addContent(plugin: Omit): Plugin { - const tag = pluginTag(plugin.src); + const tag = this.pluginTag(plugin.src); - if (!loadedPlugins.has(tag)) { - loadedPlugins.add(tag); - import(plugin.src).then(mod => customElements.define(tag, mod.default)); + if (!this.loadedPlugins.has(tag)) { + this.loadedPlugins.add(tag); + import(plugin.src).then((mod) => { + customElements.define(tag, mod.default) + }) } - return { ...plugin, - content: staticTagHtml`<${tag} + content: () => { + return staticTagHtml`<${tag} .doc=${this.doc} .docName=${this.docName} .editCount=${this.historyState.editCount} + .plugins=${this.storedPlugins} .docId=${this.docId} .pluginId=${plugin.src} .nsdoc=${this.nsdoc} @@ -586,9 +437,46 @@ export class OpenSCD extends LitElement { validator: plugin.kind === 'validator', editor: plugin.kind === 'editor', })}" - >`, + >` + }, }; } + + @state() private loadedPlugins = new Set(); + + // PLUGGING INTERFACES + @state() private pluginTags = new Map(); + /** + * Hashes `uri` using cyrb64 analogous to + * https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js . + * @returns a valid customElement tagName containing the URI hash. + */ + private pluginTag(uri: string): string { + if (!this.pluginTags.has(uri)) { + let h1 = 0xdeadbeef, + h2 = 0x41c6ce57; + for (let i = 0, ch; i < uri.length; i++) { + ch = uri.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + this.pluginTags.set( + uri, + 'oscd-plugin' + + ((h2 >>> 0).toString(16).padStart(8, '0') + + (h1 >>> 0).toString(16).padStart(8, '0')) + ); + } + return this.pluginTags.get(uri)!; + } + + } declare global { @@ -598,3 +486,140 @@ declare global { 'set-plugins': CustomEvent; } } + + +// HOSTING INTERFACES + +export interface MenuItem { + icon: string; + name: string; + hint?: string; + actionItem?: boolean; + action?: (event: CustomEvent) => void; + disabled?: () => boolean; + content: () => TemplateResult; + kind: string; +} + +export interface Validator { + validate: () => Promise; +} + +export interface MenuPlugin { + run: () => Promise; +} + +export function newResetPluginsEvent(): CustomEvent { + return new CustomEvent('reset-plugins', { bubbles: true, composed: true }); +} + +export interface AddExternalPluginDetail { + plugin: Omit; +} + +export type AddExternalPluginEvent = CustomEvent; + +export function newAddExternalPluginEvent( + plugin: Omit +): AddExternalPluginEvent { + return new CustomEvent('add-external-plugin', { + bubbles: true, + composed: true, + detail: { plugin }, + }); +} + +export interface SetPluginsDetail { + selectedPlugins: Plugin[]; +} + +export type SetPluginsEvent = CustomEvent; + +export function newSetPluginsEvent(selectedPlugins: Plugin[]): SetPluginsEvent { + return new CustomEvent('set-plugins', { + bubbles: true, + composed: true, + detail: { selectedPlugins }, + }); +} + + + + +/** + * This is a template literal tag function. See: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates + * + * Passes its arguments to LitElement's `html` tag after combining the first and + * last expressions with the first two and last two static strings. + * Throws unless the first and last expressions are identical strings. + * + * We need this to get around the expression location limitations documented in + * https://lit.dev/docs/templates/expressions/#expression-locations + * + * After upgrading to Lit 2 we can use their static HTML functions instead: + * https://lit.dev/docs/api/static-html/ + */ +function staticTagHtml( + oldStrings: ReadonlyArray, + ...oldArgs: unknown[] +): TemplateResult { + const args = [...oldArgs]; + const firstArg = args.shift(); + const lastArg = args.pop(); + + if (firstArg !== lastArg) + throw new Error( + `Opening tag <${firstArg}> does not match closing tag .` + ); + + const strings = [...oldStrings] as string[] & { raw: string[] }; + const firstString = strings.shift(); + const secondString = strings.shift(); + + const lastString = strings.pop(); + const penultimateString = strings.pop(); + + strings.unshift(`${firstString}${firstArg}${secondString}`); + strings.push(`${penultimateString}${lastArg}${lastString}`); + + return html(strings, ...args); +} + + +function withoutContent

    ( + plugin: P +): P { + return { ...plugin, content: undefined }; +} + +export const pluginIcons: Record = { + editor: 'tab', + menu: 'play_circle', + validator: 'rule_folder', + top: 'play_circle', + middle: 'play_circle', + bottom: 'play_circle', +}; + +const menuOrder: (PluginKind | MenuPosition)[] = [ + 'editor', + 'top', + 'validator', + 'middle', + 'bottom', +]; + +function menuCompare(a: Plugin, b: Plugin): -1 | 0 | 1 { + if (a.kind === b.kind && a.position === b.position) return 0; + const earlier = menuOrder.find(kind => + [a.kind, b.kind, a.position, b.position].includes(kind) + ); + return [a.kind, a.position].includes(earlier) ? -1 : 1; +} + +function compareNeedsDoc(a: Plugin, b: Plugin): -1 | 0 | 1 { + if (a.requireDoc === b.requireDoc) return 0; + return a.requireDoc ? 1 : -1; +} + diff --git a/packages/openscd/src/plugin.ts b/packages/openscd/src/plugin.ts index 4b9dfba41e..41e127a9d1 100644 --- a/packages/openscd/src/plugin.ts +++ b/packages/openscd/src/plugin.ts @@ -1,25 +1,30 @@ import { TemplateResult } from 'lit-element'; +import { Plugin as CorePlugin } from "@openscd/core" -export type Plugin = { - name: string; - src: string; - icon?: string; - default?: boolean; - kind: PluginKind; - requireDoc?: boolean; - position?: MenuPosition; - installed: boolean; +export type Plugin = CorePlugin & { official?: boolean; - content?: TemplateResult; + active: boolean; + content?: () => TemplateResult; }; -export type InstalledOfficialPlugin = { +export type PluginConfig = PluginConfigNotMenu | PluginConfigMenu + +export type PluginConfigNotMenu = Omit & { + kind: 'editor' | 'validator'; +} + +export type PluginConfigMenu = Omit & { + kind: 'menu'; + position: MenuPosition; +} + +export type InstalledOfficialPlugin = Plugin & { src: string; official: true; installed: boolean; }; - export type PluginKind = 'editor' | 'menu' | 'validator'; export const menuPosition = ['top', 'middle', 'bottom'] as const; export type MenuPosition = (typeof menuPosition)[number]; + diff --git a/packages/openscd/src/plugins.ts b/packages/openscd/src/plugins.ts index f3fcada0a3..ef90506205 100644 --- a/packages/openscd/src/plugins.ts +++ b/packages/openscd/src/plugins.ts @@ -1,13 +1,15 @@ -function generatePluginPath(plugin: string): string { +import { PluginConfig } from './plugin.js'; + +export function generatePluginPath(plugin: string): string { return location.origin+location.pathname+plugin; } -export const officialPlugins = [ +export const officialPlugins: PluginConfig[] = [ { name: 'IED', src: generatePluginPath('plugins/src/editors/IED.js'), icon: 'developer_board', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -15,7 +17,7 @@ export const officialPlugins = [ name: 'Substation', src: generatePluginPath('plugins/src/editors/Substation.js'), icon: 'margin', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -23,7 +25,7 @@ export const officialPlugins = [ name: 'Single Line Diagram', src: generatePluginPath('plugins/src/editors/SingleLineDiagram.js'), icon: 'edit', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -31,7 +33,7 @@ export const officialPlugins = [ name: 'Subscriber Message Binding (GOOSE)', src: generatePluginPath('plugins/src/editors/GooseSubscriberMessageBinding.js'), icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -39,7 +41,7 @@ export const officialPlugins = [ name: 'Subscriber Data Binding (GOOSE)', src: generatePluginPath('plugins/src/editors/GooseSubscriberDataBinding.js'), icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -47,7 +49,7 @@ export const officialPlugins = [ name: 'Subscriber Later Binding (GOOSE)', src: generatePluginPath('plugins/src/editors/GooseSubscriberLaterBinding.js'), icon: 'link', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -55,7 +57,7 @@ export const officialPlugins = [ name: 'Subscriber Message Binding (SMV)', src: generatePluginPath('plugins/src/editors/SMVSubscriberMessageBinding.js'), icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -63,7 +65,7 @@ export const officialPlugins = [ name: 'Subscriber Data Binding (SMV)', src: generatePluginPath('plugins/src/editors/SMVSubscriberDataBinding.js'), icon: 'link', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -71,7 +73,7 @@ export const officialPlugins = [ name: 'Subscriber Later Binding (SMV)', src: generatePluginPath('plugins/src/editors/SMVSubscriberLaterBinding.js'), icon: 'link', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -79,7 +81,7 @@ export const officialPlugins = [ name: 'Communication', src: generatePluginPath('plugins/src/editors/Communication.js'), icon: 'settings_ethernet', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -87,7 +89,7 @@ export const officialPlugins = [ name: '104', src: generatePluginPath('plugins/src/editors/Protocol104.js'), icon: 'settings_ethernet', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -95,7 +97,7 @@ export const officialPlugins = [ name: 'Templates', src: generatePluginPath('plugins/src/editors/Templates.js'), icon: 'copy_all', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -103,7 +105,7 @@ export const officialPlugins = [ name: 'Publisher', src: generatePluginPath('external-plugins/oscd-publisher/oscd-publisher.js'), icon: 'publish', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -111,7 +113,7 @@ export const officialPlugins = [ name: 'Communication Explorer', src: generatePluginPath('external-plugins/oscd-plugins/communication-explorer/0.0.31/index.js'), icon: 'lan', - default: true, + activeByDefault: true, kind: 'editor', requireDoc: true, }, @@ -119,7 +121,7 @@ export const officialPlugins = [ name: 'Cleanup', src: generatePluginPath('plugins/src/editors/Cleanup.js'), icon: 'cleaning_services', - default: false, + activeByDefault: false, kind: 'editor', requireDoc: true, }, @@ -127,7 +129,7 @@ export const officialPlugins = [ name: 'Open project', src: generatePluginPath('plugins/src/menu/OpenProject.js'), icon: 'folder_open', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'top', @@ -136,16 +138,25 @@ export const officialPlugins = [ name: 'New project', src: generatePluginPath('plugins/src/menu/NewProject.js'), icon: 'create_new_folder', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'top', }, + { + name: 'Plugin Store (Beta)', + src: 'https://sprinteins.github.io/oscd-plugin-store/index.js', + icon: 'shopping_bag', + activeByDefault: false, + kind: 'menu', + requireDoc: false, + position: 'bottom', + }, { name: 'Save project', src: generatePluginPath('plugins/src/menu/SaveProject.js'), icon: 'save', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'top', @@ -154,21 +165,21 @@ export const officialPlugins = [ name: 'Validate Schema', src: generatePluginPath('plugins/src/validators/ValidateSchema.js'), icon: 'rule_folder', - default: true, + activeByDefault: true, kind: 'validator', }, { name: 'Validate Templates', src: generatePluginPath('plugins/src/validators/ValidateTemplates.js'), icon: 'rule_folder', - default: true, + activeByDefault: true, kind: 'validator', }, { name: 'Import IEDs', src: generatePluginPath('plugins/src/menu/ImportIEDs.js'), icon: 'snippet_folder', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -177,7 +188,7 @@ export const officialPlugins = [ name: 'Create Virtual IED', src: generatePluginPath('plugins/src/menu/VirtualTemplateIED.js'), icon: 'developer_board', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -185,7 +196,7 @@ export const officialPlugins = [ { name: 'Subscriber Update', src: generatePluginPath('plugins/src/menu/SubscriberInfo.js'), - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -193,7 +204,7 @@ export const officialPlugins = [ { name: 'Update desc (ABB)', src: generatePluginPath('plugins/src/menu/UpdateDescriptionABB.js'), - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -201,7 +212,7 @@ export const officialPlugins = [ { name: 'Update desc (SEL)', src: generatePluginPath('plugins/src/menu/UpdateDescriptionSEL.js'), - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', @@ -210,7 +221,7 @@ export const officialPlugins = [ name: 'Merge Project', src: generatePluginPath('plugins/src/menu/Merge.js'), icon: 'merge_type', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -219,7 +230,7 @@ export const officialPlugins = [ name: 'Update Substation', src: generatePluginPath('plugins/src/menu/UpdateSubstation.js'), icon: 'merge_type', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -228,7 +239,7 @@ export const officialPlugins = [ name: 'Compare IED', src: generatePluginPath('plugins/src/menu/CompareIED.js'), icon: 'compare_arrows', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'middle', @@ -237,7 +248,7 @@ export const officialPlugins = [ name: 'Show SCL History', src: generatePluginPath('plugins/src/menu/SclHistory.js'), icon: 'history_toggle_off', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: true, position: 'bottom', @@ -246,7 +257,7 @@ export const officialPlugins = [ name: 'Help', src: generatePluginPath('plugins/src/menu/Help.js'), icon: 'help', - default: true, + activeByDefault: true, kind: 'menu', requireDoc: false, position: 'bottom', @@ -255,7 +266,7 @@ export const officialPlugins = [ name: 'Export Communication Section', src: generatePluginPath('plugins/src/menu/ExportCommunication.js'), icon: 'sim_card_download', - default: false, + activeByDefault: false, kind: 'menu', requireDoc: true, position: 'middle', diff --git a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js index a43b37e707..4c98550567 100644 --- a/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js +++ b/packages/openscd/test/integration/__snapshots__/open-scd.test.snap.js @@ -85,6 +85,7 @@ snapshots["open-scd renders menu plugins passed down as props and it looks like - help + history_toggle_off - Help + Show SCL History - - + + - history_toggle_off + help - Show SCL History + Help - - + +

  • - - - - - Editor tab - - - tab - - -
  • -
  • - - - developer_board - - IED - - - - margin - - Substation - - - - edit - - Single Line Diagram - - - - link - - Subscriber Message Binding (GOOSE) - - - - link - - Subscriber Data Binding (GOOSE) - - - - link - - Subscriber Later Binding (GOOSE) - - - - link - - Subscriber Message Binding (SMV) - - - - link - - Subscriber Data Binding (SMV) - - - - link - - Subscriber Later Binding (SMV) - - - - settings_ethernet - - Communication - - - - settings_ethernet - - 104 - - - - copy_all - - Templates - - - - publish - - Publisher - - - - cleaning_services - - Cleanup - - - - Menu entry - - - - play_circle - - - -
  • -
  • - - - folder_open - - Open project - - - - create_new_folder - - New project - - - - link - - Top Mock Plugin - - - - save - - Save project - -
  • -
  • - - - rule_folder - - Validate Schema - - - - rule_folder - - Validate Templates - -
  • -
  • - - - link - - Middle Mock Plugin - - - - snippet_folder - - Import IEDs - - - - developer_board - - Create Virtual IED - - - - play_circle - - Subscriber Update - - - - play_circle - - Update desc (ABB) - - - - play_circle - - Update desc (SEL) - - - - merge_type - - Merge Project - - - - merge_type - - Update Substation - - - - compare_arrows - - Compare IED - - - - sim_card_download - - Export Communication Section - -
  • -
  • - - - link - - Bottom Mock Plugin - - - - help - - Help - - - - history_toggle_off - - Show SCL History - - - - - - - - - - -
    -

    - Here you may add remote plug-ins directly from a custom URL. - You do this at your own risk. -

    - - - - - Editor tab - - tab - - - - Menu entry - - play_circle - - - - - Validator - - rule_folder - - - - - -
    - - - - -
    + + + + `; /* end snapshot open-scd renders menu plugins passed down as props and it looks like its snapshot */ @@ -1182,6 +459,7 @@ snapshots["open-scd renders editor plugins passed down as props and it looks lik - help + history_toggle_off - Help + Show SCL History - - + + - history_toggle_off + help - Show SCL History + Help - - + +
  • - -
    - Open project -
    -
    - -
    - New project -
    -
    -
    - - - - - Editor tab - - - tab - - -
  • -
  • - - - developer_board - - IED - - - - margin - - Substation - - - - edit - - Single Line Diagram - - - - link - - Subscriber Message Binding (GOOSE) - - - - link - - Subscriber Data Binding (GOOSE) - - - - link - - Subscriber Later Binding (GOOSE) - - - - link - - Subscriber Message Binding (SMV) - - - - link - - Subscriber Data Binding (SMV) - - - - link - - Subscriber Later Binding (SMV) - - - - settings_ethernet - - Communication - - - - settings_ethernet - - 104 - - - - copy_all - - Templates - - - - publish - - Publisher - - - - cleaning_services - - Cleanup - - - - link - - Mock Editor Plugin - - - - Menu entry - - - - play_circle - - - -
  • -
  • - - - folder_open - - Open project - - - - create_new_folder - - New project - - - - save - - Save project - -
  • -
  • - - - rule_folder - - Validate Schema - - - - rule_folder - - Validate Templates - -
  • -
  • - - - snippet_folder - - Import IEDs - - - - developer_board - - Create Virtual IED - - - - play_circle - - Subscriber Update - - - - play_circle - - Update desc (ABB) - - - - play_circle - - Update desc (SEL) - - - - merge_type - - Merge Project - - - - merge_type - - Update Substation - - - - compare_arrows - - Compare IED - - - - sim_card_download - - Export Communication Section - -
  • -
  • - - - help - - Help - - - - history_toggle_off - - Show SCL History - - - - - - - - - - -
    -

    - Here you may add remote plug-ins directly from a custom URL. - You do this at your own risk. -

    - - - - - Editor tab - - tab - - - - Menu entry - - play_circle - - - - - Validator - - rule_folder - - - - - -
    - - - + Open project + + + - -
    +
    + New project +
    + + + + + + `; /* end snapshot open-scd renders editor plugins passed down as props and it looks like its snapshot */ @@ -2249,6 +833,7 @@ snapshots["open-scd layout looks like its snapshot"] = - help + history_toggle_off - Help + Show SCL History - - + + - history_toggle_off + help - Show SCL History + Help - - + +
  • - - - - - Editor tab - - - tab - - -
  • -
  • - - - developer_board - - IED - - - - margin - - Substation - - - - edit - - Single Line Diagram - - - - link - - Subscriber Message Binding (GOOSE) - - - - link - - Subscriber Data Binding (GOOSE) - - - - link - - Subscriber Later Binding (GOOSE) - - - - link - - Subscriber Message Binding (SMV) - - - - link - - Subscriber Data Binding (SMV) - - - - link - - Subscriber Later Binding (SMV) - - - - settings_ethernet - - Communication - - - - settings_ethernet - - 104 - - - - copy_all - - Templates - - - - publish - - Publisher - - - - cleaning_services - - Cleanup - - - - Menu entry - - - - play_circle - - - -
  • -
  • - - - folder_open - - Open project - - - - create_new_folder - - New project - - - - save - - Save project - -
  • -
  • - - - rule_folder - - Validate Schema - - - - rule_folder - - Validate Templates - -
  • -
  • - - - snippet_folder - - Import IEDs - - - - developer_board - - Create Virtual IED - - - - play_circle - - Subscriber Update - - - - play_circle - - Update desc (ABB) - - - - play_circle - - Update desc (SEL) - - - - merge_type - - Merge Project - - - - merge_type - - Update Substation - - - - compare_arrows - - Compare IED - - - - sim_card_download - - Export Communication Section - -
  • -
  • - - - help - - Help - - - - history_toggle_off - - Show SCL History - - - - - - - - - - -
    -

    - Here you may add remote plug-ins directly from a custom URL. - You do this at your own risk. -

    - - - - - Editor tab - - tab - - - - Menu entry - - play_circle - - - - - Validator - - rule_folder - - - - - -
    - - - - -
    + + + + `; /* end snapshot open-scd layout looks like its snapshot */ diff --git a/packages/openscd/test/mock-open-scd.ts b/packages/openscd/test/mock-open-scd.ts index 597d549a3a..ff96c87722 100644 --- a/packages/openscd/test/mock-open-scd.ts +++ b/packages/openscd/test/mock-open-scd.ts @@ -4,6 +4,7 @@ import { html, queryAssignedNodes, query, + property } from 'lit-element'; import { OscdWizards } from '../src/addons/Wizards.js'; import { WizardFactory } from '../src/foundation.js'; @@ -11,9 +12,15 @@ import { OpenSCD } from '../src/open-scd.js'; import { WizardDialog } from '../src/wizard-dialog.js'; import { OscdHistory } from '../src/addons/History.js'; import { OscdLayout } from '../src/addons/Layout.js'; +// import type { Plugin } from '@openscd/core'; +import { Plugin } from '../src/plugin'; @customElement('mock-open-scd') export class MockOpenSCD extends OpenSCD { + + @property({ attribute: false }) + mockPlugins: Plugin[] = [] + @queryAssignedNodes() _plugins!: Array; @@ -32,10 +39,17 @@ export class MockOpenSCD extends OpenSCD { render(): TemplateResult { return html` - ${this.renderHosting()} - ${super.render()}`; + ${this.renderHosting()} + ${super.render()} + `; + } + + protected getBuiltInPlugins(): Plugin[]{ + return this.mockPlugins; } + + getPlugin(name: string): T | undefined { return this._plugins.find( p => p.tagName.toLowerCase() === name.toLowerCase() diff --git a/packages/openscd/test/mock-plugins.ts b/packages/openscd/test/mock-plugins.ts new file mode 100644 index 0000000000..254f0f071b --- /dev/null +++ b/packages/openscd/test/mock-plugins.ts @@ -0,0 +1,256 @@ + +function generatePluginPath(plugin: string): string { + return location.origin+location.pathname+plugin; +} + +export const officialPlugins = [ + { + name: 'IED', + src: generatePluginPath('plugins/src/editors/IED.js'), + icon: 'developer_board', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Substation', + src: generatePluginPath('plugins/src/editors/Substation.js'), + icon: 'margin', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Single Line Diagram', + src: generatePluginPath('plugins/src/editors/SingleLineDiagram.js'), + icon: 'edit', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Message Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Data Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Later Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Message Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Data Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Subscriber Later Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Communication', + src: generatePluginPath('plugins/src/editors/Communication.js'), + icon: 'settings_ethernet', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: '104', + src: generatePluginPath('plugins/src/editors/Protocol104.js'), + icon: 'settings_ethernet', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Templates', + src: generatePluginPath('plugins/src/editors/Templates.js'), + icon: 'copy_all', + default: true, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Publisher', + src: generatePluginPath('plugins/src/editors/Publisher.js'), + icon: 'publish', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Cleanup', + src: generatePluginPath('plugins/src/editors/Cleanup.js'), + icon: 'cleaning_services', + default: false, + kind: 'editor', + requireDoc: true, + }, + { + name: 'Open project', + src: generatePluginPath('plugins/src/menu/OpenProject.js'), + icon: 'folder_open', + default: true, + kind: 'menu', + requireDoc: false, + position: 'top', + }, + { + name: 'New project', + src: generatePluginPath('plugins/src/menu/NewProject.js'), + icon: 'create_new_folder', + default: true, + kind: 'menu', + requireDoc: false, + position: 'top', + }, + { + name: 'Save project', + src: generatePluginPath('plugins/src/menu/SaveProject.js'), + icon: 'save', + default: true, + kind: 'menu', + requireDoc: true, + position: 'top', + }, + { + name: 'Validate Schema', + src: generatePluginPath('plugins/src/validators/ValidateSchema.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + }, + { + name: 'Validate Templates', + src: generatePluginPath('plugins/src/validators/ValidateTemplates.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + }, + { + name: 'Import IEDs', + src: generatePluginPath('plugins/src/menu/ImportIEDs.js'), + icon: 'snippet_folder', + default: true, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Create Virtual IED', + src: generatePluginPath('plugins/src/menu/VirtualTemplateIED.js'), + icon: 'developer_board', + default: false, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Subscriber Update', + src: generatePluginPath('plugins/src/menu/SubscriberInfo.js'), + default: true, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Update desc (ABB)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionABB.js'), + default: false, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Update desc (SEL)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionSEL.js'), + default: false, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Merge Project', + src: generatePluginPath('plugins/src/menu/Merge.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Update Substation', + src: generatePluginPath('plugins/src/menu/UpdateSubstation.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Compare IED', + src: generatePluginPath('plugins/src/menu/CompareIED.js'), + icon: 'compare_arrows', + default: true, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, + { + name: 'Show SCL History', + src: generatePluginPath('plugins/src/menu/SclHistory.js'), + icon: 'history_toggle_off', + default: true, + kind: 'menu', + requireDoc: true, + position: 'bottom', + }, + { + name: 'Help', + src: generatePluginPath('plugins/src/menu/Help.js'), + icon: 'help', + default: true, + kind: 'menu', + requireDoc: false, + position: 'bottom', + }, + { + name: 'Export Communication Section', + src: generatePluginPath('plugins/src/menu/ExportCommunication.js'), + icon: 'sim_card_download', + default: false, + kind: 'menu', + requireDoc: true, + position: 'middle', + }, +]; diff --git a/packages/openscd/test/unit/Editor.test.ts b/packages/openscd/test/unit/Editor.test.ts index f26079472a..0b1cbd6d18 100644 --- a/packages/openscd/test/unit/Editor.test.ts +++ b/packages/openscd/test/unit/Editor.test.ts @@ -2,7 +2,17 @@ import { html, fixture, expect } from '@open-wc/testing'; import '../../src/addons/Editor.js'; import { OscdEditor } from '../../src/addons/Editor.js'; -import { Insert, newEditEvent, Remove, Update } from '@openscd/core'; +import { + Insert, + InsertV2, + newEditEvent, + newEditEventV2, + Remove, + Update, + SetAttributesV2, + SetTextContentV2, + RemoveV2 +} from '@openscd/core'; import { CommitDetail, LogDetail } from '@openscd/core/foundation/deprecated/history.js'; @@ -17,6 +27,7 @@ describe('OSCD-Editor', () => { let bay2: Element; let bay4: Element; let bay5: Element; + let bayWithoutTextContent: Element; let lnode1: Element; let lnode2: Element; @@ -39,6 +50,7 @@ describe('OSCD-Editor', () => { + `, 'application/xml', @@ -54,6 +66,7 @@ describe('OSCD-Editor', () => { bay2 = scd.querySelector('Bay[name="b2"]')!; bay4 = scd.querySelector('Bay[name="b4"]')!; bay5 = scd.querySelector('Bay[name="b5"]')!; + bayWithoutTextContent = scd.querySelector('Bay[name="bWithoutTextContent"]')!; lnode1 = scd.querySelector('LNode[name="l1"]')!; lnode2 = scd.querySelector('LNode[name="l2"]')!; }); @@ -63,13 +76,13 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); @@ -80,13 +93,13 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const newNodeFromScd = scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]'); @@ -94,13 +107,13 @@ describe('OSCD-Editor', () => { }); it('should move node when inserting existing node', () => { - const insertMove: Insert = { + const insertMove: InsertV2 = { parent: voltageLevel1, node: bay2, reference: null }; - host.dispatchEvent(newEditEvent(insertMove)); + host.dispatchEvent(newEditEventV2(insertMove)); expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b2"]')).to.deep.equal(bay2); @@ -111,12 +124,12 @@ describe('OSCD-Editor', () => { node: bay1 }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b1"]')).to.be.null; }); - describe('Update', () => { + describe('SetAttributes', () => { it('should add new attributes and leave old attributes', () => { const bay1NewAttributes = { desc: 'new description', @@ -125,12 +138,13 @@ describe('OSCD-Editor', () => { const oldAttributes = elementAttributesToMap(bay1); - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector('Bay[name="b1"]')!; @@ -147,12 +161,13 @@ describe('OSCD-Editor', () => { kind: null }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector('Bay[name="b1"]')!; @@ -168,12 +183,13 @@ describe('OSCD-Editor', () => { const oldAttributes = elementAttributesToMap(bay1); - const update: Update = { + const update: SetAttributesV2 = { element: bay1, - attributes: bay1NewAttributes + attributes: bay1NewAttributes, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); const updatedElement = scd.querySelector(`Bay[name="${bay1NewAttributes.name}"]`)!; @@ -187,67 +203,77 @@ describe('OSCD-Editor', () => { describe('namespaced attributes', () => { it('should update attribute with namespace', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newType', namespaceURI: 'xsi' } + attributes: { }, + attributesNS: { + [nsXsi]: { type: 'newType' } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - expect(lnode1.getAttributeNS('xsi', 'type')).to.equal('newType'); + expect(lnode1.getAttributeNS(nsXsi, 'type')).to.equal('newType'); }); it('should handle multiple namespaces', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newTypeXSI', namespaceURI: nsXsi } + attributes: { }, + attributesNS: { + [nsXsi]: { type: 'newTypeXSI' } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - const update2: Update = { + const update2: SetAttributesV2 = { element: lnode1, - attributes: { - type: { value: 'newTypeTD', namespaceURI: nsTd } + attributes: { }, + attributesNS: { + [nsTd]: { type: 'newTypeTD' } } }; - host.dispatchEvent(newEditEvent(update2)); + host.dispatchEvent(newEditEventV2(update2)); expect(lnode1.getAttributeNS(nsXsi, 'type')).to.equal('newTypeXSI'); expect(lnode1.getAttributeNS(nsTd, 'type')).to.equal('newTypeTD'); }); it('should remove namespaced attribute', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode2, - attributes: { - type: { value: null, namespaceURI: nsXsi } + attributes: { }, + attributesNS: { + [nsXsi]: { type: null } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); expect(lnode2.getAttributeNS(nsXsi, 'type')).to.be.null; expect(lnode2.getAttributeNS(nsTd, 'type')).to.equal('typeTD'); }); it('should add and remove multiple normal and namespaced attributes', () => { - const update: Update = { + const update: SetAttributesV2 = { element: lnode2, attributes: { - type: { value: null, namespaceURI: nsXsi }, - kind: { value: 'td-kind', namespaceURI: nsTd }, normalAttribute: 'normalValue', lnClass: null + }, + attributesNS: { + [nsXsi]: { + type: null + }, + [nsTd]: { + kind: 'td-kind' + } } }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); expect(lnode2.getAttributeNS(nsXsi, 'type')).to.be.null; expect(lnode2.getAttributeNS(nsTd, 'kind')).to.equal('td-kind'); @@ -256,29 +282,43 @@ describe('OSCD-Editor', () => { }); }); + describe('SetTextContent', () => { + it('should set text content', () => { + const update: SetTextContentV2 = { + element: bay1, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + expect(bay1.textContent).to.equal('new text'); + }); + }); + describe('Complex action', () => { it('should apply each edit from a complex edit', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - const remove: Remove = { + const remove: RemoveV2 = { node: bay2 }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent([insert, remove, update])); + host.dispatchEvent(newEditEventV2([insert, remove, update])); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; @@ -296,12 +336,12 @@ describe('OSCD-Editor', () => { }); }); - it('should log edit for user event', () => { - const remove: Remove = { + it('should log edit by default', () => { + const remove: RemoveV2 = { node: bay2, }; - host.dispatchEvent(newEditEvent(remove, 'user')); + host.dispatchEvent(newEditEventV2(remove)); expect(log).to.have.lengthOf(1); const logEntry = log[0] as CommitDetail; @@ -310,17 +350,6 @@ describe('OSCD-Editor', () => { expect(logEntry.redo).to.deep.equal(remove); }); - it('should not log edit for undo or redo event', () => { - const remove: Remove = { - node: bay2, - }; - - host.dispatchEvent(newEditEvent(remove, 'redo')); - host.dispatchEvent(newEditEvent(remove, 'undo')); - - expect(log).to.have.lengthOf(0); - }); - describe('validate after edit', () => { let hasTriggeredValidate = false; beforeEach(() => { @@ -332,11 +361,11 @@ describe('OSCD-Editor', () => { }); it('should dispatch validate event after edit', async () => { - const remove: Remove = { + const remove: RemoveV2 = { node: bay2, }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); await element.updateComplete; @@ -361,75 +390,108 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); - const undoInsert = log[0].undo as Remove; + const undoInsert = log[0].undo as RemoveV2; - host.dispatchEvent(newEditEvent(undoInsert)); + host.dispatchEvent(newEditEventV2(undoInsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; }); it('should undo remove', () => { - const remove: Remove = { + const remove: RemoveV2 = { node: bay4 }; - host.dispatchEvent(newEditEvent(remove)); + host.dispatchEvent(newEditEventV2(remove)); - const undoRemove = log[0].undo as Insert; + const undoRemove = log[0].undo as InsertV2; - host.dispatchEvent(newEditEvent(undoRemove)); + host.dispatchEvent(newEditEventV2(undoRemove)); const bay4FromScd = scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b4"]'); expect(bay4FromScd).to.deep.equal(bay4); }); - it('should undo update', () => { - const update: Update = { + it('should undo set attributes', () => { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description', kind: 'superbay' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent(update)); + host.dispatchEvent(newEditEventV2(update)); - const undoUpdate = log[0].undo as Update; + const undoUpdate = log[0].undo as SetAttributesV2; - host.dispatchEvent(newEditEvent(undoUpdate)); + host.dispatchEvent(newEditEventV2(undoUpdate)); expect(bay1.getAttribute('desc')).to.be.null; expect(bay1.getAttribute('kind')).to.equal('bay'); }); + it('should undo set textcontent', () => { + const update: SetTextContentV2 = { + element: bayWithoutTextContent, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + const undoUpdate = log[0].undo as SetTextContentV2; + + host.dispatchEvent(newEditEventV2(undoUpdate)); + + expect(bayWithoutTextContent.textContent).to.be.empty; + }); + + it('should restore children when undoing set textcontent', () => { + const update: SetTextContentV2 = { + element: bay2, + textContent: 'new text' + }; + + host.dispatchEvent(newEditEventV2(update)); + + expect(bay2.children).to.be.empty; + + const undoUpdate = log[0].undo as SetTextContentV2; + + host.dispatchEvent(newEditEventV2(undoUpdate)); + + expect(bay2.children[0]).to.deep.equal(lnode2); + }); + it('should redo previously undone action', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: null }; - host.dispatchEvent(newEditEvent(insert)); + host.dispatchEvent(newEditEventV2(insert)); const undoIsert = log[0].undo; const redoInsert = log[0].redo; - host.dispatchEvent(newEditEvent(undoIsert)); + host.dispatchEvent(newEditEventV2(undoIsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; - host.dispatchEvent(newEditEvent(redoInsert)); + host.dispatchEvent(newEditEventV2(redoInsert)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); }); @@ -438,35 +500,36 @@ describe('OSCD-Editor', () => { const newNode = scd.createElement('Bay'); newNode.setAttribute('name', 'b3'); - const insert: Insert = { + const insert: InsertV2 = { parent: voltageLevel1, node: newNode, reference: bay1 }; - const remove: Remove = { + const remove: RemoveV2 = { node: bay2 }; - const update: Update = { + const update: SetAttributesV2 = { element: bay1, attributes: { desc: 'new description' - } + }, + attributesNS: {} }; - host.dispatchEvent(newEditEvent([insert, remove, update])); + host.dispatchEvent(newEditEventV2([insert, remove, update])); const undoComplex = log[0].undo; const redoComplex = log[0].redo; - host.dispatchEvent(newEditEvent(undoComplex)); + host.dispatchEvent(newEditEventV2(undoComplex)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.deep.equal(bay2); expect(bay1.getAttribute('desc')).to.be.null; - host.dispatchEvent(newEditEvent(redoComplex)); + host.dispatchEvent(newEditEventV2(redoComplex)); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); expect(scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b2"]')).to.be.null; diff --git a/packages/openscd/test/unit/Plugging.test.ts b/packages/openscd/test/unit/Plugging.test.ts index 51129b7833..2d6aa63b7a 100644 --- a/packages/openscd/test/unit/Plugging.test.ts +++ b/packages/openscd/test/unit/Plugging.test.ts @@ -2,93 +2,120 @@ import { expect, fixture, html } from '@open-wc/testing'; import '../mock-open-scd.js'; import { MockOpenSCD } from '../mock-open-scd.js'; - import { TextField } from '@material/mwc-textfield'; -import { Plugin } from '../../src/plugin'; -import { ConfigurePluginDetail, ConfigurePluginEvent, newConfigurePluginEvent } from '../../src/plugin.events'; +import { Plugin, PluginConfig, PluginConfigMenu, PluginConfigNotMenu } from '../../src/plugin'; +import { ConfigurePluginDetail, newConfigurePluginEvent } from '../../src/plugin.events'; +import { generatePluginPath } from "../../src/plugins" + + +async function renderMockOpenSCD( + doc: XMLDocument, + docName: string = "testDoc", + builtInPlugins: Plugin[] = builtinPlugins, +): Promise{ + + const mockHTML = html`` + const openscd = (await fixture(mockHTML)) as MockOpenSCD; + await openscd.updateComplete; + return openscd +} + +function fetchDoc(path: string = '/test/testfiles/valid2007B4.scd'): Promise{ + return fetch(path) + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); +} + describe('OpenSCD-Plugin', () => { - let element: MockOpenSCD; let doc: XMLDocument; const docName = 'testDoc'; afterEach(async () => { - await new Promise(resolve => setTimeout(resolve, 50)); // await animation localStorage.clear(); }); - beforeEach(async () => { + + before(async () => { doc = await fetch('/test/testfiles/valid2007B4.scd') .then(response => response.text()) .then(str => new DOMParser().parseFromString(str, 'application/xml')); - element = ( - await fixture( - html`` - ) - ); - await element.updateComplete; + }); - it('stores default plugins on load', () =>{ - expect(element.layout).property('editors').to.have.lengthOf(6) + it('stores default plugins on load', async () =>{ + const openscd = await renderMockOpenSCD(doc, docName, builtinPlugins.slice(0, 2)) + + openscd.requestUpdate() + await openscd.updateComplete + expect(openscd.layout).property('editors').to.have.lengthOf(2) }); it('has Locale property', async () => { - expect(element).to.have.property('locale'); + const openscd = await renderMockOpenSCD(doc); + expect(openscd).to.have.property('locale'); }); - it('has docs property', () => { - expect(element).to.have.property(`docs`).that.is.a('Object'); - expect(element.docs[docName]).to.equal(doc); + it('has docs property', async () => { + const openscd = await renderMockOpenSCD(doc) + expect(openscd).to.have.property(`docs`).that.is.a('Object'); + expect(openscd.docs[docName]).to.equal(doc); }); - describe('plugin manager dialog', () => { + describe('plugin manager dialog', async () => { let firstEditorPlugin: HTMLElement; let resetAction: HTMLElement; let primaryAction: HTMLElement; + const openscd = await renderMockOpenSCD(doc) + beforeEach(async () => { - element.layout.pluginUI.show(); - await element.layout.pluginUI.updateComplete; + openscd.layout.pluginUI.show(); + await openscd.layout.pluginUI.updateComplete; firstEditorPlugin = ( - element.layout.pluginList.querySelector( + openscd.layout.pluginList.querySelector( 'mwc-check-list-item:not([noninteractive])' ) ); resetAction = ( - element.layout.pluginUI.querySelector('mwc-button[slot="secondaryAction"]') + openscd.layout.pluginUI.querySelector('mwc-button[slot="secondaryAction"]') ); primaryAction = ( - element.layout.pluginUI.querySelector('mwc-button[slot="primaryAction"]') + openscd.layout.pluginUI.querySelector('mwc-button[slot="primaryAction"]') ); }); it('disables deselected plugins', async () => { firstEditorPlugin.click(); - await element.updateComplete; - expect(element.layout).property('editors').to.have.lengthOf(5); + await openscd.updateComplete; + expect(openscd.layout).property('editors').to.have.lengthOf(13); }); it('enables selected plugins', async () => { - (element.layout.pluginList.firstElementChild).click(); - await element.updateComplete; - (element.layout.pluginList.firstElementChild).click(); - await element.updateComplete; - expect(element.layout).property('editors').to.have.lengthOf(6); + + const openscd = await renderMockOpenSCD(doc); + + (openscd.layout.pluginList.firstElementChild).click(); + await openscd.updateComplete; + (openscd.layout.pluginList.firstElementChild).click(); + await openscd.updateComplete; + expect(openscd.layout).property('editors').to.have.lengthOf(14); }); it('resets plugins to default on reset button click', async () => { - (element.layout.pluginList.firstElementChild).click(); - await element.updateComplete; + const openscd = await renderMockOpenSCD(doc); + (openscd.layout.pluginList.firstElementChild).click(); + await openscd.updateComplete; resetAction.click(); - await element.updateComplete; - expect(element.layout).property('editors').to.have.lengthOf(6); + await openscd.updateComplete; + expect(openscd.layout).property('editors').to.have.lengthOf(6); }); it('opens the custom plugin dialog on add button click', async () => { + const openscd = await renderMockOpenSCD(doc); primaryAction.click(); - await element.updateComplete; - expect(element.layout) + await openscd.updateComplete; + expect(openscd.layout) .property('pluginDownloadUI') .to.have.property('open', true); }); @@ -100,46 +127,38 @@ describe('OpenSCD-Plugin', () => { let primaryAction: HTMLElement; let menuKindOption: HTMLElement; let validatorKindOption: HTMLElement; + let openscd: MockOpenSCD beforeEach(async () => { - src = ( - element.layout.pluginDownloadUI.querySelector('#pluginSrcInput') - ); - name = ( - element.layout.pluginDownloadUI.querySelector('#pluginNameInput') - ); - primaryAction = ( - element.layout.pluginDownloadUI.querySelector( - 'mwc-button[slot="primaryAction"]' - ) - ); - element.layout.pluginDownloadUI.show(); - await element.layout.pluginDownloadUI.updateComplete; - await element.updateComplete; + openscd = await renderMockOpenSCD(doc); + src = openscd.layout.pluginDownloadUI.pluginSrcInput + name = openscd.layout.pluginDownloadUI.pluginNameInput + primaryAction = openscd.layout.pluginDownloadUI.addButton + openscd.layout.pluginDownloadUI.show(); + await openscd.layout.pluginDownloadUI.updateComplete; + await openscd.updateComplete; menuKindOption = ( - element.layout.pluginDownloadUI.querySelector( - '#pluginKindList > mwc-radio-list-item[value="menu"]' - ) - ); + openscd.layout.pluginDownloadUI.pluginKindList + .querySelector('mwc-radio-list-item[value="menu"]') + ) validatorKindOption = ( - element.layout.pluginDownloadUI.querySelector( - '#pluginKindList > mwc-radio-list-item[id="validator"]' - ) - ); + openscd.layout.pluginDownloadUI.pluginKindList + .querySelector('mwc-radio-list-item[id="validator"]') + ) }); describe('requires a name and a valid URL to add a plugin', async () => { it('does not add without user interaction', async () => { primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); + expect(openscd.layout.pluginDownloadUI).to.have.property('open', true); }) it('does not add without a name', async () => { src.value = 'http://example.com/plugin.js'; await src.updateComplete; primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); + expect(openscd.layout.pluginDownloadUI).to.have.property('open', true); }) it('does not add plugin with incorrect url', async () => { @@ -148,7 +167,7 @@ describe('OpenSCD-Plugin', () => { await src.updateComplete; await name.updateComplete; primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', true); + expect(openscd.layout.pluginDownloadUI).to.have.property('open', true); }); @@ -158,34 +177,36 @@ describe('OpenSCD-Plugin', () => { src.value = 'http://localhost:8080/plugin/plugin.js'; await src.updateComplete; - primaryAction.click(); - expect(element.layout.pluginDownloadUI).to.have.property('open', false); + expect(openscd.layout.pluginDownloadUI).to.have.property('open', false); }) }); it('adds a new editor kind plugin on add button click', async () => { + expect(openscd.layout.editors).to.have.lengthOf(3, getPluginNames(openscd.layout.editors).join(", ")); + src.value = 'http://example.com/plugin.js'; name.value = 'testName'; await src.updateComplete; await name.updateComplete; primaryAction.click(); - await element.updateComplete; - expect(element.layout.editors).to.have.lengthOf(7); + await openscd.updateComplete; + + expect(openscd.layout.editors).to.have.lengthOf(4); }); it('adds a new menu kind plugin on add button click', async () => { - const lengthMenuKindPlugins = element.layout.menuEntries.length; + const lengthMenuKindPlugins = openscd.layout.menuEntries.length; src.value = 'http://example.com/plugin.js'; name.value = 'testName'; menuKindOption.click(); await src.updateComplete; await name.updateComplete; primaryAction.click(); - await element.updateComplete; - expect(element.layout.menuEntries).to.have.lengthOf(lengthMenuKindPlugins + 1); + await openscd.updateComplete; + expect(openscd.layout.menuEntries).to.have.lengthOf(lengthMenuKindPlugins + 1); }); it('sets requireDoc and position for new menu kind plugin', async () => { @@ -195,25 +216,27 @@ describe('OpenSCD-Plugin', () => { await src.updateComplete; await name.updateComplete; primaryAction.click(); - await element.updateComplete; + await openscd.updateComplete; expect( - element.layout.menuEntries[element.layout.menuEntries.length - 1] + openscd.layout.menuEntries[openscd.layout.menuEntries.length - 1] ).to.have.property('requireDoc'); expect( - element.layout.menuEntries[element.layout.menuEntries.length - 1] + openscd.layout.menuEntries[openscd.layout.menuEntries.length - 1] ).to.have.property('position'); }); it('adds a new validator kind plugin on add button click', async () => { - expect(element.layout.validators).to.have.lengthOf(2); + expect(openscd.layout.validators).to.have.lengthOf(2); + src.value = 'http://example.com/plugin.js'; name.value = 'testName'; validatorKindOption.click(); await src.updateComplete; await name.updateComplete; primaryAction.click(); - await element.updateComplete; - expect(element.layout.validators).to.have.lengthOf(3); + await openscd.updateComplete; + + expect(openscd.layout.validators).to.have.lengthOf(3); }); }); @@ -223,6 +246,7 @@ describe('OpenSCD-Plugin', () => { desc: string currentPlugins: Plugin[] eventDetails: ConfigurePluginDetail + // expectedPlugins: Plugin[] expectedPlugins: Plugin[] } @@ -241,7 +265,8 @@ describe('OpenSCD-Plugin', () => { name: "new plugin", kind: "editor", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, }, }, expectedPlugins: [ @@ -249,7 +274,8 @@ describe('OpenSCD-Plugin', () => { name: "new plugin", kind: "editor", src: "https://example.com/new-plugin.js", - installed: false, + activeByDefault: true, + active: false, } ] }, @@ -264,7 +290,8 @@ describe('OpenSCD-Plugin', () => { name: "an existing plugin", kind: "menu", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, } ], eventDetails: { @@ -274,7 +301,8 @@ describe('OpenSCD-Plugin', () => { name: "an existing plugin", kind: "editor", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, }, }, expectedPlugins: [ @@ -282,13 +310,15 @@ describe('OpenSCD-Plugin', () => { name: "an existing plugin", kind: "menu", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, }, { name: "an existing plugin", kind: "editor", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, } ] }, @@ -302,7 +332,8 @@ describe('OpenSCD-Plugin', () => { name: "I want to change this plugin", kind: "editor", src: "https://example.com/new-plugin.js", - installed: false, + active: false, + activeByDefault: true, } ], eventDetails: { @@ -312,7 +343,8 @@ describe('OpenSCD-Plugin', () => { name: "I want to change this plugin", kind: "editor", src: "https://example.com/changed-url.js", - installed: true, + active: true, + activeByDefault: true, }, }, expectedPlugins: [ @@ -320,7 +352,8 @@ describe('OpenSCD-Plugin', () => { name: "I want to change this plugin", kind: "editor", src: "https://example.com/changed-url.js", - installed: true, + active: true, + activeByDefault: true, }, ] }, @@ -333,7 +366,8 @@ describe('OpenSCD-Plugin', () => { name: "plugin to remove", kind: "editor", src: "https://example.com/plugin-to-remove.js", - installed: false, + active: false, + activeByDefault: true, }], eventDetails: { name: "plugin to remove", @@ -351,7 +385,8 @@ describe('OpenSCD-Plugin', () => { name: "plugin to remove", kind: "editor", src: "https://example.com/plugin-to-remove.js", - installed: false, + active: false, + activeByDefault: true, }], eventDetails: { name: "wrong name", @@ -362,7 +397,8 @@ describe('OpenSCD-Plugin', () => { name: "plugin to remove", kind: "editor", src: "https://example.com/plugin-to-remove.js", - installed: false, + active: false, + activeByDefault: true, }] }, { @@ -374,7 +410,8 @@ describe('OpenSCD-Plugin', () => { name: "plugin to remove, but wrong kind", kind: "editor", src: "https://example.com/plugin-to-remove.js", - installed: false, + active: true, + activeByDefault: true, }], eventDetails: { name: "plugin to remove, but wrong kind", @@ -385,7 +422,8 @@ describe('OpenSCD-Plugin', () => { name: "plugin to remove, but wrong kind", kind: "editor", src: "https://example.com/plugin-to-remove.js", - installed: false, + active: true, + activeByDefault: true, }] }, ] @@ -395,15 +433,16 @@ describe('OpenSCD-Plugin', () => { function testFeature(tc: TestCase) { it(tc.desc, async () => { // ARRANGE + const openscd = await renderMockOpenSCD(doc); - // @ts-ignore: we use the private to arrange the scenario - element.storePlugins(tc.currentPlugins) - await element.updateComplete + // @ts-ignore: we use the private function to arrange the scenario + openscd.storePlugins(tc.currentPlugins) + await openscd.updateComplete // ACT const event = newConfigurePluginEvent(tc.eventDetails.name, tc.eventDetails.kind, tc.eventDetails.config) - element.layout.dispatchEvent(event) - await element.updateComplete + openscd.layout.dispatchEvent(event) + await openscd.updateComplete // ASSERT @@ -412,8 +451,8 @@ describe('OpenSCD-Plugin', () => { // I could not figure how to compare the two lists // I've tried to use chai's deep.members and deep.include.members // and others but non of them worked. - const keys = ["name", "kind", "src", "installed"] - const storedPlugins = element.layout.plugins.map((plugin) => { + const keys = ["name", "kind", "src", "active", "activeByDefault"] + const storedPlugins = openscd.layout.plugins.map((plugin) => { Object.keys(plugin).forEach((key) => { if(!keys.includes(key)) { delete plugin[key] @@ -423,7 +462,7 @@ describe('OpenSCD-Plugin', () => { return plugin }) - const msg = `expected: ${JSON.stringify(tc.expectedPlugins)} but got: ${JSON.stringify(element.layout.plugins)}` + const msg = `expected: ${JSON.stringify(tc.expectedPlugins)} but got: ${JSON.stringify(openscd.layout.plugins)}` expect(tc.expectedPlugins).to.have.deep.members(storedPlugins, msg) }) @@ -431,3 +470,413 @@ describe('OpenSCD-Plugin', () => { }) }); + +describe("Bugs ", async () => { + + it('Inconsistent behavior on refresh when using a local plugin #1622',async () => { + + + // ARRANGE + localStorage.clear() + const plugins = [ + generateEditorPluginConfig({name: "editor-1"}), + generateMenuPluginConfig({name: "menu-1"}), + generateMenuPluginConfig({name: "menu-2"}), + ] + const doc = await fetchDoc() + const openscd = await renderMockOpenSCD(doc, "testDoc", plugins) + // @ts-ignore: we use the private function to arrange the scenario + openscd.storePlugins(plugins) + await openscd.requestUpdate() + await openscd.updateComplete + expect(openscd.layout.plugins).to.have.lengthOf(3, getPluginNames(openscd.layout.plugins).join(", ")) + + openscd.layout.pluginUI.show(); + openscd.layout.pluginDownloadUI.show(); + await openscd.layout.pluginUI.updateComplete; + + // Add a custom plugin + const src = getCustomPluginSrcField(openscd) + const name = getCutomPluginNameField(openscd) + const addCustomPlugin = getCustomPluginAddButton(openscd) + + name.value = "editor-test-1" + src.value = "http://localhost:8080/plugin-test-1.js" + await src.updateComplete; + await name.updateComplete; + addCustomPlugin.click() + await openscd.updateComplete; + + expect(openscd.layout.plugins).to.have.lengthOf(4, getPluginNames(openscd.layout.plugins).join(", ")) + + openscd.layout.pluginUI.show(); + + const firstMenuPluginSrc = plugins[1].src + const firstMenuPlugin = openscd.layout.pluginUI?.shadowRoot?.querySelector(`mwc-check-list-item[value="${firstMenuPluginSrc}"]`) + const firstMenuPluginCheckbox = firstMenuPlugin?.shadowRoot?.querySelector('mwc-checkbox') + expect(firstMenuPluginCheckbox).to.not.be.null + expect(firstMenuPluginCheckbox).to.have.property('checked', true) + + const secondMenuPluginSrc = plugins[2].src + const secondMenuPlugin = openscd.layout.pluginUI?.shadowRoot?.querySelector(`mwc-check-list-item[value="${secondMenuPluginSrc}"]`) + const secondMenuPluginChexkbox = secondMenuPlugin?.shadowRoot?.querySelector('mwc-checkbox') + expect(secondMenuPluginChexkbox).to.have.property('checked', true) + + + // ACT + firstMenuPluginCheckbox!.click() + await firstMenuPluginCheckbox!.updateComplete + await secondMenuPluginChexkbox!.updateComplete + await openscd.updateComplete; + + + // ASSERT + expect(firstMenuPluginCheckbox).to.have.property('checked', false) + expect(secondMenuPluginChexkbox).to.have.property('checked', true) + + + }) +}) + + +export function generateEditorPluginConfig( overwrite: Partial = {}): Plugin{ + + const randId = crypto.randomUUID().slice(0, 8) + const defaultConfig: Plugin = { + kind: 'editor', + name: `Editor Plugin #${randId}`, + src: `http://example.com/plugin-${randId}.js`, + active: true, + activeByDefault: true, + official: true, + icon: 'developer_board', + requireDoc: true, + } + + return { + ...defaultConfig, + ...overwrite, + } +} + +export function generateMenuPluginConfig( overwrite: Partial = {}): Plugin{ + + const randId = crypto.randomUUID().slice(0, 8) + const defaultConfig: Plugin = { + kind: 'menu', + name: `Menu Plugin #${randId}`, + src: `http://example.com/plugin-${randId}.js`, + active: true, + position: 'top', + activeByDefault: true, + official: true, + icon: 'developer_board', + requireDoc: true, + } + + return { + ...defaultConfig, + ...overwrite, + } +} + +const builtinPlugins: Plugin[] = [ + { + name: 'IED', + src: generatePluginPath('plugins/src/editors/IED.js'), + icon: 'developer_board', + activeByDefault: true, + kind: 'editor', + requireDoc: true, + active: true, + }, + { + name: 'Substation', + src: generatePluginPath('plugins/src/editors/Substation.js'), + icon: 'margin', + activeByDefault: true, + kind: 'editor', + requireDoc: true, + active: true, + }, + { + name: 'Single Line Diagram', + src: generatePluginPath('plugins/src/editors/SingleLineDiagram.js'), + icon: 'edit', + activeByDefault: true, + kind: 'editor', + requireDoc: true, + active: true, + }, + // { + // name: 'Subscriber Message Binding (GOOSE)', + // src: generatePluginPath('plugins/src/editors/GooseSubscriberMessageBinding.js'), + // icon: 'link', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Subscriber Data Binding (GOOSE)', + // src: generatePluginPath('plugins/src/editors/GooseSubscriberDataBinding.js'), + // icon: 'link', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Subscriber Later Binding (GOOSE)', + // src: generatePluginPath('plugins/src/editors/GooseSubscriberLaterBinding.js'), + // icon: 'link', + // activeByDefault: true, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Subscriber Message Binding (SMV)', + // src: generatePluginPath('plugins/src/editors/SMVSubscriberMessageBinding.js'), + // icon: 'link', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Subscriber Data Binding (SMV)', + // src: generatePluginPath('plugins/src/editors/SMVSubscriberDataBinding.js'), + // icon: 'link', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Subscriber Later Binding (SMV)', + // src: generatePluginPath('plugins/src/editors/SMVSubscriberLaterBinding.js'), + // icon: 'link', + // activeByDefault: true, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Communication', + // src: generatePluginPath('plugins/src/editors/Communication.js'), + // icon: 'settings_ethernet', + // activeByDefault: true, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: '104', + // src: generatePluginPath('plugins/src/editors/Protocol104.js'), + // icon: 'settings_ethernet', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Templates', + // src: generatePluginPath('plugins/src/editors/Templates.js'), + // icon: 'copy_all', + // activeByDefault: true, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Publisher', + // src: generatePluginPath('plugins/src/editors/Publisher.js'), + // icon: 'publish', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + // { + // name: 'Cleanup', + // src: generatePluginPath('plugins/src/editors/Cleanup.js'), + // icon: 'cleaning_services', + // activeByDefault: false, + // kind: 'editor', + // requireDoc: true, + // active: true, + // }, + { + name: 'Open project', + src: generatePluginPath('plugins/src/menu/OpenProject.js'), + icon: 'folder_open', + activeByDefault: true, + kind: 'menu', + requireDoc: false, + active: true, + position: 'top', + }, + { + name: 'New project', + src: generatePluginPath('plugins/src/menu/NewProject.js'), + icon: 'create_new_folder', + activeByDefault: true, + kind: 'menu', + requireDoc: false, + active: true, + position: 'top', + }, + { + name: 'Save project', + src: generatePluginPath('plugins/src/menu/SaveProject.js'), + icon: 'save', + activeByDefault: true, + kind: 'menu', + requireDoc: true, + active: true, + position: 'top', + }, + { + name: 'Validate Schema', + src: generatePluginPath('plugins/src/validators/ValidateSchema.js'), + icon: 'rule_folder', + activeByDefault: true, + kind: 'validator', + active: true, + }, + { + name: 'Validate Templates', + src: generatePluginPath('plugins/src/validators/ValidateTemplates.js'), + icon: 'rule_folder', + activeByDefault: true, + kind: 'validator', + active: true, + }, + { + name: 'Import IEDs', + src: generatePluginPath('plugins/src/menu/ImportIEDs.js'), + icon: 'snippet_folder', + activeByDefault: true, + kind: 'menu', + requireDoc: true, + active: true, + position: 'middle', + }, + { + name: 'Create Virtual IED', + src: generatePluginPath('plugins/src/menu/VirtualTemplateIED.js'), + icon: 'developer_board', + activeByDefault: false, + kind: 'menu', + requireDoc: true, + active: true, + position: 'middle', + }, + { + name: 'Subscriber Update', + src: generatePluginPath('plugins/src/menu/SubscriberInfo.js'), + activeByDefault: true, + kind: 'menu', + requireDoc: true, + active: true, + position: 'middle', + }, + // { + // name: 'Update desc (ABB)', + // src: generatePluginPath('plugins/src/menu/UpdateDescriptionABB.js'), + // activeByDefault: false, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, + // { + // name: 'Update desc (SEL)', + // src: generatePluginPath('plugins/src/menu/UpdateDescriptionSEL.js'), + // activeByDefault: false, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, + // { + // name: 'Merge Project', + // src: generatePluginPath('plugins/src/menu/Merge.js'), + // icon: 'merge_type', + // activeByDefault: true, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, + // { + // name: 'Update Substation', + // src: generatePluginPath('plugins/src/menu/UpdateSubstation.js'), + // icon: 'merge_type', + // activeByDefault: true, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, + // { + // name: 'Compare IED', + // src: generatePluginPath('plugins/src/menu/CompareIED.js'), + // icon: 'compare_arrows', + // activeByDefault: true, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, + // { + // name: 'Show SCL History', + // src: generatePluginPath('plugins/src/menu/SclHistory.js'), + // icon: 'history_toggle_off', + // activeByDefault: true, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'bottom', + // }, + // { + // name: 'Help', + // src: generatePluginPath('plugins/src/menu/Help.js'), + // icon: 'help', + // activeByDefault: true, + // kind: 'menu', + // requireDoc: false, + // active: true, + // position: 'bottom', + // }, + // { + // name: 'Export Communication Section', + // src: generatePluginPath('plugins/src/menu/ExportCommunication.js'), + // icon: 'sim_card_download', + // activeByDefault: false, + // kind: 'menu', + // requireDoc: true, + // active: true, + // position: 'middle', + // }, +]; + + +function getPluginNames(plugins: Plugin[]): string[]{ + return plugins.map(plugin => plugin.name) +} + +function getCustomPluginSrcField(openscd: MockOpenSCD): TextField { + return openscd.layout.pluginDownloadUI.pluginSrcInput +} + +function getCutomPluginNameField(openscd: MockOpenSCD): TextField { + return openscd.layout.pluginDownloadUI.pluginNameInput +} + + +function getCustomPluginAddButton(openscd: MockOpenSCD): HTMLElement { + return openscd.layout.pluginDownloadUI.addButton +} + diff --git a/packages/openscd/test/unit/edit-action-to-v1-converter.test.ts b/packages/openscd/test/unit/edit-action-to-v1-converter.test.ts new file mode 100644 index 0000000000..b7ef2bc2e4 --- /dev/null +++ b/packages/openscd/test/unit/edit-action-to-v1-converter.test.ts @@ -0,0 +1,148 @@ +import { html, fixture, expect } from '@open-wc/testing'; + +import { + Create, + Delete, + EditorAction, + isCreate, + isDelete, + isMove, + isReplace, + isSimple, + isUpdate, + Move, + Replace, + SimpleAction, + Update, + createUpdateAction +} from '@openscd/core/foundation/deprecated/editor.js'; +import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; + +import { convertEditActiontoV1 } from '../../src/addons/editor/edit-action-to-v1-converter.js'; + + +describe('edit-action-to-v1-converter', () => { + const doc = new DOMParser().parseFromString( + ` + + + + + + `, + 'application/xml' + ); + const substation = doc.querySelector('Substation')!; + const substation2 = doc.querySelector('Substation[name="sub2"]')!; + const bay = doc.querySelector('Bay')!; + + it('should convert delete to remove', () => { + const deleteAction: Delete = { + old: { + parent: substation, + element: bay + } + }; + + const remove = convertEditActiontoV1(deleteAction); + + const expectedRemove: Remove = { + node: bay + }; + + expect(remove).to.deep.equal(expectedRemove); + }); + + it('should convert create to insert', () => { + const newBay = doc.createElement('Bay'); + newBay.setAttribute('name', 'bay2'); + + const createAction: Create = { + new: { + parent: substation, + element: newBay + } + }; + + const insert = convertEditActiontoV1(createAction); + + const expectedInsert: Insert = { + parent: substation, + node: newBay, + reference: null + }; + + expect(insert).to.deep.equal(expectedInsert); + }); + + it('should convert update to updateV2', () => { + const newAttributes = { + name: 'newBayName', + }; + const updateAction = createUpdateAction(bay, newAttributes); + + const updateV2 = convertEditActiontoV1(updateAction); + + const expectedUpdateV2: UpdateV2 = { + element: bay, + attributes: { + ...newAttributes, + desc: null + } + }; + + expect(updateV2).to.deep.equal(expectedUpdateV2); + }); + + it('should convert move to insert', () => { + const moveAction: Move = { + old: { + parent: substation, + element: bay, + reference: null + }, + new: { + parent: substation2, + reference: null + } + }; + + const insert = convertEditActiontoV1(moveAction); + + const expectedInsert: Insert = { + parent: substation2, + node: bay, + reference: null + }; + + expect(insert).to.deep.equal(expectedInsert); + }); + + it('should convert replace to complex action with remove and insert', () => { + const ied = doc.createElement('IED'); + ied.setAttribute('name', 'ied'); + + const replace: Replace = { + old: { + element: bay + }, + new: { + element: ied + } + }; + + const [ remove, insert ] = convertEditActiontoV1(replace) as Edit[]; + + const expectedRemove: Remove = { + node: bay + }; + const expectedInsert: Insert = { + parent: substation, + node: ied, + reference: bay.nextSibling + }; + + expect(remove).to.deep.equal(expectedRemove); + expect(insert).to.deep.equal(expectedInsert); + }); +}); diff --git a/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts index 6676a1ffed..da9bf79415 100644 --- a/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts +++ b/packages/openscd/test/unit/edit-v1-to-v2-converter.test.ts @@ -16,15 +16,17 @@ import { Update, createUpdateAction } from '@openscd/core/foundation/deprecated/editor.js'; -import { Edit, Insert, Remove, Update as UpdateV2 } from '@openscd/core'; +import { Edit, Insert, InsertV2, Remove, Update as UpdateV1, RemoveV2, SetAttributesV2 } from '@openscd/core'; -import { convertEditV1toV2 } from '../../src/addons/editor/edit-v1-to-v2-converter.js'; +import { convertEditV1toV2 } from '../../src/addons/editor/edit-v1-to-v2-converter'; describe('edit-v1-to-v2-converter', () => { + const nsXsi = 'urn:example.com'; + const doc = new DOMParser().parseFromString( ` - + @@ -35,114 +37,90 @@ describe('edit-v1-to-v2-converter', () => { const substation = doc.querySelector('Substation')!; const substation2 = doc.querySelector('Substation[name="sub2"]')!; const bay = doc.querySelector('Bay')!; - - it('should convert delete to remove', () => { - const deleteAction: Delete = { - old: { - parent: substation, - element: bay - } + + it('should keep remove as is', () => { + const remove: Remove = { + node: bay }; - - const remove = convertEditV1toV2(deleteAction); - - const expectedRemove: Remove = { + + const removeV2 = convertEditV1toV2(remove); + + const expectedRemoveV2: RemoveV2 = { node: bay }; - - expect(remove).to.deep.equal(expectedRemove); + + expect(removeV2).to.deep.equal(expectedRemoveV2); }); - it('should convert create to insert', () => { + it('should keep insert as is', () => { const newBay = doc.createElement('Bay'); newBay.setAttribute('name', 'bay2'); - const createAction: Create = { - new: { - parent: substation, - element: newBay - } - }; - - const insert = convertEditV1toV2(createAction); - - const expectedInsert: Insert = { + const insert: Insert = { + node: newBay, parent: substation, + reference: null + }; + + const insertV2 = convertEditV1toV2(insert); + + const expectedInsertV2: InsertV2 = { node: newBay, + parent: substation, reference: null }; - - expect(insert).to.deep.equal(expectedInsert); + + expect(insertV2).to.deep.equal(expectedInsertV2); }); - - it('should convert update to updateV2', () => { + + it('should convert update to set attributes', () => { const newAttributes = { name: 'newBayName', }; - const updateAction = createUpdateAction(bay, newAttributes); - - const updateV2 = convertEditV1toV2(updateAction); - - const expectedUpdateV2: UpdateV2 = { + const update: UpdateV1 = { element: bay, - attributes: { - ...newAttributes, - desc: null - } + attributes: newAttributes + } + + const setAttributesV2 = convertEditV1toV2(update); + + const expectedSetAttributesV2: SetAttributesV2 = { + element: bay, + attributes: newAttributes, + attributesNS: {} }; - - expect(updateV2).to.deep.equal(expectedUpdateV2); + + expect(setAttributesV2).to.deep.equal(expectedSetAttributesV2); }); - it('should convert move to insert', () => { - const moveAction: Move = { - old: { - parent: substation, - element: bay, - reference: null - }, - new: { - parent: substation2, - reference: null + it('shoudl convert update with namespaced attributes', () => { + const newAttributes = { + name: 'newBayName', + type: { + value: 'new value', + namespaceURI: nsXsi } }; - const insert = convertEditV1toV2(moveAction); - - const expectedInsert: Insert = { - parent: substation2, - node: bay, - reference: null - }; - - expect(insert).to.deep.equal(expectedInsert); - }); + const update: UpdateV1 = { + element: bay, + attributes: newAttributes + } - it('should convert replace to complex action with remove and insert', () => { - const ied = doc.createElement('IED'); - ied.setAttribute('name', 'ied'); + const setAttributesV2 = convertEditV1toV2(update); - const replace: Replace = { - old: { - element: bay + const expectedSetAttributesV2: SetAttributesV2 = { + element: bay, + attributes: { + name: 'newBayName' }, - new: { - element: ied + attributesNS: { + [nsXsi]: { + type: 'new value' + } } }; - const [ remove, insert ] = convertEditV1toV2(replace) as Edit[]; - - const expectedRemove: Remove = { - node: bay - }; - const expectedInsert: Insert = { - parent: substation, - node: ied, - reference: bay.nextSibling - }; - - expect(remove).to.deep.equal(expectedRemove); - expect(insert).to.deep.equal(expectedInsert); + expect(setAttributesV2).to.deep.equal(expectedSetAttributesV2); }); }); diff --git a/packages/openscd/web-test-runner.config.mjs b/packages/openscd/web-test-runner.config.mjs index 0bf46459bd..fea03e351e 100644 --- a/packages/openscd/web-test-runner.config.mjs +++ b/packages/openscd/web-test-runner.config.mjs @@ -18,7 +18,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ product: 'chromium', launchOptions: { headless: true, - devtools: true, + devtools: false, } }), // playwrightLauncher({ product: 'firefox' }), diff --git a/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js b/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js index d8cf210dae..2e0ad05334 100644 --- a/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js +++ b/packages/plugins/test/integration/editors/__snapshots__/IED.test.snap.js @@ -34,7 +34,6 @@ snapshots["IED Plugin with a doc loaded containing IEDs looks like the latest sn aria-selected="true" graphic="control" mwc-list-item="" - selected="" tabindex="0" twoline="" value="IED1" diff --git a/packages/plugins/test/integration/validators/ValidateSchema.test.ts b/packages/plugins/test/integration/validators/ValidateSchema.test.ts index 67d90b4d53..b4a5f15e95 100644 --- a/packages/plugins/test/integration/validators/ValidateSchema.test.ts +++ b/packages/plugins/test/integration/validators/ValidateSchema.test.ts @@ -2,6 +2,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import '@openscd/open-scd/test/mock-open-scd.js'; import { MockOpenSCD } from '@openscd/open-scd/test/mock-open-scd.js'; +import type { Plugin } from '@openscd/open-scd/src/plugin.js'; import ValidateSchema from '../../../src/validators/ValidateSchema.js'; import { IssueDetail, LogEntry } from '@openscd/core/foundation/deprecated/history.js'; @@ -18,7 +19,7 @@ describe('ValidateSchema plugin', () => { before(async () => { parent = await fixture(html` - + `); element = parent.getActivePlugin(); @@ -108,3 +109,290 @@ describe('ValidateSchema plugin', () => { }); }); + + +const builtinPlugins: Plugin[] = [ + { + name: 'IED', + src: generatePluginPath('plugins/src/editors/IED.js'), + icon: 'developer_board', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Substation', + src: generatePluginPath('plugins/src/editors/Substation.js'), + icon: 'margin', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Single Line Diagram', + src: generatePluginPath('plugins/src/editors/SingleLineDiagram.js'), + icon: 'edit', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Message Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Data Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Later Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Message Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Data Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Later Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Communication', + src: generatePluginPath('plugins/src/editors/Communication.js'), + icon: 'settings_ethernet', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: '104', + src: generatePluginPath('plugins/src/editors/Protocol104.js'), + icon: 'settings_ethernet', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Templates', + src: generatePluginPath('plugins/src/editors/Templates.js'), + icon: 'copy_all', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Publisher', + src: generatePluginPath('plugins/src/editors/Publisher.js'), + icon: 'publish', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Cleanup', + src: generatePluginPath('plugins/src/editors/Cleanup.js'), + icon: 'cleaning_services', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Open project', + src: generatePluginPath('plugins/src/menu/OpenProject.js'), + icon: 'folder_open', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'top', + }, + { + name: 'New project', + src: generatePluginPath('plugins/src/menu/NewProject.js'), + icon: 'create_new_folder', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'top', + }, + { + name: 'Save project', + src: generatePluginPath('plugins/src/menu/SaveProject.js'), + icon: 'save', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'top', + }, + { + name: 'Validate Schema', + src: generatePluginPath('plugins/src/validators/ValidateSchema.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + installed: true, + }, + { + name: 'Validate Templates', + src: generatePluginPath('plugins/src/validators/ValidateTemplates.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + installed: true, + }, + { + name: 'Import IEDs', + src: generatePluginPath('plugins/src/menu/ImportIEDs.js'), + icon: 'snippet_folder', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Create Virtual IED', + src: generatePluginPath('plugins/src/menu/VirtualTemplateIED.js'), + icon: 'developer_board', + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Subscriber Update', + src: generatePluginPath('plugins/src/menu/SubscriberInfo.js'), + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update desc (ABB)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionABB.js'), + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update desc (SEL)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionSEL.js'), + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Merge Project', + src: generatePluginPath('plugins/src/menu/Merge.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update Substation', + src: generatePluginPath('plugins/src/menu/UpdateSubstation.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Compare IED', + src: generatePluginPath('plugins/src/menu/CompareIED.js'), + icon: 'compare_arrows', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Show SCL History', + src: generatePluginPath('plugins/src/menu/SclHistory.js'), + icon: 'history_toggle_off', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'bottom', + }, + { + name: 'Help', + src: generatePluginPath('plugins/src/menu/Help.js'), + icon: 'help', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'bottom', + }, + { + name: 'Export Communication Section', + src: generatePluginPath('plugins/src/menu/ExportCommunication.js'), + icon: 'sim_card_download', + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, +]; + +export function generatePluginPath(plugin: string): string { + return location.origin+location.pathname+plugin; +} diff --git a/packages/plugins/test/integration/validators/ValidateTemplates.test.ts b/packages/plugins/test/integration/validators/ValidateTemplates.test.ts index 3e1dd8aa1d..d6997811b5 100644 --- a/packages/plugins/test/integration/validators/ValidateTemplates.test.ts +++ b/packages/plugins/test/integration/validators/ValidateTemplates.test.ts @@ -2,6 +2,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import '@openscd/open-scd/test/mock-open-scd.js'; import { MockOpenSCD } from '@openscd/open-scd/test/mock-open-scd.js'; +import type { Plugin } from '@openscd/open-scd/src/plugin.js'; import ValidateTemplates from '../../../src/validators/ValidateTemplates.js'; @@ -21,7 +22,7 @@ describe('ValidateTemplates OpenSCD integration test ', () => { .then(str => new DOMParser().parseFromString(str, 'application/xml')); parent = await fixture(html` - `); @@ -46,7 +47,7 @@ describe('ValidateTemplates OpenSCD integration test ', () => { .then(str => new DOMParser().parseFromString(str, 'application/xml')); parent = await fixture(html` - `); @@ -74,7 +75,7 @@ describe('ValidateTemplates OpenSCD integration test ', () => { .then(str => new DOMParser().parseFromString(str, 'application/xml')); parent = await fixture(html` - `); @@ -96,3 +97,289 @@ describe('ValidateTemplates OpenSCD integration test ', () => { }); }); }); + +const builtinPlugins: Plugin[] = [ + { + name: 'IED', + src: generatePluginPath('plugins/src/editors/IED.js'), + icon: 'developer_board', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Substation', + src: generatePluginPath('plugins/src/editors/Substation.js'), + icon: 'margin', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Single Line Diagram', + src: generatePluginPath('plugins/src/editors/SingleLineDiagram.js'), + icon: 'edit', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Message Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Data Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Later Binding (GOOSE)', + src: generatePluginPath('plugins/src/editors/GooseSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Message Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberMessageBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Data Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberDataBinding.js'), + icon: 'link', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Subscriber Later Binding (SMV)', + src: generatePluginPath('plugins/src/editors/SMVSubscriberLaterBinding.js'), + icon: 'link', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Communication', + src: generatePluginPath('plugins/src/editors/Communication.js'), + icon: 'settings_ethernet', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: '104', + src: generatePluginPath('plugins/src/editors/Protocol104.js'), + icon: 'settings_ethernet', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Templates', + src: generatePluginPath('plugins/src/editors/Templates.js'), + icon: 'copy_all', + default: true, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Publisher', + src: generatePluginPath('plugins/src/editors/Publisher.js'), + icon: 'publish', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Cleanup', + src: generatePluginPath('plugins/src/editors/Cleanup.js'), + icon: 'cleaning_services', + default: false, + kind: 'editor', + requireDoc: true, + installed: true, + }, + { + name: 'Open project', + src: generatePluginPath('plugins/src/menu/OpenProject.js'), + icon: 'folder_open', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'top', + }, + { + name: 'New project', + src: generatePluginPath('plugins/src/menu/NewProject.js'), + icon: 'create_new_folder', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'top', + }, + { + name: 'Save project', + src: generatePluginPath('plugins/src/menu/SaveProject.js'), + icon: 'save', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'top', + }, + { + name: 'Validate Schema', + src: generatePluginPath('plugins/src/validators/ValidateSchema.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + installed: true, + }, + { + name: 'Validate Templates', + src: generatePluginPath('plugins/src/validators/ValidateTemplates.js'), + icon: 'rule_folder', + default: true, + kind: 'validator', + installed: true, + }, + { + name: 'Import IEDs', + src: generatePluginPath('plugins/src/menu/ImportIEDs.js'), + icon: 'snippet_folder', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Create Virtual IED', + src: generatePluginPath('plugins/src/menu/VirtualTemplateIED.js'), + icon: 'developer_board', + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Subscriber Update', + src: generatePluginPath('plugins/src/menu/SubscriberInfo.js'), + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update desc (ABB)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionABB.js'), + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update desc (SEL)', + src: generatePluginPath('plugins/src/menu/UpdateDescriptionSEL.js'), + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Merge Project', + src: generatePluginPath('plugins/src/menu/Merge.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Update Substation', + src: generatePluginPath('plugins/src/menu/UpdateSubstation.js'), + icon: 'merge_type', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Compare IED', + src: generatePluginPath('plugins/src/menu/CompareIED.js'), + icon: 'compare_arrows', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, + { + name: 'Show SCL History', + src: generatePluginPath('plugins/src/menu/SclHistory.js'), + icon: 'history_toggle_off', + default: true, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'bottom', + }, + { + name: 'Help', + src: generatePluginPath('plugins/src/menu/Help.js'), + icon: 'help', + default: true, + kind: 'menu', + requireDoc: false, + installed: true, + position: 'bottom', + }, + { + name: 'Export Communication Section', + src: generatePluginPath('plugins/src/menu/ExportCommunication.js'), + icon: 'sim_card_download', + default: false, + kind: 'menu', + requireDoc: true, + installed: true, + position: 'middle', + }, +]; + +export function generatePluginPath(plugin: string): string { + return location.origin+location.pathname+plugin; +} diff --git a/packages/plugins/test/unit/menu/__snapshots__/VirtualTemplateIED.test.snap.js b/packages/plugins/test/unit/menu/__snapshots__/VirtualTemplateIED.test.snap.js index 9f308faba4..b753457b04 100644 --- a/packages/plugins/test/unit/menu/__snapshots__/VirtualTemplateIED.test.snap.js +++ b/packages/plugins/test/unit/menu/__snapshots__/VirtualTemplateIED.test.snap.js @@ -1,7 +1,7 @@ /* @web/test-runner snapshot v1 */ export const snapshots = {}; -snapshots["Plugin that creates with some user input a virtual template IED - SPECIFICATION looks like the latest snapshot"] = +snapshots["Plugin that creates with some user input a virtual template IED - SPECIFICATION looks like the latest snapshot"] = ` CSWI 1 @@ -81,7 +81,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QC9>Earth_Switch>(CILO OpenSCD_CILO)" + value="AA1>E1>Q01>QC9>Earth_Switch>(CILO OpenSCD_CILO 1)" > CILO 1 @@ -90,7 +90,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QC9>Earth_Switch>(XSWI OpenSCD_XSWI_EarthSwitch)" + value="AA1>E1>Q01>QC9>Earth_Switch>(XSWI OpenSCD_XSWI_EarthSwitch 1)" > XSWI 1 @@ -148,7 +148,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QB1>Disconnector>(CSWI OpenSCD_CSWI)" + value="AA1>E1>Q01>QB1>Disconnector>(CSWI OpenSCD_CSWI 1)" > CSWI 1 @@ -157,7 +157,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QB1>Disconnector>(XSWI OpenSCD_XSWI_DIS)" + value="AA1>E1>Q01>QB1>Disconnector>(XSWI OpenSCD_XSWI_DIS 1)" > XSWI 1 @@ -166,7 +166,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QB1>Disconnector>(CILO OpenSCD_CILO)" + value="AA1>E1>Q01>QB1>Disconnector>(CILO OpenSCD_CILO 1)" > CILO 1 @@ -224,7 +224,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QA1>Circuit_Breaker>(CSWI OpenSCD_CSWI)" + value="AA1>E1>Q01>QA1>Circuit_Breaker>(CSWI OpenSCD_CSWI 1)" > CSWI 1 @@ -233,7 +233,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QA1>Circuit_Breaker>(CILO OpenSCD_CILO)" + value="AA1>E1>Q01>QA1>Circuit_Breaker>(CILO OpenSCD_CILO 1)" > CILO 1 @@ -242,7 +242,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>QA1>Circuit_Breaker>(XCBR OpenSCD_XCBR)" + value="AA1>E1>Q01>QA1>Circuit_Breaker>(XCBR OpenSCD_XCBR 1)" > XCBR 1 @@ -292,7 +292,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Timed_Overcurrent>(PTOC OpenSCD_PTOC)" + value="AA1>E1>Q01>Timed_Overcurrent>(PTOC OpenSCD_PTOC 2)" > ID_ PTOC 2 @@ -301,7 +301,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Timed_Overcurrent>(PTOC OpenSCD_PTOC)" + value="AA1>E1>Q01>Timed_Overcurrent>(PTOC OpenSCD_PTOC 1)" > IDD_ PTOC 1 @@ -359,7 +359,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Distance_Protection>Zone4>(PDIS OpenSCD_PDIS)" + value="AA1>E1>Q01>Distance_Protection>Zone4>(PDIS OpenSCD_PDIS 1)" > Zone4 PDIS 1 @@ -368,7 +368,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Distance_Protection>Zon3>(PDIS OpenSCD_PDIS)" + value="AA1>E1>Q01>Distance_Protection>Zon3>(PDIS OpenSCD_PDIS 1)" > Zon3 PDIS 1 @@ -377,7 +377,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Distance_Protection>Zone2>(PDIS OpenSCD_PDIS)" + value="AA1>E1>Q01>Distance_Protection>Zone2>(PDIS OpenSCD_PDIS 1)" > Zone2 PDIS 1 @@ -386,7 +386,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q01>Distance_Protection>Zone1>(PDIS OpenSCD_PDIS)" + value="AA1>E1>Q01>Distance_Protection>Zone1>(PDIS OpenSCD_PDIS 1)" > Zone1 PDIS 1 @@ -444,7 +444,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q02>QB1>Disconnector>(CSWI OpenSCD_CSWI)" + value="AA1>E1>Q02>QB1>Disconnector>(CSWI OpenSCD_CSWI 1)" > CSWI 1 @@ -453,7 +453,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q02>QB1>Disconnector>(XSWI OpenSCD_XSWI_DIS)" + value="AA1>E1>Q02>QB1>Disconnector>(XSWI OpenSCD_XSWI_DIS 1)" > XSWI 1 @@ -462,7 +462,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>E1>Q02>QB1>Disconnector>(CILO OpenSCD_CILO)" + value="AA1>E1>Q02>QB1>Disconnector>(CILO OpenSCD_CILO 1)" > CILO 1 @@ -520,7 +520,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>J1>Q01>QC9>Earth_Switch>(CSWI OpenSCD_CSWI)" + value="AA1>J1>Q01>QC9>Earth_Switch>(CSWI OpenSCD_CSWI 1)" > CSWI 1 @@ -529,7 +529,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>J1>Q01>QC9>Earth_Switch>(CILO OpenSCD_CILO)" + value="AA1>J1>Q01>QC9>Earth_Switch>(CILO OpenSCD_CILO 1)" > CILO 1 @@ -538,7 +538,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE graphic="control" mwc-list-item="" tabindex="-1" - value="AA1>J1>Q01>QC9>Earth_Switch>(XSWI OpenSCD_XSWI_EarthSwitch)" + value="AA1>J1>Q01>QC9>Earth_Switch>(XSWI OpenSCD_XSWI_EarthSwitch 1)" > XSWI 1 @@ -569,7 +569,7 @@ snapshots["Plugin that creates with some user input a virtual template IED - SPE `; /* end snapshot Plugin that creates with some user input a virtual template IED - SPECIFICATION looks like the latest snapshot */ -snapshots["Plugin that creates with some user input a virtual template IED - SPECIFICATION IEDs data model show selected logical nodes and its structure"] = +snapshots["Plugin that creates with some user input a virtual template IED - SPECIFICATION IEDs data model show selected logical nodes and its structure"] = ` diff --git a/packages/plugins/web-test-runner.config.mjs b/packages/plugins/web-test-runner.config.mjs index d717d5ef1f..4386b9bd4c 100644 --- a/packages/plugins/web-test-runner.config.mjs +++ b/packages/plugins/web-test-runner.config.mjs @@ -28,6 +28,10 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ name: 'integration', files: 'test/integration/**/*.test.ts', }, + // { + // name: 'single', + // files: 'test/integration/validators/ValidateTemplates.test.ts', + // }, ], /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ @@ -41,7 +45,14 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ /** Browsers to run tests on */ browsers: [ - playwrightLauncher({ product: 'chromium' }), + playwrightLauncher({ + // concurrency: 1, + product: 'chromium', + launchOptions: { + headless: true, + devtools: false, + }, + }), // playwrightLauncher({ product: 'firefox' }), // playwrightLauncher({ product: 'webkit' }), ],