diff --git a/.github/workflows/build-project.yml b/.github/workflows/build-project.yml index e408cd1830..70651c63e0 100644 --- a/.github/workflows/build-project.yml +++ b/.github/workflows/build-project.yml @@ -35,10 +35,10 @@ jobs: id: buildx if: ${{ github.event_name == 'pull_request' }} uses: docker/setup-buildx-action@v3 - - name: Use Node.js 18.x + - name: Use Node.js 20.x uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '20.x' - name: Test application with npm timeout-minutes: 45 diff --git a/.github/workflows/release-project.yml b/.github/workflows/release-project.yml index 54ae038d33..a05e92d001 100644 --- a/.github/workflows/release-project.yml +++ b/.github/workflows/release-project.yml @@ -39,10 +39,10 @@ jobs: with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Use Node.js 18.x + - name: Use Node.js 20.x uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: '20.x' - name: Build application with npm run: | diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 54f0dd714d..b477e1c35c 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -10,10 +10,10 @@ jobs: with: submodules: 'true' - - name: Use Node.js 18.x + - name: Use Node.js 20.x uses: actions/setup-node@v6 with: - node-version: "18.x" + node-version: "20.x" - name: Install and Test OpenSCD run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03d49b5e42..3d93efa029 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,10 @@ jobs: with: submodules: 'true' - - name: Use Node.js 18.x + - name: Use Node.js 20.x uses: actions/setup-node@v6 with: - node-version: "18.x" + node-version: "20.x" - name: Install and Test run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ff068419ac..2ec6448dcc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { "packages/openscd": "0.37.0", "packages/core": "0.1.4", - ".": "0.43.0" + ".": "0.44.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d0c6f032..8747c7d211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.44.0](https://github.com/openscd/open-scd/compare/v0.43.0...v0.44.0) (2025-11-10) + + +### Features + +* API compliant editor ([#1719](https://github.com/openscd/open-scd/issues/1719)) ([e43ee6a](https://github.com/openscd/open-scd/commit/e43ee6a10805d1d09e5a8adb539be7d68a65ab6a)) +* Form library ([#1718](https://github.com/openscd/open-scd/issues/1718)) ([396bb13](https://github.com/openscd/open-scd/commit/396bb13d0d5ecf6c3994072f00958587bb9e5fcd)) + + +### Bug Fixes + +* update node to 20.x in release workflow ([#1716](https://github.com/openscd/open-scd/issues/1716)) ([115f22a](https://github.com/openscd/open-scd/commit/115f22a6f1f9cfe02a4817ec03811bcc53cd1fea)) + ## [0.43.0](https://github.com/openscd/open-scd/compare/v0.42.0...v0.43.0) (2025-10-27) diff --git a/package-lock.json b/package-lock.json index 2ca334b3dd..50fa72aea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8284,10 +8284,23 @@ "resolved": "packages/distribution", "link": true }, + "node_modules/@openscd/forms": { + "resolved": "packages/forms", + "link": true + }, "node_modules/@openscd/open-scd": { "resolved": "packages/openscd", "link": true }, + "node_modules/@openscd/oscd-api": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@openscd/oscd-api/-/oscd-api-0.1.5.tgz", + "integrity": "sha512-SPm79bIqhSSxYMfnHIIwfuNpQa+UyJPY4Gxl6MVjHLY1jOkhevL+k/FfI4g08v3RdMG/+2MYu6ZxpB4zuQU5ZQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/@openscd/plugins": { "resolved": "packages/plugins", "link": true @@ -32436,6 +32449,7 @@ "dependencies": { "@lit/localize": "^0.11.4", "@open-wc/lit-helpers": "^0.5.1", + "@openscd/oscd-api": "^0.1.5", "lit": "^2.2.7" }, "devDependencies": { @@ -32449,6 +32463,7 @@ "@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/parser": "^5.30.7", "@web/dev-server": "^0.1.32", + "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "next", "@web/test-runner-playwright": "^0.8.10", "@web/test-runner-visual-regression": "^0.6.6", @@ -34421,6 +34436,167 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "packages/forms": { + "name": "@openscd/forms", + "version": "0.0.1", + "license": "Apache-2.0", + "devDependencies": { + "@open-wc/testing": "^2.5.33", + "@web/dev-server-esbuild": "^0.2.16", + "@web/test-runner": "^0.13.22", + "sinon": "^17.0.1", + "sinon-chai": "^3.7.0", + "typescript": "^4.7.4" + } + }, + "packages/forms/node_modules/@open-wc/scoped-elements": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-1.3.7.tgz", + "integrity": "sha512-q/wKf4sXl7cr1kNfl8z6TLO2TrpXsFMCrfCD51sCEljltwYIXOmI6SnRXmWlnzG37A8AwHRpDXYmjPj2F4gPxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "lit-html": "^1.0.0" + } + }, + "packages/forms/node_modules/@open-wc/testing": { + "version": "2.5.33", + "resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-2.5.33.tgz", + "integrity": "sha512-+EJNs0i+VV4nE+BrG70l2DNGXOZTSrluruaaU06HUSk57ZlKa+kIxWmkLxCOLlbgnQgrPrQWxbs3lgB1tIx/YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-wc/chai-dom-equals": "^0.12.36", + "@open-wc/semantic-dom-diff": "^0.19.3", + "@open-wc/testing-helpers": "^1.8.12", + "@types/chai": "^4.2.11", + "@types/chai-dom": "^0.0.9", + "@types/mocha": "^5.2.7", + "@types/sinon-chai": "^3.2.3", + "chai": "^4.2.0", + "chai-a11y-axe": "^1.3.1", + "chai-dom": "^1.8.1", + "mocha": "^6.2.2", + "sinon-chai": "^3.5.0" + } + }, + "packages/forms/node_modules/@open-wc/testing-helpers": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-1.8.12.tgz", + "integrity": "sha512-+4exEHYvnFqI1RGDDIKFHPZ7Ws5NK1epvEku3zLaOYN3zc+huX19SndNc5+X++v8A+quN/iXbHlh80ROyNaYDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-wc/scoped-elements": "^1.2.4", + "lit-element": "^2.2.1", + "lit-html": "^1.0.0" + } + }, + "packages/forms/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/forms/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/forms/node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "packages/forms/node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/forms/node_modules/@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true, + "license": "MIT" + }, + "packages/forms/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/forms/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "packages/forms/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/forms/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "packages/open-scd": { "version": "0.33.0", "extraneous": true, @@ -34511,6 +34687,7 @@ "@material/mwc-textfield": "0.22.1", "@material/mwc-top-app-bar-fixed": "0.22.1", "@openscd/core": "*", + "@openscd/oscd-api": "^0.1.5", "@openscd/xml": "*", "ace-custom-element": "^1.6.5", "lit": "^2.2.7", @@ -44610,11 +44787,13 @@ "@open-wc/eslint-config": "^7.0.0", "@open-wc/lit-helpers": "^0.5.1", "@open-wc/testing": "next", + "@openscd/oscd-api": "^0.1.5", "@rollup/plugin-typescript": "^9.0.2", "@types/node": "^18.11.9", "@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/parser": "^5.30.7", "@web/dev-server": "^0.1.32", + "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner": "next", "@web/test-runner-playwright": "^0.8.10", "@web/test-runner-visual-regression": "^0.6.6", @@ -46011,6 +46190,137 @@ } } }, + "@openscd/forms": { + "version": "file:packages/forms", + "requires": { + "@open-wc/testing": "^2.5.33", + "@web/dev-server-esbuild": "^0.2.16", + "@web/test-runner": "^0.13.22", + "sinon": "^17.0.1", + "sinon-chai": "^3.7.0", + "typescript": "^4.7.4" + }, + "dependencies": { + "@open-wc/scoped-elements": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@open-wc/scoped-elements/-/scoped-elements-1.3.7.tgz", + "integrity": "sha512-q/wKf4sXl7cr1kNfl8z6TLO2TrpXsFMCrfCD51sCEljltwYIXOmI6SnRXmWlnzG37A8AwHRpDXYmjPj2F4gPxA==", + "dev": true, + "requires": { + "@open-wc/dedupe-mixin": "^1.3.0", + "lit-html": "^1.0.0" + } + }, + "@open-wc/testing": { + "version": "2.5.33", + "resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-2.5.33.tgz", + "integrity": "sha512-+EJNs0i+VV4nE+BrG70l2DNGXOZTSrluruaaU06HUSk57ZlKa+kIxWmkLxCOLlbgnQgrPrQWxbs3lgB1tIx/YA==", + "dev": true, + "requires": { + "@open-wc/chai-dom-equals": "^0.12.36", + "@open-wc/semantic-dom-diff": "^0.19.3", + "@open-wc/testing-helpers": "^1.8.12", + "@types/chai": "^4.2.11", + "@types/chai-dom": "^0.0.9", + "@types/mocha": "^5.2.7", + "@types/sinon-chai": "^3.2.3", + "chai": "^4.2.0", + "chai-a11y-axe": "^1.3.1", + "chai-dom": "^1.8.1", + "mocha": "^6.2.2", + "sinon-chai": "^3.5.0" + } + }, + "@open-wc/testing-helpers": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-1.8.12.tgz", + "integrity": "sha512-+4exEHYvnFqI1RGDDIKFHPZ7Ws5NK1epvEku3zLaOYN3zc+huX19SndNc5+X++v8A+quN/iXbHlh80ROyNaYDA==", + "dev": true, + "requires": { + "@open-wc/scoped-elements": "^1.2.4", + "lit-element": "^2.2.1", + "lit-html": "^1.0.0" + } + }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true + } + } + }, "@openscd/open-scd": { "version": "file:packages/openscd", "requires": { @@ -46038,6 +46348,7 @@ "@open-wc/semantic-dom-diff": "^0.19.5", "@open-wc/testing": "^2.5.33", "@openscd/core": "*", + "@openscd/oscd-api": "^0.1.5", "@openscd/xml": "*", "@snowpack/plugin-typescript": "^1.2.1", "@types/marked": "^2.0.4", @@ -47031,6 +47342,14 @@ } } }, + "@openscd/oscd-api": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@openscd/oscd-api/-/oscd-api-0.1.5.tgz", + "integrity": "sha512-SPm79bIqhSSxYMfnHIIwfuNpQa+UyJPY4Gxl6MVjHLY1jOkhevL+k/FfI4g08v3RdMG/+2MYu6ZxpB4zuQU5ZQ==", + "requires": { + "tslib": "^2.8.1" + } + }, "@openscd/plugins": { "version": "file:packages/plugins", "requires": { diff --git a/package.json b/package.json index 2eceb19ce8..8556f73874 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "npx nx run-many -t build --all --exclude=@openscd/distribution" + "build": "NODE_OPTIONS=--no-experimental-require-module npx nx run-many -t build --all --exclude=@openscd/distribution" }, "repository": { "type": "git", diff --git a/packages/compas-open-scd/manifest.json b/packages/compas-open-scd/manifest.json index d2ca32cb14..04b66f3624 100644 --- a/packages/compas-open-scd/manifest.json +++ b/packages/compas-open-scd/manifest.json @@ -40,5 +40,5 @@ "purpose": "maskable" } ], - "version": "0.33.0" + "version": "0.44.0-1" } diff --git a/packages/compas-open-scd/package.json b/packages/compas-open-scd/package.json index fbdf349ee1..f04fd24f5b 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.43.0-1", + "version": "0.44.0-1", "repository": "https://github.com/openscd/open-scd.git", "description": "OpenSCD CoMPAS Edition", "directory": "packages/compas-open-scd", @@ -73,7 +73,7 @@ "build": "npm run doc && npm run build:only && cp .nojekyll build/", "build:only": "npx rimraf node_modules/.cache/snowpack/build/lit@2.8.0 && snowpack build && workbox generateSW workbox-config.cjs", "__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" + "start": "npx rimraf node_modules/.cache/snowpack/build/lit@2.8.0 && NODE_OPTIONS=--no-experimental-require-module snowpack dev" }, "devDependencies": { "@commitlint/cli": "^13.1.0", diff --git a/packages/compas-open-scd/src/open-scd.ts b/packages/compas-open-scd/src/open-scd.ts index 4274dc225e..21072ef4f7 100644 --- a/packages/compas-open-scd/src/open-scd.ts +++ b/packages/compas-open-scd/src/open-scd.ts @@ -16,10 +16,6 @@ import './addons/CompasLayout.js'; import '@openscd/open-scd/src/addons/Waiter.js'; import '@openscd/open-scd/src/addons/Settings.js'; -import { - HistoryState, - historyStateEvent, -} from '@openscd/open-scd/src/addons/History.js'; import { initializeNsdoc, Nsdoc, @@ -34,7 +30,7 @@ import { ActionDetail } from '@material/mwc-list'; import { officialPlugins as builtinPlugins } from '../public/js/plugins.js'; import type { PluginSet, Plugin as CorePlugin } from '@openscd/core'; -import { OscdApi } from '@openscd/core'; +import { OscdApi, XMLEditor } from '@openscd/core'; import { classMap } from 'lit-html/directives/class-map.js'; import { newConfigurePluginEvent, @@ -65,15 +61,15 @@ export class OpenSCD extends LitElement { @@ -104,12 +100,7 @@ export class OpenSCD extends LitElement { /** The UUID of the current [[`doc`]] */ @property({ type: String }) docId = ''; - @state() - historyState: HistoryState = { - editCount: -1, - canRedo: false, - canUndo: false, - }; + editor = new XMLEditor(); /** Object containing all *.nsdoc files and a function extracting element's label form them*/ @property({ attribute: false }) @@ -128,6 +119,10 @@ export class OpenSCD extends LitElement { @state() private storedPlugins: Plugin[] = []; + @state() private editCount = -1; + + private unsubscribers: (() => any)[] = []; + /** Loads and parses an `XMLDocument` after [[`src`]] has changed. */ private async loadDoc(src: string): Promise { const response = await fetch(src); @@ -216,12 +211,17 @@ export class OpenSCD extends LitElement { this.checkAppVersion(); this.loadPlugins(); + this.unsubscribers.push( + this.editor.subscribe(e => this.editCount++), + this.editor.subscribeUndoRedo(e => this.editCount++) + ); + // 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(); - }); + } + + disconnectedCallback(): void { + this.unsubscribers.forEach(u => u()); } /** @@ -465,7 +465,7 @@ export class OpenSCD extends LitElement { return staticTagHtml`<${tag} .doc=${this.doc} .docName=${this.docName} - .editCount=${this.historyState.editCount} + .editCount=${this.editCount} .plugins=${this.storedPlugins} .docId=${this.docId} .pluginId=${plugin.src} @@ -473,6 +473,7 @@ export class OpenSCD extends LitElement { .docs=${this.docs} .locale=${this.locale} .oscdApi=${new OscdApi(tag)} + .editor=${this.editor} .compasApi=${this.compasApi} class="${classMap({ plugin: true, diff --git a/packages/core/.gitignore b/packages/core/.gitignore index d56a73fc47..d6df7fd911 100644 --- a/packages/core/.gitignore +++ b/packages/core/.gitignore @@ -3,3 +3,4 @@ dist/ node_modules/ doc/ +coverage/ diff --git a/packages/core/api/editor/subject.ts b/packages/core/api/editor/subject.ts new file mode 100644 index 0000000000..9719bb1dbe --- /dev/null +++ b/packages/core/api/editor/subject.ts @@ -0,0 +1,26 @@ +export type Subscriber = (value: T) => void; +export type Unsubscriber = () => Subscriber; + +export class Subject { + private subscribers: Subscriber[] = []; + + public next(value: T): void { + this.subscribers.forEach(s => s(value)); + } + + public subscribe(subscriber: Subscriber): Unsubscriber { + this.subscribers.push(subscriber); + + return () => { + this.unsubscribe(subscriber); + return subscriber; + } + } + + public unsubscribe(subscriber: Subscriber): void { + const indexToRemove = this.subscribers.findIndex(s => s === subscriber); + if (indexToRemove > -1) { + this.subscribers.splice(indexToRemove, 1); + } + } +} diff --git a/packages/core/api/editor/xml-editor.ts b/packages/core/api/editor/xml-editor.ts new file mode 100644 index 0000000000..89d30f8f19 --- /dev/null +++ b/packages/core/api/editor/xml-editor.ts @@ -0,0 +1,92 @@ +import { Transactor, TransactedCallback, Commit, CommitOptions } from '@openscd/oscd-api/dist/Transactor.js'; +import { EditV2 } from '@openscd/oscd-api/dist/editv2.js'; + +import { Subject } from './subject.js'; +import { handleEditV2 } from '../../foundation.js'; + +export interface OscdCommit extends Commit { + time: number; +} + +export class XMLEditor implements Transactor { + public past: OscdCommit[] = []; + public future: OscdCommit[] = []; + + private commitSubject = new Subject>(); + private undoSubject = new Subject>(); + private redoSubject = new Subject>(); + + get canUndo(): boolean { + return this.past.length > 0; + } + + get canRedo(): boolean { + return this.future.length > 0; + } + + reset(): void { + this.past = []; + this.future = []; + } + + commit(change: EditV2, { title, squash }: CommitOptions = {}): OscdCommit { + const commit: OscdCommit = + squash && this.past.length + ? this.past[this.past.length - 1] + : { undo: [], redo: [], time: Date.now() }; + // TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57 + const undo = handleEditV2(change as any); + // typed as per https://github.com/microsoft/TypeScript/issues/49280#issuecomment-1144181818 recommendation: + commit.undo.unshift(...[undo].flat(Infinity as 1)); + commit.redo.push(...[change].flat(Infinity as 1)); + if (title) commit.title = title; + if (squash && this.past.length) this.past.pop(); + this.past.push(commit); + this.future = []; + this.commitSubject.next(commit); + return commit; + }; + + undo(): OscdCommit | undefined { + const commit = this.past.pop(); + if (!commit) return; + // TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57 + handleEditV2(commit.undo as any); + this.future.unshift(commit); + this.undoSubject.next(commit); + return commit; + }; + + redo(): OscdCommit | undefined { + const commit = this.future.shift(); + if (!commit) return; + // TODO: Fix type once issue is fixed https://github.com/openscd/oscd-api/issues/57 + handleEditV2(commit.redo as any); + this.past.push(commit); + this.redoSubject.next(commit); + return commit; + }; + + subscribe(txCallback: TransactedCallback): () => TransactedCallback { + return this.commitSubject.subscribe(txCallback) as () => TransactedCallback; + }; + + subscribeUndo(txCallback: TransactedCallback): () => TransactedCallback { + return this.undoSubject.subscribe(txCallback) as () => TransactedCallback; + } + + subscribeRedo(txCallback: TransactedCallback): () => TransactedCallback { + return this.redoSubject.subscribe(txCallback) as () => TransactedCallback; + } + + subscribeUndoRedo(txCallback: TransactedCallback): () => TransactedCallback { + const unsubscribeUndo = this.subscribeUndo(txCallback); + const unsubscribeRedo = this.subscribeRedo(txCallback); + + return () => { + unsubscribeUndo(); + unsubscribeRedo(); + return txCallback; + } + } +} diff --git a/packages/core/foundation.ts b/packages/core/foundation.ts index a408b03b73..a7d30aa52d 100644 --- a/packages/core/foundation.ts +++ b/packages/core/foundation.ts @@ -68,3 +68,5 @@ export function crossProduct(...arrays: T[][]): T[][] { } export { OscdApi } from './api/api.js'; + +export { XMLEditor } from './api/editor/xml-editor.js'; diff --git a/packages/core/foundation/deprecated/history.ts b/packages/core/foundation/deprecated/history.ts index b78f919432..39f462c34e 100644 --- a/packages/core/foundation/deprecated/history.ts +++ b/packages/core/foundation/deprecated/history.ts @@ -12,9 +12,6 @@ export interface LogDetailBase { /** The [[`LogEntry`]] for a committed [[`EditorAction`]]. */ export interface CommitDetail extends LogDetailBase { kind: 'action'; - redo: EditV2; - undo: EditV2; - squash?: boolean; } /** A [[`LogEntry`]] for notifying the user. */ export interface InfoDetail extends LogDetailBase { diff --git a/packages/core/package.json b/packages/core/package.json index 8db774da98..3e9a7f52fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "clean": "rimraf .tsbuildinfo dist", "build": "tsc -b", "doc": "typedoc --out doc foundation.ts", + "test": "web-test-runner --coverage", "prepublish": "npm run lint && npm run build && npm run doc", "lint": "eslint --ext .ts,.html . --ignore-path .gitignore && prettier \"**/*.ts\" --check --ignore-path .gitignore", "format": "eslint --ext .ts,.html . --fix --ignore-path .gitignore && prettier \"**/*.ts\" --write --ignore-path .gitignore" @@ -38,7 +39,8 @@ "dependencies": { "@lit/localize": "^0.11.4", "@open-wc/lit-helpers": "^0.5.1", - "lit": "^2.2.7" + "lit": "^2.2.7", + "@openscd/oscd-api": "^0.1.5" }, "devDependencies": { "@custom-elements-manifest/analyzer": "^0.6.3", @@ -52,6 +54,7 @@ "@typescript-eslint/parser": "^5.30.7", "@web/dev-server": "^0.1.32", "@web/test-runner": "next", + "@web/dev-server-esbuild": "^0.2.16", "@web/test-runner-playwright": "^0.8.10", "@web/test-runner-visual-regression": "^0.6.6", "concurrently": "^7.3.0", diff --git a/packages/core/test/subject.test.ts b/packages/core/test/subject.test.ts new file mode 100644 index 0000000000..f72640966b --- /dev/null +++ b/packages/core/test/subject.test.ts @@ -0,0 +1,63 @@ +import { expect } from '@open-wc/testing'; + +import { Subject } from '../api/editor/subject.js'; + +describe('Subject', () => { + let subject: Subject; + + let subOneValues: string[]; + let subTwoValues: string[]; + + beforeEach(() => { + subject = new Subject(); + + subOneValues = []; + subTwoValues = []; + }); + + it('should call subscribers on next', () => { + const subscriberOne = (v: string) => subOneValues.push(v); + const subscriberTwo = (v: string) => subTwoValues.push(v); + + subject.subscribe(subscriberOne); + + subject.next('first'); + + expect(subOneValues).to.deep.equal([ 'first' ]); + expect(subTwoValues).to.deep.equal([]); + + subject.subscribe(subscriberTwo); + + subject.next('second'); + + expect(subOneValues).to.deep.equal([ 'first', 'second' ]); + expect(subTwoValues).to.deep.equal([ 'second' ]); + }); + + it('should remove correct subscriber on unsubscribe', () => { + const subscriberOne = (v: string) => subOneValues.push(v); + const subscriberTwo = (v: string) => subTwoValues.push(v); + + const unsubscribeOne = subject.subscribe(subscriberOne); + const unsubscribeTwo = subject.subscribe(subscriberTwo); + + subject.next('first'); + + expect(subOneValues).to.deep.equal([ 'first' ]); + expect(subTwoValues).to.deep.equal([ 'first' ]); + + unsubscribeOne(); + + subject.next('second'); + + expect(subOneValues).to.deep.equal([ 'first' ]); + expect(subTwoValues).to.deep.equal([ 'first', 'second' ]); + + unsubscribeTwo(); + + subject.next('third'); + + expect(subOneValues).to.deep.equal([ 'first' ]); + expect(subTwoValues).to.deep.equal([ 'first', 'second' ]); + }); +}); diff --git a/packages/core/test/xml-editor.test.ts b/packages/core/test/xml-editor.test.ts new file mode 100644 index 0000000000..9ee352d2de --- /dev/null +++ b/packages/core/test/xml-editor.test.ts @@ -0,0 +1,107 @@ +import { expect } from '@open-wc/testing'; +import { EditV2 } from '@openscd/oscd-api/dist/editv2.js'; + +import { OscdCommit, XMLEditor } from '../api/editor/xml-editor.js'; +import { RemoveV2 } from '../foundation.js'; + +describe('XMLEditor', () => { + let editor: XMLEditor; + let scd: XMLDocument; + let subscriberValues: OscdCommit[]; + + let substation: Element; + let voltageLevel: Element; + let bay1: Element; + + beforeEach(() => { + editor = new XMLEditor(); + + subscriberValues = []; + + scd = new DOMParser().parseFromString( + ` + + + + + + `, + 'application/xml' + ); + + substation = scd.querySelector('Substation')!; + voltageLevel = scd.querySelector('VoltageLevel')!; + bay1 = scd.querySelector('Bay')!; + }); + + it('should call subscriber on commit', () => { + editor.subscribe(c => subscriberValues.push(c as any)); + + const deleteBay: RemoveV2 = { + node: bay1 + }; + + editor.commit(deleteBay); + + const [ commit ] = subscriberValues; + expect(commit.redo).to.deep.equal([ deleteBay ]); + }); + + it('should set title in commit', () => { + const title = 'Important change'; + + const deleteBay: RemoveV2 = { + node: bay1 + }; + + editor.commit(deleteBay, { title }); + + const [ commit ] = editor.past; + expect(commit.title).to.equal(title); + }); + + it('should undo and redo changes', () => { + const deleteBay: RemoveV2 = { + node: bay1 + }; + + editor.commit(deleteBay); + + const bayAfterDelete = scd.querySelector('Bay'); + expect(bayAfterDelete).to.be.null; + + editor.undo(); + + const bayAfterUndo = scd.querySelector('Bay'); + expect(bayAfterUndo).to.equal(bay1); + + editor.redo(); + + const bayAfterRedo = scd.querySelector('Bay'); + expect(bayAfterRedo).to.be.null; + }); + + it('should call subscribers on undo and redo', () => { + const undos = []; + const redos = []; + + editor.subscribeUndo(c => undos.push(c)); + editor.subscribeRedo(c => redos.push(c)); + + const deleteBay: RemoveV2 = { + node: bay1 + }; + + editor.commit(deleteBay); + + editor.undo(); + + const [ lastUndo ] = undos; + expect(lastUndo.redo).to.deep.equal([ deleteBay ]); + + editor.redo(); + + const [ lastRedo ] = redos; + expect(lastRedo.redo).to.deep.equal([ deleteBay ]); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5274920d1c..65e3040775 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -19,5 +19,6 @@ "tsBuildInfoFile": ".tsbuildinfo", "incremental": true }, - "include": ["**/*.ts"] + "include": ["**/*.ts"], + "exclude": ["**/*.test.ts"] } diff --git a/packages/core/web-test-runner.config.js b/packages/core/web-test-runner.config.js index 334e8ef62d..9e4f774453 100644 --- a/packages/core/web-test-runner.config.js +++ b/packages/core/web-test-runner.config.js @@ -1,162 +1,26 @@ -import { visualRegressionPlugin } from '@web/test-runner-visual-regression/plugin'; -import { playwrightLauncher } from '@web/test-runner-playwright'; - -import pixelmatch from 'pixelmatch'; -import { PNG } from 'pngjs'; - -const fuzzy = ['win32', 'darwin'].includes(process.platform); // allow for 1% difference on non-linux OSs -const local = !process.env.CI; - -console.assert(local, 'Running in CI!'); -console.assert(!fuzzy, 'Running on OS with 1% test pixel diff threshold!'); - -const thresholdPercentage = fuzzy && local ? 1 : 0; - -const filteredLogs = [ - 'Running in dev mode', - 'Lit is in dev mode', - 'mwc-list-item scheduled an update', -]; - -const browsers = [ - playwrightLauncher({ product: 'chromium' }), - // playwrightLauncher({ product: 'firefox' }), - // playwrightLauncher({ product: 'webkit' }), - ]; - -function defaultGetImageDiff({ baselineImage, image, options }) { - let error = ''; - let basePng = PNG.sync.read(baselineImage); - let png = PNG.sync.read(image); - let { width, height } = png; - - if (basePng.width !== png.width || basePng.height !== png.height) { - error = - `Screenshot is not the same width and height as the baseline. ` + - `Baseline: { width: ${basePng.width}, height: ${basePng.height} }` + - `Screenshot: { width: ${png.width}, height: ${png.height} }`; - width = Math.max(basePng.width, png.width); - height = Math.max(basePng.height, png.height); - let oldPng = basePng; - basePng = new PNG({ width, height }); - oldPng.data.copy(basePng.data, 0, 0, oldPng.data.length); - oldPng = png; - png = new PNG({ width, height }); - oldPng.data.copy(png.data, 0, 0, oldPng.data.length); - } - - const diff = new PNG({ width, height }); - - const numDiffPixels = pixelmatch(basePng.data, png.data, diff.data, width, height, options); - const diffPercentage = (numDiffPixels / (width * height)) * 100; - - return { - error, - diffImage: PNG.sync.write(diff), - diffPercentage, - }; -} +// import { playwrightLauncher } from '@web/test-runner-playwright'; +import { esbuildPlugin } from '@web/dev-server-esbuild'; export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ - plugins: [ - visualRegressionPlugin({ - update: process.argv.includes('--update-visual-baseline'), - getImageDiff: (options) => { - const result = defaultGetImageDiff(options); - if (result.diffPercentage < thresholdPercentage) - result.diffPercentage = 0; - return result; - } - }), - ], + /** we run test directly on TypeScript files */ + plugins: [esbuildPlugin({ ts: true })], - files: 'dist/**/*.spec.js', + /** Resolve bare module imports */ + nodeResolve: true, + + /** filter browser logs + * Plugins have a fix URL and do not fit to the file structure in test environment. + * Creating open-scd in the tests leads to error in the browser log - we had to disable the browser log + */ + browserLogs: false, + /** specify groups for unit and integrations tests + * hint: no --group definition runs all groups + */ groups: [ { - name: 'visual', - files: 'dist/**/*.test.js', - testRunnerHtml: testFramework => ` - - - - - - - - - - - - -`, + name: 'unit', + files: 'test/**/*.test.ts', }, ], - - /** Resolve bare module imports */ - nodeResolve: { - exportConditions: ['browser', 'development'], - }, - - /** Filter out lit dev mode logs */ - filterBrowserLogs(log) { - for (const arg of log.args) { - if (typeof arg === 'string' && filteredLogs.some(l => arg.includes(l))) { - return false; - } - } - return true; - }, - - /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ - // esbuildTarget: 'auto', - - /** Amount of browsers to run concurrently */ - concurrentBrowsers: 3, - - /** Amount of test files per browser to test concurrently */ - concurrency: 2, - - /** Browsers to run tests on */ - browsers, - - // See documentation for all available options }); diff --git a/packages/forms/.gitignore b/packages/forms/.gitignore new file mode 100644 index 0000000000..d6df7fd911 --- /dev/null +++ b/packages/forms/.gitignore @@ -0,0 +1,6 @@ +.tsbuildinfo + +dist/ +node_modules/ +doc/ +coverage/ diff --git a/packages/forms/package.json b/packages/forms/package.json new file mode 100644 index 0000000000..9cab5fd785 --- /dev/null +++ b/packages/forms/package.json @@ -0,0 +1,31 @@ +{ + "name": "@openscd/forms", + "version": "0.0.1", + "description": "The forms package OpenSCD", + "author": "Open-SCD", + "license": "Apache-2.0", + "packageManager": "npm@8.12.2", + "type": "module", + "browser": "./dist/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "./dist/**" + ], + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "clean": "rimraf .tsbuildinfo dist", + "build": "tsc -b", + "test": "web-test-runner --coverage" + }, + "devDependencies": { + "typescript": "^4.7.4", + "@open-wc/testing": "^2.5.33", + "@web/dev-server-esbuild": "^0.2.16", + "@web/test-runner": "^0.13.22", + "sinon": "^17.0.1", + "sinon-chai": "^3.7.0" + } +} diff --git a/packages/forms/project.json b/packages/forms/project.json new file mode 100644 index 0000000000..7fe5dad49d --- /dev/null +++ b/packages/forms/project.json @@ -0,0 +1,7 @@ +{ + "name": "@openscd/forms", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/forms/src", + "targets": {} +} diff --git a/packages/forms/src/form-group.ts b/packages/forms/src/form-group.ts new file mode 100644 index 0000000000..d29e572308 --- /dev/null +++ b/packages/forms/src/form-group.ts @@ -0,0 +1,60 @@ +import { Form, Validator, FormValue, FormErrors } from "./types/types.js" + +export class FormGroup { + private form: Form; + private _errors: FormErrors = {}; + + public get errors(): FormErrors { + return this._errors; + } + + constructor(form: Form) { + this.form = form; + } + + public validate(): boolean { + const formValue = this.getFormValue(); + let hasError = false; + + for (const [fieldKey, fieldDefinition] of Object.entries(this.form)) { + const validators = this.toArray(fieldDefinition.validators); + + const value = formValue[fieldKey]; + + const errors: string[] = validators + .map(v => v(value, formValue)) + .filter(e => e !== null) as string[]; + + this.errors[fieldKey] = errors; + + if (errors.length > 0) { + fieldDefinition.formField.error = true; + fieldDefinition.formField.errorText = errors[0]; + + hasError = true; + } else { + fieldDefinition.formField.error = false; + fieldDefinition.formField.errorText = null; + } + } + + return !hasError; + } + + private getFormValue(): FormValue { + const formValue: FormValue = {}; + + Object.keys(this.form).forEach(key => formValue[key] = this.form[key].formField.value); + + return formValue; + } + + private toArray(validators: Validator | Validator[] | undefined): Validator[] { + if (!validators) { + return []; + } + + return Array.isArray(validators) ? validators : [ validators ]; + } + +} diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts new file mode 100644 index 0000000000..5be7489faf --- /dev/null +++ b/packages/forms/src/index.ts @@ -0,0 +1,5 @@ +export * from "./validators.js"; + +export * from "./form-group.js"; + +export * from "./types/types.js"; diff --git a/packages/forms/src/types/types.ts b/packages/forms/src/types/types.ts new file mode 100644 index 0000000000..5b06370e99 --- /dev/null +++ b/packages/forms/src/types/types.ts @@ -0,0 +1,29 @@ +export type Value = null | string | number | boolean; + +// Interface to be usable with material form fields such as MdTextField, MdSelect etc +export interface FormField { + value: Value; + error: boolean; + errorText: string | null; +} + +export interface Validator { + (value: Value, formValue: { [key: string]: Value }): null | string; +} + +export interface FormFieldDefinition { + formField: FormField, + validators?: Validator | Validator[] +} + +export interface Form { + [key: string]: FormFieldDefinition +} + +export interface FormValue { + [key: string]: Value; +} + +export interface FormErrors { + [key: string]: string[]; +} diff --git a/packages/forms/src/validators.ts b/packages/forms/src/validators.ts new file mode 100644 index 0000000000..31807bb9d9 --- /dev/null +++ b/packages/forms/src/validators.ts @@ -0,0 +1,24 @@ +import { FormValue, Validator, Value } from "./types/types.js" + +const required: (errorMessage: string) => Validator = (errorMessage: string) => (value, formValue) => { + if (typeof value === 'number') { + return isNaN(value) ? errorMessage : null; + } + + return value ? null : errorMessage; +} + +const requiredIf: (predicate: (value: Value, formValue: FormValue) => boolean, errorMessage: string) => Validator = (predicate, errorMessage) => (value, formValue) => { + const isRequired = predicate(value, formValue); + + if (isRequired) { + return required(errorMessage)(value, formValue); + } + + return null; +} + +export const Validators = { + required, + requiredIf +} diff --git a/packages/forms/test/form-group.test.ts b/packages/forms/test/form-group.test.ts new file mode 100644 index 0000000000..8e17c2b197 --- /dev/null +++ b/packages/forms/test/form-group.test.ts @@ -0,0 +1,63 @@ +import { expect } from '@open-wc/testing'; + +import { FormGroup, Validator, Validators } from '../src/index.js'; +import { MockFormField } from './mocks/mock-form-fields.js'; + + +describe('FormGroup', () => { + it('should validate if there are no validators', () => { + const formField = new MockFormField(); + const formGroup = new FormGroup({ + name: { formField } + }); + + const isValid = formGroup.validate(); + + expect(isValid).to.be.true; + }); + + it('should validate and set error and errorMessage on the appropriate form field', () => { + const nameField = new MockFormField(); + const townField = new MockFormField(); + const formGroup = new FormGroup({ + name: { formField: nameField }, + town: { formField: townField, validators: Validators.required('Town required') } + }); + + const isValid = formGroup.validate(); + + expect(isValid).to.be.false; + expect(nameField.error).to.be.false; + expect(nameField.errorText).to.be.null; + expect(townField.error).to.be.true; + expect(townField.errorText).to.equal('Town required'); + }); + + it('should validate default and custom validators', () => { + const nameField = new MockFormField(); + const startsWithB: Validator = (v) => { + const isValid = typeof v === 'string' && v.startsWith('B'); + return isValid ? null : 'Must start with B'; + } + + const formGroup = new FormGroup({ + name: { formField: nameField, validators: [ Validators.required('Name required'), startsWithB ] } + }); + + let isValid = formGroup.validate(); + expect(isValid).to.be.false; + expect(nameField.errorText).to.equal('Name required'); + + nameField.value = 'A invalid value'; + + isValid = formGroup.validate(); + expect(isValid).to.be.false; + expect(nameField.errorText).to.equal('Must start with B'); + + nameField.value = 'B is a valid value'; + + isValid = formGroup.validate(); + expect(isValid).to.be.true; + expect(nameField.errorText).to.be.null; + }); +}); diff --git a/packages/forms/test/mocks/mock-form-fields.ts b/packages/forms/test/mocks/mock-form-fields.ts new file mode 100644 index 0000000000..06802e7746 --- /dev/null +++ b/packages/forms/test/mocks/mock-form-fields.ts @@ -0,0 +1,7 @@ +import { FormField, Value } from '../../src/types/types.js'; + +export class MockFormField implements FormField { + value: Value = null; + error = false; + errorText: string | null = null; +} diff --git a/packages/forms/test/validators.test.ts b/packages/forms/test/validators.test.ts new file mode 100644 index 0000000000..d10468e118 --- /dev/null +++ b/packages/forms/test/validators.test.ts @@ -0,0 +1,51 @@ +import { expect } from '@open-wc/testing'; + +import { Validators } from '../src/index.js'; + +describe('Validators', () => { + it('should validate required', () => { + const errorMessage = 'Value is required'; + const requiredValidator = Validators.required(errorMessage); + + // Valid values + expect(requiredValidator(0, {})).to.be.null; + expect(requiredValidator(-17, {})).to.be.null; + expect(requiredValidator(10e23, {})).to.be.null; + expect(requiredValidator('Abc', {})).to.be.null; + expect(requiredValidator(' ', {})).to.be.null; + expect(requiredValidator(true, {})).to.be.null; + + // Invalid values + expect(requiredValidator(null, {})).to.equal(errorMessage); + expect(requiredValidator('', {})).to.equal(errorMessage); + expect(requiredValidator(NaN, {})).to.equal(errorMessage); + expect(requiredValidator(false, {})).to.equal(errorMessage); + }); + + it('should validate requiredIf', () => { + const requireCondition = { required: true }; + const errorMessage = 'Value is required'; + const requiredValidator = Validators.requiredIf(() => requireCondition.required, errorMessage); + + // Required if is triggered + expect(requiredValidator(null, {})).to.equal(errorMessage); + expect(requiredValidator('', {})).to.equal(errorMessage); + expect(requiredValidator(NaN, {})).to.equal(errorMessage); + expect(requiredValidator(false, {})).to.equal(errorMessage); + + expect(requiredValidator(true, {})).to.be.null; + expect(requiredValidator(0, {})).to.be.null; + expect(requiredValidator('Abc', {})).to.be.null; + + // Required if is disabled + requireCondition.required = false; + + expect(requiredValidator(null, {})).to.be.null; + expect(requiredValidator('', {})).to.be.null; + expect(requiredValidator(NaN, {})).to.be.null; + expect(requiredValidator(false, {})).to.be.null; + expect(requiredValidator(true, {})).to.be.null; + expect(requiredValidator(0, {})).to.be.null; + expect(requiredValidator('Abc', {})).to.be.null; + }); +}); diff --git a/packages/forms/tsconfig.json b/packages/forms/tsconfig.json new file mode 100644 index 0000000000..41c7af8c9b --- /dev/null +++ b/packages/forms/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "esnext", + "moduleResolution": "node", + "noEmitOnError": true, + "lib": ["es2018", "dom"], + "strict": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declaration": true, + "importHelpers": true, + "outDir": "./dist", + "sourceMap": true, + "inlineSources": true, + "rootDir": "./src", + "tsBuildInfoFile": ".tsbuildinfo", + "incremental": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/forms/web-test-runner.config.mjs b/packages/forms/web-test-runner.config.mjs new file mode 100644 index 0000000000..9e4f774453 --- /dev/null +++ b/packages/forms/web-test-runner.config.mjs @@ -0,0 +1,26 @@ +// import { playwrightLauncher } from '@web/test-runner-playwright'; +import { esbuildPlugin } from '@web/dev-server-esbuild'; + +export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ + /** we run test directly on TypeScript files */ + plugins: [esbuildPlugin({ ts: true })], + + /** Resolve bare module imports */ + nodeResolve: true, + + /** filter browser logs + * Plugins have a fix URL and do not fit to the file structure in test environment. + * Creating open-scd in the tests leads to error in the browser log - we had to disable the browser log + */ + browserLogs: false, + + /** specify groups for unit and integrations tests + * hint: no --group definition runs all groups + */ + groups: [ + { + name: 'unit', + files: 'test/**/*.test.ts', + }, + ], +}); diff --git a/packages/openscd/package.json b/packages/openscd/package.json index 4b64689d4a..054ac4ab42 100644 --- a/packages/openscd/package.json +++ b/packages/openscd/package.json @@ -39,6 +39,7 @@ "@material/mwc-textarea": "0.22.1", "@material/mwc-textfield": "0.22.1", "@material/mwc-top-app-bar-fixed": "0.22.1", + "@openscd/oscd-api": "^0.1.5", "@openscd/core": "*", "@openscd/xml": "*", "ace-custom-element": "^1.6.5", diff --git a/packages/openscd/src/addons/Editor.ts b/packages/openscd/src/addons/Editor.ts index 70a07fca52..d385bbc580 100644 --- a/packages/openscd/src/addons/Editor.ts +++ b/packages/openscd/src/addons/Editor.ts @@ -1,16 +1,9 @@ import { - EditV2, EditEventV2, OpenEvent, - newEditCompletedEvent, newEditEvent, - handleEditV2, - isInsertV2, - isRemoveV2, - isSetAttributesV2, - isSetTextContentV2, - isComplexV2, - newEditEventV2 + newEditEventV2, + XMLEditor } from '@openscd/core'; import { property, @@ -31,17 +24,12 @@ import { newValidateEvent } from '@openscd/core/foundation/deprecated/validation import { OpenDocEvent } from '@openscd/core/foundation/deprecated/open-event.js'; import { - AttributeValue, Edit, EditEvent, - Insert, isComplex, isInsert, - isNamespaced, isRemove, isUpdate, - Remove, - Update, } from '@openscd/core'; import { convertEditActiontoV1 } from './editor/edit-action-to-v1-converter.js'; @@ -56,34 +44,14 @@ export class OscdEditor extends LitElement { @property({ type: String }) docName = ''; /** The UUID of the current [[`doc`]] */ @property({ type: String }) docId = ''; + /** XML Editor to apply changes to the scd */ + @property({ type: Object }) editor!: XMLEditor; @property({ type: Object, }) host!: HTMLElement; - private getLogText(edit: EditV2): { title: string, message?: string } { - if (isInsertV2(edit)) { - const name = edit.node instanceof Element ? - edit.node.tagName : - get('editing.node'); - return { title: get('editing.created', { name }) }; - } else if (isSetAttributesV2(edit) || isSetTextContentV2(edit)) { - const name = edit.element.tagName; - return { title: get('editing.updated', { name }) }; - } else if (isRemoveV2(edit)) { - const name = edit.node instanceof Element ? - edit.node.tagName : - get('editing.node'); - return { title: get('editing.deleted', { name }) }; - } else if (isComplexV2(edit)) { - const message = edit.map(e => this.getLogText(e)).map(({ title }) => title).join(', '); - return { title: get('editing.complex'), message }; - } - - return { title: '' }; - } - private onAction(event: EditorActionEvent) { const edit = convertEditActiontoV1(event.detail.action); const editV2 = convertEditV1toV2(edit); @@ -151,24 +119,9 @@ export class OscdEditor extends LitElement { } async handleEditEventV2(event: EditEventV2) { - const edit = event.detail.edit; + const { edit, title, squash } = event.detail; - const undoEdit = handleEditV2(edit); - - const shouldCreateHistoryEntry = event.detail.createHistoryEntry !== false; - - if (shouldCreateHistoryEntry) { - const { title, message } = this.getLogText(edit); - - this.dispatchEvent(newLogEvent({ - kind: 'action', - title: event.detail.title ?? title, - message, - redo: edit, - undo: undoEdit, - squash: event.detail.squash - })); - } + this.editor.commit(edit, { title, squash }); await this.updateComplete; this.dispatchEvent(newValidateEvent()); diff --git a/packages/openscd/src/addons/History.ts b/packages/openscd/src/addons/History.ts index 9efd2097cd..1f58b2c773 100644 --- a/packages/openscd/src/addons/History.ts +++ b/packages/openscd/src/addons/History.ts @@ -24,8 +24,6 @@ import { Snackbar } from '@material/mwc-snackbar'; import '../filtered-list.js'; import { - CommitDetail, - CommitEntry, InfoDetail, InfoEntry, IssueDetail, @@ -38,24 +36,15 @@ import { import { getFilterIcon, iconColors } from '../icons/icons.js'; import { Plugin } from '../plugin.js'; -import { EditV2, isComplexV2, newEditEventV2 } from '@openscd/core'; +import { XMLEditor } from '@openscd/core'; -export const historyStateEvent = 'history-state'; -export interface HistoryState { - editCount: number; - canUndo: boolean; - canRedo: boolean; -} -export type HistoryStateEvent = CustomEvent; - -function newHistoryStateEvent(state: HistoryState): HistoryStateEvent { - return new CustomEvent(historyStateEvent, { detail: state }); -} +import { getLogText } from './history/get-log-text.js'; -declare global { - interface ElementEventMap { - [historyStateEvent]: HistoryStateEvent; - } +interface HistoryItem { + title: string; + message?: string; + time: number; + isActive: boolean; } const icons = { @@ -135,39 +124,27 @@ export function newEmptyIssuesEvent( }); } -export function newUndoEvent(): CustomEvent { - return new CustomEvent('undo', { bubbles: true, composed: true }); -} - -export function newRedoEvent(): CustomEvent { - return new CustomEvent('redo', { bubbles: true, composed: true }); -} - @customElement('oscd-history') export class OscdHistory extends LitElement { /** All [[`LogEntry`]]s received so far through [[`LogEvent`]]s. */ @property({ type: Array }) log: InfoEntry[] = []; - /** All [[`CommitEntry`]]s received so far through [[`LogEvent`]]s */ - @property({ type: Array }) - history: CommitEntry[] = []; - - /** Index of the last [[`EditorAction`]] applied. */ - @property({ type: Number }) - editCount = -1; + /** XML Editor to apply changes to the scd */ + @property({ type: Object }) editor!: XMLEditor; @property() diagnoses = new Map(); - @property({ - type: Object, - }) + @property({ type: Object }) host!: HTMLElement; @state() latestIssue!: IssueDetail; + @state() + history: HistoryItem[] = []; + @query('#log') logUI!: Dialog; @query('#history') historyUI!: Dialog; @query('#diagnostic') diagnosticUI!: Dialog; @@ -176,27 +153,7 @@ export class OscdHistory extends LitElement { @query('#info') infoUI!: Snackbar; @query('#issue') issueUI!: Snackbar; - get canUndo(): boolean { - return this.editCount >= 0; - } - get canRedo(): boolean { - return this.nextAction >= 0; - } - - get previousAction(): number { - if (!this.canUndo) return -1; - return this.history - .slice(0, this.editCount) - .map(entry => (entry.kind == 'action' ? true : false)) - .lastIndexOf(true); - } - get nextAction(): number { - let index = this.history - .slice(this.editCount + 1) - .findIndex(entry => entry.kind == 'action'); - if (index >= 0) index += this.editCount + 1; - return index; - } + private unsubscribers: (() => any)[] = []; private onIssue(de: IssueEvent): void { const issues = this.diagnoses.get(de.detail.validatorId); @@ -209,108 +166,17 @@ export class OscdHistory extends LitElement { this.issueUI.show(); } - undo(): boolean { - if (!this.canUndo) return false; - - const undoEdit = (this.history[this.editCount]).undo; - this.host.dispatchEvent(newEditEventV2(undoEdit, { createHistoryEntry: false })); - this.setEditCount(this.previousAction); - - return true; - } - redo(): boolean { - if (!this.canRedo) return false; - - const redoEdit = (this.history[this.nextAction]).redo; - this.host.dispatchEvent(newEditEventV2(redoEdit, { createHistoryEntry: false })); - this.setEditCount(this.nextAction); - - return true; + undo(): void { + this.editor.undo(); } - - private onHistory(detail: CommitDetail) { - const entry: CommitEntry = { - time: new Date(), - ...detail, - }; - - if (this.nextAction !== -1) { - this.history.splice(this.nextAction); - } - - this.addHistoryEntry(entry); - this.setEditCount(this.history.length - 1); - this.requestUpdate('history', []); - } - - private addHistoryEntry(entry: CommitEntry) { - const shouldSquash = Boolean(entry.squash) && this.history.length > 0; - - if (shouldSquash) { - const previousEntry = this.history.pop() as CommitEntry; - const squashedEntry = this.squashHistoryEntries(entry, previousEntry); - this.history.push(squashedEntry); - } else { - this.history.push(entry); - } - } - - private squashHistoryEntries(current: CommitEntry, previous: CommitEntry): CommitEntry { - const undo = this.squashUndo(current.undo, previous.undo); - const redo = this.squashRedo(current.redo, previous.redo); - - return { - ...current, - undo, - redo - }; - } - - private squashUndo(current: EditV2, previous: EditV2): EditV2 { - const isCurrentComplex = isComplexV2(current); - const isPreviousComplex = isComplexV2(previous); - - const previousUndos: EditV2[] = (isPreviousComplex ? previous : [ previous ]) as EditV2[]; - const currentUndos: EditV2[] = (isCurrentComplex ? current : [ current ]) as EditV2[]; - - return [ - ...currentUndos, - ...previousUndos - ]; - } - - private squashRedo(current: EditV2, previous: EditV2): EditV2 { - const isCurrentComplex = isComplexV2(current); - const isPreviousComplex = isComplexV2(previous); - - const previousRedos: EditV2[] = (isPreviousComplex ? previous : [ previous ]) as EditV2[]; - const currentRedos: EditV2[] = (isCurrentComplex ? current : [ current ]) as EditV2[]; - - return [ - ...previousRedos, - ...currentRedos - ]; + redo(): void { + this.editor.redo(); } private onReset() { this.log = []; - this.history = []; - this.setEditCount(-1); - } - - private setEditCount(count: number): void { - this.editCount = count; - this.dispatchHistoryStateEvent(); - } - - private dispatchHistoryStateEvent(): void { - this.host.dispatchEvent( - newHistoryStateEvent({ - editCount: this.editCount, - canUndo: this.canUndo, - canRedo: this.canRedo - }) - ); + this.editor.reset(); + this.updateHistory(); } private onInfo(detail: InfoDetail) { @@ -343,7 +209,7 @@ export class OscdHistory extends LitElement { this.onReset(); break; case 'action': - this.onHistory(le.detail); + // No longer needed break; default: this.onInfo(le.detail); @@ -379,6 +245,24 @@ export class OscdHistory extends LitElement { : this.diagnosticUI.show(); } + private updateHistory(): void { + const { past, future } = this.editor; + + const activeIndex = past.length - 1; + const allEntries = [ ...past, ...future ]; + + this.history = allEntries.map((e, index) => { + const { title, message } = getLogText(e.redo as any); + + return { + isActive: index === activeIndex, + time: e.time, + title: e.title ?? title, + message + }; + }); + } + constructor() { super(); this.undo = this.undo.bind(this); @@ -388,22 +272,28 @@ export class OscdHistory extends LitElement { this.historyUIHandler = this.historyUIHandler.bind(this); this.emptyIssuesHandler = this.emptyIssuesHandler.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); - this.dispatchHistoryStateEvent = this.dispatchHistoryStateEvent.bind(this); document.onkeydown = this.handleKeyPress; } connectedCallback(): void { super.connectedCallback(); + this.unsubscribers.push( + this.editor.subscribe(e => this.updateHistory()), + this.editor.subscribeUndoRedo(e => this.updateHistory()) + ); + this.host.addEventListener('log', this.onLog); this.host.addEventListener('issue', this.onIssue); this.host.addEventListener('history-dialog-ui', this.historyUIHandler); this.host.addEventListener('empty-issues', this.emptyIssuesHandler); - this.host.addEventListener('undo', this.undo); - this.host.addEventListener('redo', this.redo); this.diagnoses.clear(); } + disconnectedCallback(): void { + this.unsubscribers.forEach(u => u()); + } + renderLogEntry( entry: InfoEntry, index: number, @@ -414,7 +304,6 @@ export class OscdHistory extends LitElement { class="${entry.kind}" graphic="icon" ?twoline=${!!entry.message} - ?activated=${this.editCount == log.length - index - 1} > @@ -434,32 +323,27 @@ export class OscdHistory extends LitElement { } renderHistoryEntry( - entry: CommitEntry, - index: number, - history: LogEntry[] + entry: HistoryItem ): TemplateResult { return html` - - ${entry.time?.toLocaleString()} - ${entry.title} + ${this.formatTime(entry.time)} + ${entry.title} + ${entry.message} - history - `; + + `; + } + + private formatTime(time: number): string { + const date = new Date(time); + const hours = date.getHours(); + const minutes = date.getMinutes(); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` } private renderLog(): TemplateResult[] | TemplateResult { @@ -474,7 +358,7 @@ export class OscdHistory extends LitElement { private renderHistory(): TemplateResult[] | TemplateResult { if (this.history.length > 0) - return this.history.slice().reverse().map(this.renderHistoryEntry, this); + return this.history.slice().reverse().map(e => this.renderHistoryEntry(e)); else return html` ${get('history.placeholder')} @@ -546,14 +430,14 @@ export class OscdHistory extends LitElement { diff --git a/packages/openscd/src/addons/Layout.ts b/packages/openscd/src/addons/Layout.ts index f3bf0afbf3..0753d37732 100644 --- a/packages/openscd/src/addons/Layout.ts +++ b/packages/openscd/src/addons/Layout.ts @@ -11,40 +11,27 @@ import { import { get } from 'lit-translate'; import { newPendingStateEvent } from '@openscd/core/foundation/deprecated/waiter.js'; import { newSettingsUIEvent } from '@openscd/core/foundation/deprecated/settings.js'; +import { XMLEditor } from '@openscd/core'; import { MenuItem, Validator, MenuPlugin, pluginIcons, - newResetPluginsEvent, - newAddExternalPluginEvent, - newSetPluginsEvent, } from '../open-scd.js'; import { - MenuPosition, Plugin, - menuPosition, - PluginKind, } from "../plugin.js" import { - HistoryState, HistoryUIKind, newEmptyIssuesEvent, - newHistoryUIEvent, - newRedoEvent, - newUndoEvent, + newHistoryUIEvent } from './History.js'; 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'; @@ -72,14 +59,15 @@ export class OscdLayout extends LitElement { /** Index of the last [[`EditorAction`]] applied. */ @property({ type: Number }) editCount = -1; + /** XML Editor to apply changes to the scd */ + @property({ type: Object }) editor!: XMLEditor; + /** The plugins to render the layout. */ @property({ type: Array }) plugins: Plugin[] = []; /** The open-scd host element */ @property({ type: Object }) host!: HTMLElement; - @property({ type: Object }) historyState!: HistoryState; - @state() validated: Promise = Promise.resolve(); @state() shouldValidate = false; @state() activeEditor: Plugin | undefined = this.calcActiveEditors()[0]; @@ -173,9 +161,9 @@ export class OscdLayout extends LitElement { name: 'undo', actionItem: true, action: (): void => { - this.dispatchEvent(newUndoEvent()); + this.editor.undo(); }, - disabled: (): boolean => !this.historyState.canUndo, + disabled: (): boolean => !this.editor.canUndo, kind: 'static', content: () => html``, }, @@ -184,9 +172,9 @@ export class OscdLayout extends LitElement { name: 'redo', actionItem: true, action: (): void => { - this.dispatchEvent(newRedoEvent()); + this.editor.redo(); }, - disabled: (): boolean => !this.historyState.canRedo, + disabled: (): boolean => !this.editor.canRedo, kind: 'static', content: () => html``, }, diff --git a/packages/openscd/src/addons/history/get-log-text.ts b/packages/openscd/src/addons/history/get-log-text.ts new file mode 100644 index 0000000000..ac13f48f5a --- /dev/null +++ b/packages/openscd/src/addons/history/get-log-text.ts @@ -0,0 +1,31 @@ +import { + EditV2, + isInsertV2, + isRemoveV2, + isSetAttributesV2, + isSetTextContentV2, + isComplexV2 +} from '@openscd/core'; +import { get } from 'lit-translate'; + +export const getLogText = (edit: EditV2): { title: string, message?: string } => { + if (isInsertV2(edit)) { + const name = edit.node instanceof Element ? + edit.node.tagName : + get('editing.node'); + return { title: get('editing.created', { name }) }; + } else if (isSetAttributesV2(edit) || isSetTextContentV2(edit)) { + const name = edit.element.tagName; + return { title: get('editing.updated', { name }) }; + } else if (isRemoveV2(edit)) { + const name = edit.node instanceof Element ? + edit.node.tagName : + get('editing.node'); + return { title: get('editing.deleted', { name }) }; + } else if (isComplexV2(edit)) { + const message = edit.map(e => getLogText(e)).map(({ title }) => title).join(', '); + return { title: get('editing.complex'), message }; + } + + return { title: '' }; +} diff --git a/packages/openscd/src/open-scd.ts b/packages/openscd/src/open-scd.ts index 56318c1b02..4a1c544b77 100644 --- a/packages/openscd/src/open-scd.ts +++ b/packages/openscd/src/open-scd.ts @@ -45,9 +45,7 @@ import type { Plugin as CorePlugin, EditCompletedEvent, } from '@openscd/core'; -import { OscdApi } from '@openscd/core'; - -import { HistoryState, historyStateEvent } from './addons/History.js'; +import { OscdApi, XMLEditor } from '@openscd/core'; import { InstalledOfficialPlugin, MenuPosition, PluginKind, Plugin } from "./plugin.js" import { ConfigurePluginEvent, ConfigurePluginDetail, newConfigurePluginEvent } from './plugin.events.js'; @@ -66,13 +64,17 @@ export class OpenSCD extends LitElement { return html` - + @@ -101,12 +103,7 @@ export class OpenSCD extends LitElement { /** The UUID of the current [[`doc`]] */ @property({ type: String }) docId = ''; - @state() - historyState: HistoryState = { - editCount: -1, - canRedo: false, - canUndo: false, - } + editor = new XMLEditor(); /** Object containing all *.nsdoc files and a function extracting element's label form them*/ @property({ attribute: false }) @@ -126,6 +123,10 @@ export class OpenSCD extends LitElement { @state() private storedPlugins: Plugin[] = []; + @state() private editCount = -1; + + private unsubscribers: (() => any)[] = []; + /** Loads and parses an `XMLDocument` after [[`src`]] has changed. */ private async loadDoc(src: string): Promise { const response = await fetch(src); @@ -185,14 +186,19 @@ export class OpenSCD extends LitElement { connectedCallback(): void { super.connectedCallback(); - this.loadPlugins() + this.loadPlugins(); + + this.unsubscribers.push( + this.editor.subscribe(e => this.editCount++), + this.editor.subscribeUndoRedo(e => this.editCount++) + ); // 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(); - }); + } + + disconnectedCallback(): void { + this.unsubscribers.forEach(u => u()); } @@ -426,7 +432,7 @@ export class OpenSCD extends LitElement { return staticTagHtml`<${tag} .doc=${this.doc} .docName=${this.docName} - .editCount=${this.historyState.editCount} + .editCount=${this.editCount} .plugins=${this.storedPlugins} .docId=${this.docId} .pluginId=${plugin.src} @@ -434,6 +440,7 @@ export class OpenSCD extends LitElement { .docs=${this.docs} .locale=${this.locale} .oscdApi=${new OscdApi(tag)} + .editor=${this.editor} class="${classMap({ plugin: true, menu: plugin.kind === 'menu', diff --git a/packages/openscd/test/integration/Setting.test.ts b/packages/openscd/test/integration/Setting.test.ts index e5e7483e24..c2f4829dab 100644 --- a/packages/openscd/test/integration/Setting.test.ts +++ b/packages/openscd/test/integration/Setting.test.ts @@ -5,16 +5,19 @@ import '../../src/addons/History.js'; import '../../src/addons/Settings.js'; import { OscdHistory } from '../../src/addons/History.js'; import { OscdSettings } from '../../src/addons/Settings.js'; +import { XMLEditor } from '@openscd/core'; describe('Oscd-Settings', () => { let logger: OscdHistory; let settings: OscdSettings; + let editor: XMLEditor; beforeEach(async () => { localStorage.clear(); + editor = new XMLEditor(); logger = await fixture( - html` + html` ` ); diff --git a/packages/openscd/test/mock-editor-logger.ts b/packages/openscd/test/mock-editor-logger.ts index 2894e6206d..802fdc147e 100644 --- a/packages/openscd/test/mock-editor-logger.ts +++ b/packages/openscd/test/mock-editor-logger.ts @@ -12,6 +12,7 @@ import '../src/addons/Editor.js'; import '../src/addons/History.js'; import { OscdEditor } from '../src/addons/Editor.js'; import { OscdHistory } from '../src/addons/History.js'; +import { XMLEditor } from '@openscd/core'; @customElement('mock-editor-logger') export class MockEditorLogger extends LitElement { @@ -30,14 +31,18 @@ export class MockEditorLogger extends LitElement { @query('oscd-editor') editor!: OscdEditor; + @state() + xmlEditor = new XMLEditor(); + render(): TemplateResult { - return html` + return html` `; diff --git a/packages/openscd/test/mock-wizard-editor.ts b/packages/openscd/test/mock-wizard-editor.ts index a1962b0846..3ce08afe54 100644 --- a/packages/openscd/test/mock-wizard-editor.ts +++ b/packages/openscd/test/mock-wizard-editor.ts @@ -12,6 +12,7 @@ import '../src/addons/Wizards.js'; import '../src/addons/Editor.js'; import { OscdWizards } from '../src/addons/Wizards.js'; +import { XMLEditor } from '@openscd/core'; @customElement('mock-wizard-editor') export class MockWizardEditor extends LitElement { @@ -20,6 +21,8 @@ export class MockWizardEditor extends LitElement { @query('oscd-wizards') wizards!: OscdWizards; + editor = new XMLEditor(); + render(): TemplateResult { return html` diff --git a/packages/openscd/test/unit/Editor.test.ts b/packages/openscd/test/unit/Editor.test.ts index 0b1cbd6d18..b118281714 100644 --- a/packages/openscd/test/unit/Editor.test.ts +++ b/packages/openscd/test/unit/Editor.test.ts @@ -11,7 +11,8 @@ import { Update, SetAttributesV2, SetTextContentV2, - RemoveV2 + RemoveV2, + XMLEditor } from '@openscd/core'; import { CommitDetail, LogDetail } from '@openscd/core/foundation/deprecated/history.js'; @@ -20,6 +21,7 @@ describe('OSCD-Editor', () => { let element: OscdEditor; let host: HTMLElement; let scd: XMLDocument; + let editor: XMLEditor; let voltageLevel1: Element; let voltageLevel2: Element; @@ -57,8 +59,9 @@ describe('OSCD-Editor', () => { ); host = document.createElement('div'); + editor = new XMLEditor(); - element = await fixture(html``, { parentNode: host }); + element = await fixture(html``, { parentNode: host }); voltageLevel1 = scd.querySelector('VoltageLevel[name="v1"]')!; voltageLevel2 = scd.querySelector('VoltageLevel[name="v2"]')!; @@ -336,20 +339,6 @@ describe('OSCD-Editor', () => { }); }); - it('should log edit by default', () => { - const remove: RemoveV2 = { - node: bay2, - }; - - host.dispatchEvent(newEditEventV2(remove)); - - expect(log).to.have.lengthOf(1); - const logEntry = log[0] as CommitDetail; - expect(logEntry.kind).to.equal('action'); - expect(logEntry.title).to.equal('[editing.deleted]'); - expect(logEntry.redo).to.deep.equal(remove); - }); - describe('validate after edit', () => { let hasTriggeredValidate = false; beforeEach(() => { @@ -398,9 +387,7 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2(insert)); - const undoInsert = log[0].undo as RemoveV2; - - host.dispatchEvent(newEditEventV2(undoInsert)); + editor.undo(); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; }); @@ -412,9 +399,7 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2(remove)); - const undoRemove = log[0].undo as InsertV2; - - host.dispatchEvent(newEditEventV2(undoRemove)); + editor.undo(); const bay4FromScd = scd.querySelector('VoltageLevel[name="v2"] > Bay[name="b4"]'); expect(bay4FromScd).to.deep.equal(bay4); @@ -432,9 +417,7 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2(update)); - const undoUpdate = log[0].undo as SetAttributesV2; - - host.dispatchEvent(newEditEventV2(undoUpdate)); + editor.undo(); expect(bay1.getAttribute('desc')).to.be.null; expect(bay1.getAttribute('kind')).to.equal('bay'); @@ -448,9 +431,7 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2(update)); - const undoUpdate = log[0].undo as SetTextContentV2; - - host.dispatchEvent(newEditEventV2(undoUpdate)); + editor.undo(); expect(bayWithoutTextContent.textContent).to.be.empty; }); @@ -465,9 +446,7 @@ describe('OSCD-Editor', () => { expect(bay2.children).to.be.empty; - const undoUpdate = log[0].undo as SetTextContentV2; - - host.dispatchEvent(newEditEventV2(undoUpdate)); + editor.undo(); expect(bay2.children[0]).to.deep.equal(lnode2); }); @@ -484,14 +463,11 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2(insert)); - const undoIsert = log[0].undo; - const redoInsert = log[0].redo; - - host.dispatchEvent(newEditEventV2(undoIsert)); + editor.undo(); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.be.null; - host.dispatchEvent(newEditEventV2(redoInsert)); + editor.redo(); expect(scd.querySelector('VoltageLevel[name="v1"] > Bay[name="b3"]')).to.deep.equal(newNode); }); @@ -520,16 +496,13 @@ describe('OSCD-Editor', () => { host.dispatchEvent(newEditEventV2([insert, remove, update])); - const undoComplex = log[0].undo; - const redoComplex = log[0].redo; - - host.dispatchEvent(newEditEventV2(undoComplex)); + editor.undo(); 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(newEditEventV2(redoComplex)); + editor.redo(); 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/Historing.test.ts b/packages/openscd/test/unit/Historing.test.ts index c93869afba..722c207dcc 100644 --- a/packages/openscd/test/unit/Historing.test.ts +++ b/packages/openscd/test/unit/Historing.test.ts @@ -10,47 +10,31 @@ import { newLogEvent, } from '@openscd/core/foundation/deprecated/history.js'; import { OscdHistory } from '../../src/addons/History.js'; +import { InsertV2 } from '@openscd/core'; +import { createElement } from '@openscd/xml'; describe('HistoringElement', () => { let mock: MockOpenSCD; let element: OscdHistory; - beforeEach(async () => { - mock = await fixture(html``); - element = mock.historyAddon; - }); + let scd: XMLDocument; - it('starts out with an empty log', () => - expect(element).property('log').to.be.empty); - - it('cannot undo', () => expect(element).property('canUndo').to.be.false); - it('cannot redo', () => expect(element).property('canRedo').to.be.false); - - it('cannot undo info messages', () => { - element.dispatchEvent(newLogEvent({ kind: 'info', title: 'test info' })); - expect(element).property('log').to.have.lengthOf(1); - expect(element).property('canUndo').to.be.false; - }); - - it('cannot undo warning messages', () => { - element.dispatchEvent( - newLogEvent({ kind: 'warning', title: 'test warning' }) + beforeEach(async () => { + scd = new DOMParser().parseFromString( + ` + + + + + + `, + 'application/xml', ); - expect(element).property('log').to.have.lengthOf(1); - expect(element).property('canUndo').to.be.false; - }); - it('cannot undo error messages', () => { - element.dispatchEvent(newLogEvent({ kind: 'error', title: 'test error' })); - expect(element).property('log').to.have.lengthOf(1); - expect(element).property('canUndo').to.be.false; + mock = await fixture(html``); + element = mock.historyAddon; }); - it('has no previous action', () => - expect(element).to.have.property('previousAction', -1)); - it('has no edit count', () => - expect(element).to.have.property('editCount', -1)); - it('has no next action', () => - expect(element).to.have.property('nextAction', -1)); + it('starts out with an empty log', () => expect(element).property('log').to.be.empty); it('renders a placeholder message', () => expect(element.logUI).to.contain('mwc-list-item[disabled]')); @@ -102,121 +86,63 @@ describe('HistoringElement', () => { }); describe('with an action logged', () => { + const insertTitle = 'Insert bay 2'; + let voltageLevel: Element; + beforeEach(async () => { - element.dispatchEvent( - newLogEvent({ - kind: 'action', - title: 'test MockAction', - redo: mockEdits.insert(), - undo: mockEdits.insert() - }) - ); + voltageLevel = scd.querySelector('VoltageLevel')!; + const bay2 = createElement(scd, 'Bay', { name: 'b2' }); + const insert: InsertV2 = { + parent: voltageLevel, + node: bay2, + reference: null + }; + element.editor.commit(insert, { title: insertTitle }); + element.requestUpdate(); await element.updateComplete; mock.requestUpdate(); await mock.updateComplete; }); - it('can undo', () => expect(element).property('canUndo').to.be.true); - it('cannot redo', () => expect(element).property('canRedo').to.be.false); - - it('has no previous action', () => - expect(element).to.have.property('previousAction', -1)); - it('has an edit count', () => - expect(element).to.have.property('editCount', 0)); - it('has no next action', () => - expect(element).to.have.property('nextAction', -1)); - - it('can reset its log', () => { - element.dispatchEvent(newLogEvent({ kind: 'reset' })); - expect(element).property('log').to.be.empty; - expect(element).property('history').to.be.empty; - expect(element).to.have.property('editCount', -1); + it('should have a history', () => { + expect(element.history.length).to.equal(1); + const insertEntry = element.history[0]; + expect(insertEntry.title).to.equal(insertTitle); + expect(insertEntry.isActive).to.true; }); - it('renders a history message for the action', () => - expect(element.historyUI).to.contain.text('test')); + it('should keep undone entries in history and set is active accordingly', () => { + const bay3 = createElement(scd, 'Bay', { name: 'b3' }); + const insert: InsertV2 = { + parent: voltageLevel, + node: bay3, + reference: null + }; - describe('with a second action logged', () => { - beforeEach(() => { - element.dispatchEvent( - newLogEvent({ - kind: 'info', - title: 'test info', - }) - ); - element.dispatchEvent( - newLogEvent({ - kind: 'action', - title: 'test MockAction', - redo: mockEdits.remove(), - undo: mockEdits.remove() - }) - ); - }); + element.editor.commit(insert); - it('has a previous action', () => - expect(element).to.have.property('previousAction', 0)); - it('has an edit count', () => - expect(element).to.have.property('editCount', 1)); - it('has no next action', () => - expect(element).to.have.property('nextAction', -1)); + let [ bay2Insert, bay3Insert ] = element.history; + expect(bay2Insert.isActive).to.be.false; + expect(bay3Insert.isActive).to.be.true; - describe('with an action undone', () => { - beforeEach(() => element.undo()); + element.editor.undo(); - it('has no previous action', () => - expect(element).to.have.property('previousAction', -1)); - it('has an edit count', () => - expect(element).to.have.property('editCount', 0)); - it('has a next action', () => - expect(element).to.have.property('nextAction', 1)); + [ bay2Insert, bay3Insert ] = element.history; + expect(bay2Insert.isActive).to.be.true; + expect(bay3Insert.isActive).to.be.false; - it('can redo', () => expect(element).property('canRedo').to.be.true); + element.editor.redo(); - it('removes the undone action when a new action is logged', async () => { - element.dispatchEvent( - newLogEvent({ - kind: 'action', - title: 'test MockAction', - redo: mockEdits.insert(), - undo: mockEdits.insert() - }) - ); - await element.updateComplete; - expect(element).property('log').to.have.lengthOf(1); - expect(element).property('history').to.have.lengthOf(2); - expect(element).to.have.property('editCount', 1); - expect(element).to.have.property('nextAction', -1); - }); - - describe('with the second action undone', () => { - beforeEach(async () => { - element.undo(); - await element.updateComplete; - await mock.updateComplete; - }); - - it('cannot undo any funther', () => { - console.log('error'); - expect(element.undo()).to.be.false; - }); - }); - - describe('with the action redone', () => { - beforeEach(() => element.redo()); - - it('has a previous action', () => - expect(element).to.have.property('previousAction', 0)); - it('has an edit count', () => - expect(element).to.have.property('editCount', 1)); - it('has no next action', () => - expect(element).to.have.property('nextAction', -1)); + [ bay2Insert, bay3Insert ] = element.history; + expect(bay2Insert.isActive).to.be.false; + expect(bay3Insert.isActive).to.be.true; + }); - it('cannot redo any further', () => - expect(element.redo()).to.be.false); - }); - }); + it('can reset its log', () => { + element.dispatchEvent(newLogEvent({ kind: 'reset' })); + expect(element).property('log').to.be.empty; + expect(element).property('history').to.be.empty; }); });