Compare commits
	
		
			64 Commits
		
	
	
		
			include-e2
			...
			notebook-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ade4d6f77a | ||
|   | 80d4c10fea | ||
|   | aa6d509fde | ||
|   | a1c6168c91 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a706a8b73e | ||
|   | 1ddf5e5137 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a79646a915 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d5266e7ac7 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 05de7ee2e0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | dad88112c4 | ||
|   | 202d6d8c5d | ||
|   | e70bcc414c | ||
|   | 7bb4a136d7 | ||
|   | 8af3b4309f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bed3d83fd7 | ||
|   | efda42cf6d | ||
|   | e8ee5b3fc9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 393cb9767f | ||
|   | 8b5daad65c | ||
|   | fabfecdb3e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a2d8b13204 | ||
|   | 4b14d2d6d2 | ||
|   | d545124942 | ||
|   | 6abdbfdff0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 500e655476 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5e1f026db2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d9efae98c8 | ||
|   | 091f6406a8 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 42a0e503cc | ||
|   | 4697352f60 | ||
|   | 015c764ab3 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8fe465d9fc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9c1368885a | ||
|   | 391c0b2e7c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2ae061dbcd | ||
|   | 41fc502564 | ||
|   | b4554d2fc1 | ||
|   | feba5f6d3b | ||
|   | 4357d35f4a | ||
|   | 5041f80e5b | ||
|   | 9e23f79bc8 | ||
|   | bd1e869f6a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e4a36532e7 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2bc2316613 | ||
|   | 2fa36b2176 | ||
|   | efa38d779e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 951cc6ec0d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ef4b8a9934 | ||
|   | c14b48917e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 26165d0a99 | ||
|   | f7cf3f72c2 | ||
|   | cb8e09c9f9 | ||
|   | 026eb86f5f | ||
|   | 866859a937 | ||
|   | afc54f41f6 | ||
|   | 72c980f991 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9bf39a9cd4 | ||
|   | 33fd95cb2b | ||
|   | 8c92178895 | ||
|   | 35bbebbbc7 | ||
|   | ce463babff | ||
|   | 27c30132d2 | ||
|   | 2bdac56505 | ||
|   | 35c42ba43d | 
							
								
								
									
										4
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -21,9 +21,9 @@ Closes <!--- Insert Issue Number(s) this PR addresses. Start by typing # will op | ||||
| ### Reviewer Checklist | ||||
|  | ||||
| * [ ] Changes appear to address issue? | ||||
| * [ ] Reviewer has tested changes by following the provided instructions? | ||||
| * [ ] Changes appear not to be breaking changes? | ||||
| * [ ] Appropriate unit tests included? | ||||
| * [ ] Appropriate automated tests included? | ||||
| * [ ] Code style and in-line documentation are appropriate? | ||||
| * [ ] Commit messages meet standards? | ||||
| * [ ] Has associated issue been labelled unverified? (only applicable if this PR closes the issue) | ||||
| * [ ] Has associated issue been labelled bug? (only applicable if this PR is for a bug fix) | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/codeql/codeql-config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| name: 'Custom CodeQL config' | ||||
							
								
								
									
										12
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -13,14 +13,18 @@ updates: | ||||
|       - "pr:daveit" | ||||
|       - "pr:platform" | ||||
|     ignore: | ||||
|         #We have to source the container which is not detected by Dependabot | ||||
|       #We have to source the playwright container which is not detected by Dependabot | ||||
|       - dependency-name: "@playwright/test" | ||||
|         #Lots of noise in these type patch releases. | ||||
|       - dependency-name: "playwright-core" | ||||
|       #Lots of noise in these type patch releases. | ||||
|       - dependency-name: "@babel/eslint-parser" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|         update-types: ["version-update:semver-patch"]  | ||||
|       - dependency-name: "eslint-plugin-vue" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|  | ||||
|       - dependency-name: "babel-loader" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|       - dependency-name: "sinon" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|   - package-ecosystem: "github-actions" | ||||
|     directory: "/" | ||||
|     schedule: | ||||
|   | ||||
							
								
								
									
										31
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,11 +1,10 @@ | ||||
|  | ||||
| name: "CodeQL" | ||||
| name: 'CodeQL' | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [ master ] | ||||
|     branches: [master, 'release/*'] | ||||
|   pull_request: | ||||
|     branches: [ master ] | ||||
|     branches: [master, 'release/*'] | ||||
|     paths-ignore: | ||||
|       - '**/*Spec.js' | ||||
|       - '**/*.md' | ||||
| @@ -27,17 +26,19 @@ jobs: | ||||
|       security-events: write | ||||
|  | ||||
|     steps: | ||||
|     - name: Checkout repository | ||||
|       uses: actions/checkout@v3 | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v3 | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v2 | ||||
|       with: | ||||
|         languages: javascript | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v2 | ||||
|         with: | ||||
|           config-file: ./.github/codeql/codeql-config.yml | ||||
|           languages: javascript | ||||
|           queries: security-and-quality | ||||
|  | ||||
|     - name: Autobuild | ||||
|       uses: github/codeql-action/autobuild@v2 | ||||
|       - name: Autobuild | ||||
|         uses: github/codeql-action/autobuild@v2 | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v2 | ||||
|       - name: Perform CodeQL Analysis | ||||
|         uses: github/codeql-action/analyze@v2 | ||||
|   | ||||
							
								
								
									
										98
									
								
								.github/workflows/lighthouse.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,98 +0,0 @@ | ||||
| name: lighthouse | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|     inputs:  | ||||
|       version: | ||||
|         description: 'Which branch do you want to test?' # Limited to branch for now | ||||
|         required: false | ||||
|         default: 'master' | ||||
|   pull_request: | ||||
|     types:  | ||||
|       - labeled | ||||
| jobs: | ||||
|   lighthouse-pr: | ||||
|     if: ${{ github.event.label.name == 'pr:lighthouse' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout Master for Baseline | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: master #explicitly checkout master for baseline | ||||
|       - name: Install Node 16 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v2 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline and ignore exit codes | ||||
|         run: lhci autorun || true | ||||
|       - name: Perform clean checkout of PR | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           clean: true | ||||
|       - name: Install Node version which is compatible with PR | ||||
|         uses: actions/setup-node@v3 | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci with PR | ||||
|         run: lhci autorun | ||||
|         env: | ||||
|           LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | ||||
|   lighthouse-nightly: | ||||
|     if: ${{ github.event.schedule }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Install Node 16 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v2 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline | ||||
|         run: lhci autorun | ||||
|         env: | ||||
|           LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | ||||
|   lighthouse-dispatch: | ||||
|     if: ${{ github.event.workflow_dispatch }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: ${{ github.event.inputs.version }} | ||||
|       - name: Install Node 14 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v3 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline | ||||
|         run: lhci autorun | ||||
|          | ||||
							
								
								
									
										8
									
								
								.github/workflows/npm-prerelease.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -16,7 +16,11 @@ jobs: | ||||
|         with: | ||||
|           node-version: 16 | ||||
|       - run: npm install | ||||
|       - run: npm test | ||||
|       - run: | | ||||
|           echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc | ||||
|           npm whoami | ||||
|           npm publish --access=public --tag unstable openmct | ||||
|       # - run: npm test | ||||
|  | ||||
|   publish-npm-prerelease: | ||||
|     needs: build | ||||
| @@ -28,6 +32,6 @@ jobs: | ||||
|           node-version: 16 | ||||
|           registry-url: https://registry.npmjs.org/ | ||||
|       - run: npm install | ||||
|       - run: npm publish --access public --tag unstable | ||||
|       - run: npm publish --access=public --tag unstable | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||
|   | ||||
							
								
								
									
										11
									
								
								.npmignore
									
									
									
									
									
								
							
							
						
						| @@ -10,9 +10,6 @@ | ||||
| # https://github.com/nasa/openmct/issues/4992 | ||||
| !/example/**/* | ||||
|  | ||||
| # We will remove this in https://github.com/nasa/openmct/issues/4922 | ||||
| !/app.js | ||||
|  | ||||
| # ...except for these files in the above folders. | ||||
| /src/**/*Spec.js | ||||
| /src/**/test/ | ||||
| @@ -24,4 +21,10 @@ | ||||
| !copyright-notice.html | ||||
| !index.html | ||||
| !openmct.js | ||||
| !SECURITY.md | ||||
| !SECURITY.md | ||||
|  | ||||
| # Add e2e tests to npm package | ||||
| !/e2e/**/* | ||||
|  | ||||
| # ... except our test-data folder files. | ||||
| /e2e/test-data/*.json | ||||
|   | ||||
							
								
								
									
										12
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -30,6 +30,8 @@ Building and running Open MCT in your local dev environment is very easy. Be sur | ||||
|  | ||||
| Open MCT is now running, and can be accessed by pointing a web browser at [http://localhost:8080/](http://localhost:8080/) | ||||
|  | ||||
| Open MCT is built using [`npm`](http://npmjs.com/) and [`webpack`](https://webpack.js.org/). | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
| Documentation is available on the [Open MCT website](https://nasa.github.io/openmct/documentation/). | ||||
| @@ -43,11 +45,9 @@ our documentation. | ||||
| We want Open MCT to be as easy to use, install, run, and develop for as | ||||
| possible, and your feedback will help us get there! Feedback can be provided via [GitHub issues](https://github.com/nasa/openmct/issues/new/choose), [Starting a GitHub Discussion](https://github.com/nasa/openmct/discussions), or by emailing us at [arc-dl-openmct@mail.nasa.gov](mailto:arc-dl-openmct@mail.nasa.gov). | ||||
|  | ||||
| ## Building Applications With Open MCT | ||||
| ## Developing Applications With Open MCT | ||||
|  | ||||
| Open MCT is built using [`npm`](http://npmjs.com/) and [`webpack`](https://webpack.js.org/). | ||||
|  | ||||
| See our documentation for a guide on [building Applications with Open MCT](https://github.com/nasa/openmct/blob/master/API.md#starting-an-open-mct-application). | ||||
| For more on developing with Open MCT, see our documentation for a guide on [Developing Applications with Open MCT](./API.md#starting-an-open-mct-application). | ||||
|  | ||||
| ## Compatibility | ||||
|  | ||||
| @@ -64,7 +64,7 @@ that is intended to be added or removed as a single unit. | ||||
| As well as providing an extension mechanism, most of the core Open MCT codebase is also  | ||||
| written as plugins. | ||||
|  | ||||
| For information on writing plugins, please see [our API documentation](https://github.com/nasa/openmct/blob/master/API.md#plugins). | ||||
| For information on writing plugins, please see [our API documentation](./API.md#plugins). | ||||
|  | ||||
| ## Tests | ||||
|  | ||||
| @@ -100,7 +100,7 @@ To run the performance tests: | ||||
| The test suite is configured to all tests localed in `e2e/tests/` ending in `*.e2e.spec.js`. For more about the e2e test suite, please see the [README](./e2e/README.md) | ||||
|  | ||||
| ### Security Tests | ||||
| Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/) | ||||
| Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/). The list of CWE coverage items is avaiable in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml). | ||||
|  | ||||
| ### Test Reporting and Code Coverage | ||||
|  | ||||
|   | ||||
							
								
								
									
										92
									
								
								app.js
									
									
									
									
									
								
							
							
						
						| @@ -1,92 +0,0 @@ | ||||
| /*global process*/ | ||||
|  | ||||
| /** | ||||
|  * Usage: | ||||
|  * | ||||
|  * npm install minimist express | ||||
|  * node app.js [options] | ||||
|  */ | ||||
|  | ||||
| const options = require('minimist')(process.argv.slice(2)); | ||||
| const express = require('express'); | ||||
| const app = express(); | ||||
| const fs = require('fs'); | ||||
| const request = require('request'); | ||||
| const __DEV__ = !process.env.NODE_ENV || process.env.NODE_ENV === 'development'; | ||||
|  | ||||
| // Defaults | ||||
| options.port = options.port || options.p || 8080; | ||||
| options.host = options.host || 'localhost'; | ||||
| options.directory = options.directory || options.D || '.'; | ||||
|  | ||||
| // Show command line options | ||||
| if (options.help || options.h) { | ||||
|     console.log("\nUsage: node app.js [options]\n"); | ||||
|     console.log("Options:"); | ||||
|     console.log("  --help, -h               Show this message."); | ||||
|     console.log("  --port, -p <number>      Specify port."); | ||||
|     console.log("  --directory, -D <bundle>   Serve files from specified directory."); | ||||
|     console.log(""); | ||||
|     process.exit(0); | ||||
| } | ||||
|  | ||||
| app.disable('x-powered-by'); | ||||
|  | ||||
| app.use('/proxyUrl', function proxyRequest(req, res, next) { | ||||
|     console.log('Proxying request to: ', req.query.url); | ||||
|     req.pipe(request({ | ||||
|         url: req.query.url, | ||||
|         strictSSL: false | ||||
|     }).on('error', next)).pipe(res); | ||||
| }); | ||||
|  | ||||
| class WatchRunPlugin { | ||||
|     apply(compiler) { | ||||
|         compiler.hooks.emit.tapAsync('WatchRunPlugin', (compilation, callback) => { | ||||
|             console.log('Begin compile at ' + new Date()); | ||||
|             callback(); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| const webpack = require('webpack'); | ||||
| let webpackConfig; | ||||
| if (__DEV__) { | ||||
|     webpackConfig = require('./webpack.dev'); | ||||
|     webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); | ||||
|     webpackConfig.entry.openmct = [ | ||||
|         'webpack-hot-middleware/client?reload=true', | ||||
|         webpackConfig.entry.openmct | ||||
|     ]; | ||||
|     webpackConfig.plugins.push(new WatchRunPlugin()); | ||||
| } else { | ||||
|     webpackConfig = require('./webpack.coverage'); | ||||
| } | ||||
|  | ||||
| const compiler = webpack(webpackConfig); | ||||
|  | ||||
| app.use(require('webpack-dev-middleware')( | ||||
|     compiler, | ||||
|     { | ||||
|         publicPath: '/dist', | ||||
|         stats: 'errors-warnings' | ||||
|     } | ||||
| )); | ||||
|  | ||||
| if (__DEV__) { | ||||
|     app.use(require('webpack-hot-middleware')( | ||||
|         compiler, | ||||
|         {} | ||||
|     )); | ||||
| } | ||||
|  | ||||
| // Expose index.html for development users. | ||||
| app.get('/', function (req, res) { | ||||
|     fs.createReadStream('index.html').pipe(res); | ||||
| }); | ||||
|  | ||||
| // Finally, open the HTTP server and log the instance to the console | ||||
| app.listen(options.port, options.host, function () { | ||||
|     console.log('Open MCT application running at %s:%s', options.host, options.port); | ||||
| }); | ||||
|  | ||||
| @@ -1,3 +0,0 @@ | ||||
|         <hr> | ||||
|     </body> | ||||
| </html> | ||||
							
								
								
									
										209
									
								
								docs/gendocs.js
									
									
									
									
									
								
							
							
						
						| @@ -1,209 +0,0 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /*global require,process,__dirname,GLOBAL*/ | ||||
| /*jslint nomen: false */ | ||||
|  | ||||
|  | ||||
| // Usage: | ||||
| //   node gendocs.js --in <source directory> --out <dest directory> | ||||
|  | ||||
| var CONSTANTS = { | ||||
|         DIAGRAM_WIDTH: 800, | ||||
|         DIAGRAM_HEIGHT: 500 | ||||
|     }, | ||||
|     TOC_HEAD = "# Table of Contents"; | ||||
|  | ||||
| GLOBAL.window = GLOBAL.window ||  GLOBAL; // nomnoml expects window to be defined | ||||
| (function () { | ||||
|     "use strict"; | ||||
|  | ||||
|     var fs = require("fs"), | ||||
|         mkdirp = require("mkdirp"), | ||||
|         path = require("path"), | ||||
|         glob = require("glob"), | ||||
|         marked = require("marked"), | ||||
|         split = require("split"), | ||||
|         stream = require("stream"), | ||||
|         nomnoml = require('nomnoml'), | ||||
|         toc = require("markdown-toc"), | ||||
|         Canvas = require('canvas'), | ||||
|         header = fs.readFileSync(path.resolve(__dirname, 'header.html')), | ||||
|         footer = fs.readFileSync(path.resolve(__dirname, 'footer.html')), | ||||
|         options = require("minimist")(process.argv.slice(2)); | ||||
|  | ||||
|     // Convert from nomnoml source to a target PNG file. | ||||
|     function renderNomnoml(source, target) { | ||||
|         var canvas = | ||||
|             new Canvas(CONSTANTS.DIAGRAM_WIDTH, CONSTANTS.DIAGRAM_HEIGHT); | ||||
|         nomnoml.draw(canvas, source, 1.0); | ||||
|         canvas.pngStream().pipe(fs.createWriteStream(target)); | ||||
|     } | ||||
|  | ||||
|     // Stream transform. | ||||
|     // Pulls out nomnoml diagrams from fenced code blocks and renders them | ||||
|     // as PNG files in the output directory, prefixed with a provided name. | ||||
|     // The fenced code blocks will be replaced with Markdown in the | ||||
|     // output of this stream. | ||||
|     function nomnomlifier(outputDirectory, prefix) { | ||||
|         var transform = new stream.Transform({ objectMode: true }), | ||||
|             isBuilding = false, | ||||
|             counter = 1, | ||||
|             outputPath, | ||||
|             source = ""; | ||||
|  | ||||
|         transform._transform = function (chunk, encoding, done) { | ||||
|             if (!isBuilding) { | ||||
|                 if (chunk.trim().indexOf("```nomnoml") === 0) { | ||||
|                     var outputFilename = prefix + '-' + counter + '.png'; | ||||
|                     outputPath = path.join(outputDirectory, outputFilename); | ||||
|                     this.push([ | ||||
|                         "\n\n\n" | ||||
|                     ].join("")); | ||||
|                     isBuilding = true; | ||||
|                     source = ""; | ||||
|                     counter += 1; | ||||
|                 } else { | ||||
|                     // Otherwise, pass through | ||||
|                     this.push(chunk + '\n'); | ||||
|                 } | ||||
|             } else { | ||||
|                 if (chunk.trim() === "```") { | ||||
|                     // End nomnoml | ||||
|                     renderNomnoml(source, outputPath); | ||||
|                     isBuilding = false; | ||||
|                 } else { | ||||
|                     source += chunk + '\n'; | ||||
|                 } | ||||
|             } | ||||
|             done(); | ||||
|         }; | ||||
|  | ||||
|         return transform; | ||||
|     } | ||||
|  | ||||
|     // Convert from Github-flavored Markdown to HTML | ||||
|     function gfmifier(renderTOC) { | ||||
|         var transform = new stream.Transform({ objectMode: true }), | ||||
|             markdown = ""; | ||||
|         transform._transform = function (chunk, encoding, done) { | ||||
|             markdown += chunk; | ||||
|             done(); | ||||
|         }; | ||||
|         transform._flush = function (done) { | ||||
|             if (renderTOC){ | ||||
|                 // Prepend table of contents | ||||
|                 markdown = | ||||
|                     [ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n"); | ||||
|             } | ||||
|             this.push(header); | ||||
|             this.push(marked(markdown)); | ||||
|             this.push(footer); | ||||
|             done(); | ||||
|         }; | ||||
|         return transform; | ||||
|     } | ||||
|  | ||||
|     // Custom renderer for marked; converts relative links from md to html, | ||||
|     // and makes headings linkable. | ||||
|     function CustomRenderer() { | ||||
|         var renderer = new marked.Renderer(), | ||||
|             customRenderer = Object.create(renderer); | ||||
|         customRenderer.heading = function (text, level) { | ||||
|             var escapedText = (text || "").trim().toLowerCase().replace(/\W/g, "-"), | ||||
|                 aOpen = "<a name=\"" + escapedText + "\" href=\"#" + escapedText + "\">", | ||||
|                 aClose = "</a>"; | ||||
|             return aOpen + renderer.heading.apply(renderer, arguments) + aClose; | ||||
|         }; | ||||
|         // Change links to .md files to .html | ||||
|         customRenderer.link = function (href, title, text) { | ||||
|             // ...but only if they look like relative paths | ||||
|             return (href || "").indexOf(":") === -1 && href[0] !== "/" ? | ||||
|                     renderer.link(href.replace(/\.md/, ".html"), title, text) : | ||||
|                     renderer.link.apply(renderer, arguments); | ||||
|         }; | ||||
|         return customRenderer; | ||||
|     } | ||||
|  | ||||
|     options['in'] = options['in'] || options.i; | ||||
|     options.out = options.out || options.o; | ||||
|  | ||||
|     marked.setOptions({ | ||||
|         renderer: new CustomRenderer(), | ||||
|         gfm: true, | ||||
|         tables: true, | ||||
|         breaks: false, | ||||
|         pedantic: false, | ||||
|         sanitize: true, | ||||
|         smartLists: true, | ||||
|         smartypants: false | ||||
|     }); | ||||
|  | ||||
|     // Convert all markdown files. | ||||
|     // First, pull out nomnoml diagrams. | ||||
|     // Then, convert remaining Markdown to HTML. | ||||
|     glob(options['in'] + "/**/*.md", {}, function (err, files) { | ||||
|         files.forEach(function (file) { | ||||
|             var destination = file.replace(options['in'], options.out) | ||||
|                 .replace(/md$/, "html"), | ||||
|                 destPath = path.dirname(destination), | ||||
|                 prefix = path.basename(destination).replace(/\.html$/, ""), | ||||
|                 //Determine whether TOC should be rendered for this file based | ||||
|                 //on regex provided as command line option | ||||
|                 renderTOC = file.match(options['suppress-toc'] || "") === null; | ||||
|  | ||||
|             mkdirp(destPath, function (err) { | ||||
|                 fs.createReadStream(file, { encoding: 'utf8' }) | ||||
|                     .pipe(split()) | ||||
|                     .pipe(nomnomlifier(destPath, prefix)) | ||||
|                     .pipe(gfmifier(renderTOC)) | ||||
|                     .pipe(fs.createWriteStream(destination, { | ||||
|                         encoding: 'utf8' | ||||
|                     })); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     // Also copy over all HTML, CSS, or PNG files | ||||
|     glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) { | ||||
|         files.forEach(function (file) { | ||||
|             var destination = file.replace(options['in'], options.out), | ||||
|                 destPath = path.dirname(destination), | ||||
|                 streamOptions = {}; | ||||
|             if (file.match(/png$/)) { | ||||
|                 streamOptions.encoding = null; | ||||
|             } else { | ||||
|                 streamOptions.encoding = 'utf8'; | ||||
|             } | ||||
|  | ||||
|             mkdirp(destPath, function (err) { | ||||
|                 fs.createReadStream(file, streamOptions) | ||||
|                     .pipe(fs.createWriteStream(destination, streamOptions)); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
| }()); | ||||
| @@ -1,9 +0,0 @@ | ||||
| <html> | ||||
|     <head> | ||||
|         <link rel="stylesheet" | ||||
|               href="//nasa.github.io/openmct/static/res/css/styles.css"> | ||||
|         <link rel="stylesheet" | ||||
|               href="//nasa.github.io/openmct/static/res/css/documentation.css"> | ||||
|     </head> | ||||
|     <body> | ||||
|  | ||||
| @@ -15,8 +15,8 @@ | ||||
|  | ||||
| ## Sections | ||||
|   | ||||
|  * The [API](api/) document is generated from inline documentation  | ||||
|  using [JSDoc](http://usejsdoc.org/), and describes the JavaScript objects and | ||||
|  * The [API](api/) uses inline documentation  | ||||
|  using [TypeScript](https://www.typescriptlang.org) and some legacy [JSDoc](https://jsdoc.app/). It describes the JavaScript objects and | ||||
|  functions that make up the software platform. | ||||
|  | ||||
|  * The [Development Process](process/) document describes the | ||||
|   | ||||
| @@ -151,7 +151,7 @@ Current list of test tags: | ||||
|  | ||||
| - `@ipad` - Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no Create button). | ||||
| - `@gds` - Denotes a GDS Test Case used in the VIPER Mission. | ||||
| - `@addInit` - Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of app.js. | ||||
| - `@addInit` - Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`. | ||||
| - `@localStorage` - Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). | ||||
| - `@snapshot` - Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container. | ||||
| - `@unstable` - A new test or test which is known to be flaky. | ||||
|   | ||||
| @@ -46,6 +46,7 @@ | ||||
|  */ | ||||
|  | ||||
| const Buffer = require('buffer').Buffer; | ||||
| const genUuid = require('uuid').v4; | ||||
|  | ||||
| /** | ||||
|  * This common function creates a domain object with the default options. It is the preferred way of creating objects | ||||
| @@ -56,6 +57,10 @@ const Buffer = require('buffer').Buffer; | ||||
|  * @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object. | ||||
|  */ | ||||
| async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine' }) { | ||||
|     if (!name) { | ||||
|         name = `${type}:${genUuid()}`; | ||||
|     } | ||||
|  | ||||
|     const parentUrl = await getHashUrlToDomainObject(page, parent); | ||||
|  | ||||
|     // Navigate to the parent object. This is necessary to create the object | ||||
| @@ -70,11 +75,14 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine | ||||
|     await page.click(`li:text("${type}")`); | ||||
|  | ||||
|     // Modify the name input field of the domain object to accept 'name' | ||||
|     if (name) { | ||||
|         const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); | ||||
|         await nameInput.fill(""); | ||||
|         await nameInput.fill(name); | ||||
|     } | ||||
|     const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); | ||||
|     await nameInput.fill(""); | ||||
|     await nameInput.fill(name); | ||||
|  | ||||
|     // Fill the "Notes" section with information about the | ||||
|     // currently running test and its project. | ||||
|     const notesInput = page.locator('form[name="mctForm"] #notes-textarea'); | ||||
|     await notesInput.fill(page.testNotes); | ||||
|  | ||||
|     // Click OK button and wait for Navigate event | ||||
|     await Promise.all([ | ||||
| @@ -96,8 +104,8 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         name: name || `Unnamed ${type}`, | ||||
|         uuid: uuid, | ||||
|         name, | ||||
|         uuid, | ||||
|         url: objectUrl | ||||
|     }; | ||||
| } | ||||
| @@ -225,15 +233,14 @@ async function getHashUrlToDomainObject(page, uuid) { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Utilizes the OpenMCT API to detect if the given object has an active transaction (is in Edit mode). | ||||
|  * Utilizes the OpenMCT API to detect if the UI is in Edit mode. | ||||
|  * @private | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {string | import('../src/api/objects/ObjectAPI').Identifier} identifier | ||||
|  * @return {Promise<boolean>} true if the object has an active transaction, false otherwise | ||||
|  * @return {Promise<boolean>} true if the Open MCT is in Edit Mode | ||||
|  */ | ||||
| async function _isInEditMode(page, identifier) { | ||||
|     // eslint-disable-next-line no-return-await | ||||
|     return await page.evaluate((objectIdentifier) => window.openmct.objects.isTransactionActive(objectIdentifier), identifier); | ||||
|     return await page.evaluate(() => window.openmct.editor.isEditing()); | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -20,6 +20,8 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { createDomainObjectWithDefaults } = require('../appActions'); | ||||
|  | ||||
| const NOTEBOOK_DROP_AREA = '.c-notebook__drag-area'; | ||||
|  | ||||
| /** | ||||
| @@ -38,24 +40,17 @@ async function enterTextEntry(page, text) { | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function dragAndDropEmbed(page, myItemsFolderName) { | ||||
|     // Click button:has-text("Create") | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     // Click li:has-text("Sine Wave Generator") | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     // Click form[name="mctForm"] >> text=My Items | ||||
|     await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|     // Click text=OK | ||||
|     await page.locator('text=OK').click(); | ||||
|     // Click text=Open MCT My Items >> span >> nth=3 | ||||
|     await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); | ||||
|     // Click text=Unnamed CUSTOM_NAME | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=Unnamed CUSTOM_NAME').click() | ||||
|     ]); | ||||
|  | ||||
|     await page.dragAndDrop('text=UNNAMED SINE WAVE GENERATOR', NOTEBOOK_DROP_AREA); | ||||
| async function dragAndDropEmbed(page, notebookObject) { | ||||
|     // Create example telemetry object | ||||
|     const swg = await createDomainObjectWithDefaults(page, { | ||||
|         type: "Sine Wave Generator" | ||||
|     }); | ||||
|     // Navigate to notebook | ||||
|     await page.goto(notebookObject.url); | ||||
|     // Expand the tree to reveal the notebook | ||||
|     await page.click('button[title="Show selected item in tree"]'); | ||||
|     // Drag and drop the SWG into the notebook | ||||
|     await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA); | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line no-undef | ||||
|   | ||||
| @@ -14,7 +14,7 @@ const config = { | ||||
|     testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-perfromance.config.js | ||||
|     timeout: 60 * 1000, | ||||
|     webServer: { | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         command: 'npm run start:coverage', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: false | ||||
|   | ||||
| @@ -12,10 +12,7 @@ const config = { | ||||
|     testIgnore: '**/*.perf.spec.js', | ||||
|     timeout: 30 * 1000, | ||||
|     webServer: { | ||||
|         env: { | ||||
|             NODE_ENV: 'test' | ||||
|         }, | ||||
|         command: 'npm run start', | ||||
|         command: 'npm run start:coverage', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 120 * 1000, | ||||
|         reuseExistingServer: true | ||||
|   | ||||
| @@ -6,12 +6,12 @@ const CI = process.env.CI === 'true'; | ||||
|  | ||||
| /** @type {import('@playwright/test').PlaywrightTestConfig} */ | ||||
| const config = { | ||||
|     retries: 1, //Only for debugging purposes because trace is enabled only on first retry | ||||
|     retries: 1, //Only for debugging purposes for trace: 'on-first-retry' | ||||
|     testDir: 'tests/performance/', | ||||
|     timeout: 60 * 1000, | ||||
|     workers: 1, //Only run in serial with 1 worker | ||||
|     webServer: { | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         command: 'npm run start', //coverage not generated | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: !CI | ||||
|   | ||||
| @@ -4,13 +4,13 @@ | ||||
|  | ||||
| /** @type {import('@playwright/test').PlaywrightTestConfig<{ theme: string }>} */ | ||||
| const config = { | ||||
|     retries: 1, // visual tests should never retry due to snapshot comparison errors. Leaving as a shim | ||||
|     retries: 0, // Visual tests should never retry due to snapshot comparison errors. Leaving as a shim | ||||
|     testDir: 'tests/visual', | ||||
|     testMatch: '**/*.visual.spec.js', // only run visual tests | ||||
|     timeout: 60 * 1000, | ||||
|     workers: 1, //Lower stress on Circle CI Agent for Visual tests https://github.com/percy/cli/discussions/1067 | ||||
|     webServer: { | ||||
|         command: 'cross-env NODE_ENV=test npm run start', | ||||
|         command: 'npm run start:coverage', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: !process.env.CI | ||||
| @@ -31,7 +31,7 @@ const config = { | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             name: 'chrome-snow-theme', | ||||
|             name: 'chrome-snow-theme', //Runs the same visual tests but with snow-theme enabled | ||||
|             use: { | ||||
|                 browserName: 'chromium', | ||||
|                 theme: 'snow' | ||||
|   | ||||
| @@ -126,13 +126,21 @@ exports.test = test.extend({ | ||||
|     // This should follow in the Project's configuration. Can be set to 'snow' in playwright config.js | ||||
|     theme: [theme, { option: true }], | ||||
|     // eslint-disable-next-line no-shadow | ||||
|     page: async ({ page, theme }, use) => { | ||||
|     page: async ({ page, theme }, use, testInfo) => { | ||||
|         // eslint-disable-next-line playwright/no-conditional-in-test | ||||
|         if (theme === 'snow') { | ||||
|             //inject snow theme | ||||
|             await page.addInitScript({ path: path.join(__dirname, './helper', './useSnowTheme.js') }); | ||||
|         } | ||||
|  | ||||
|         // Attach info about the currently running test and its project. | ||||
|         // This will be used by appActions to fill in the created | ||||
|         // domain object's notes. | ||||
|         page.testNotes = [ | ||||
|             `${testInfo.titlePath.join('\n')}`, | ||||
|             `${testInfo.project.name}` | ||||
|         ].join('\n'); | ||||
|  | ||||
|         await use(page); | ||||
|     }, | ||||
|     myItemsFolderName: [myItemsFolderName, { option: true }], | ||||
| @@ -140,22 +148,5 @@ exports.test = test.extend({ | ||||
|     openmctConfig: async ({ myItemsFolderName }, use) => { | ||||
|         await use({ myItemsFolderName }); | ||||
|     } | ||||
|     // objectCreateOptions: [objectCreateOptions, {option: true}], | ||||
|     // eslint-disable-next-line no-shadow | ||||
|     // domainObject: [async ({ page, objectCreateOptions }, use) => { | ||||
|     //     // FIXME: This is a false-positive caused by a bug in the eslint-plugin-playwright rule. | ||||
|     //     // eslint-disable-next-line playwright/no-conditional-in-test | ||||
|     //     if (objectCreateOptions === null) { | ||||
|     //         await use(page); | ||||
|  | ||||
|     //         return; | ||||
|     //     } | ||||
|  | ||||
|     //     //Go to baseURL | ||||
|     //     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     //     const uuid = await getOrCreateDomainObject(page, objectCreateOptions); | ||||
|     //     await use({ uuid }); | ||||
|     // }, { auto: true }] | ||||
| }); | ||||
| exports.expect = expect; | ||||
|   | ||||
							
								
								
									
										2207
									
								
								e2e/test-data/ExampleLayouts.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -20,7 +20,7 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures.js'); | ||||
| const { test, expect } = require('../../pluginFixtures.js'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions.js'); | ||||
|  | ||||
| test.describe('AppActions', () => { | ||||
| @@ -50,11 +50,11 @@ test.describe('AppActions', () => { | ||||
|             }); | ||||
|  | ||||
|             await page.goto(timer1.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Foo'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer1.name); | ||||
|             await page.goto(timer2.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Bar'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer2.name); | ||||
|             await page.goto(timer3.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Baz'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer3.name); | ||||
|         }); | ||||
|  | ||||
|         await test.step('Create multiple nested objects in a row', async () => { | ||||
| @@ -74,11 +74,11 @@ test.describe('AppActions', () => { | ||||
|                 parent: folder2.uuid | ||||
|             }); | ||||
|             await page.goto(folder1.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Foo'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder1.name); | ||||
|             await page.goto(folder2.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Bar'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder2.name); | ||||
|             await page.goto(folder3.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Baz'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder3.name); | ||||
|  | ||||
|             expect(folder1.url).toBe(`${e2eFolder.url}/${folder1.uuid}`); | ||||
|             expect(folder2.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}`); | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
| /* | ||||
| This test suite is dedicated to testing our use of the playwright framework as it | ||||
| relates to how we've extended it (i.e. ./e2e/baseFixtures.js) and assumptions made in our dev environment | ||||
| (app.js and ./e2e/webpack-dev-middleware.js) | ||||
| (`npm start` and ./e2e/webpack-dev-middleware.js) | ||||
| */ | ||||
|  | ||||
| const { test } = require('../../baseFixtures.js'); | ||||
|   | ||||
| @@ -45,7 +45,7 @@ | ||||
| */ | ||||
|  | ||||
| // Structure: Some standard Imports. Please update the required pathing. | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { test, expect } = require('../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
|  | ||||
| /** | ||||
| @@ -144,5 +144,5 @@ async function renameTimerFrom3DotMenu(page, timerUrl, newNameForTimer) { | ||||
|     await page.locator('text=Properties Title Notes >> input[type="text"]').fill(newNameForTimer); | ||||
|  | ||||
|     // Click Ok button to Save | ||||
|     await page.locator('text=OK').click(); | ||||
|     await page.locator('button:has-text("OK")').click(); | ||||
| } | ||||
|   | ||||
| @@ -43,14 +43,14 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add sine wave generator with defaults | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     //Add a 5000 ms Delay | ||||
|     await page.locator('[aria-label="Loading Delay \\(ms\\)"]').fill('5000'); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -58,7 +58,7 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|     // focus the overlay plot | ||||
|     await page.goto(overlayPlot.url); | ||||
|  | ||||
|     await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Overlay Plot'); | ||||
|     await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlot.name); | ||||
|     //Save localStorage for future test execution | ||||
|     await context.storageState({ path: './e2e/test-data/VisualTestData_storage.json' }); | ||||
| }); | ||||
|   | ||||
| @@ -25,7 +25,7 @@ | ||||
| * | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { test, expect } = require('../../pluginFixtures'); | ||||
|  | ||||
| test.describe("CouchDB Status Indicator @couchdb", () => { | ||||
|     test.use({ failOnConsoleError: false }); | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
| This test suite is dedicated to tests which verify the basic operations surrounding the example event generator. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../appActions'); | ||||
|  | ||||
| test.describe('Example Event Generator CRUD Operations', () => { | ||||
|   | ||||
| @@ -96,7 +96,7 @@ test.describe('Sine Wave Generator', () => { | ||||
|         //Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         // Verify that the Sine Wave Generator is displayed and correct | ||||
|   | ||||
| @@ -25,6 +25,7 @@ This test suite is dedicated to tests which verify form functionality in isolati | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
| const path = require('path'); | ||||
|  | ||||
| const TEST_FOLDER = 'test folder'; | ||||
| @@ -43,7 +44,7 @@ test.describe('Form Validation Behavior', () => { | ||||
|         await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab'); | ||||
|  | ||||
|         //Required Field Form Validation | ||||
|         await expect(page.locator('text=OK')).toBeDisabled(); | ||||
|         await expect(page.locator('button:has-text("OK")')).toBeDisabled(); | ||||
|         await expect(page.locator('.c-form-row__state-indicator').first()).toHaveClass(/invalid/); | ||||
|  | ||||
|         //Correct Form Validation for missing title and trigger validation with 'Tab' | ||||
| @@ -52,13 +53,13 @@ test.describe('Form Validation Behavior', () => { | ||||
|         await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab'); | ||||
|  | ||||
|         //Required Field Form Validation is corrected | ||||
|         await expect(page.locator('text=OK')).toBeEnabled(); | ||||
|         await expect(page.locator('button:has-text("OK")')).toBeEnabled(); | ||||
|         await expect(page.locator('.c-form-row__state-indicator').first()).not.toHaveClass(/invalid/); | ||||
|  | ||||
|         //Finish Creating Domain Object | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         //Verify that the Domain Object has been created with the corrected title property | ||||
| @@ -91,6 +92,44 @@ test.describe('Persistence operations @addInit', () => { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| test.describe('Persistence operations @couchdb', () => { | ||||
|     test.use({ failOnConsoleError: false }); | ||||
|     test('Editing object properties should generate a single persistence operation', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/5616' | ||||
|         }); | ||||
|  | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create a new 'Clock' object with default settings | ||||
|         const clock = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock' | ||||
|         }); | ||||
|  | ||||
|         // Count all persistence operations (PUT requests) for this specific object | ||||
|         let putRequestCount = 0; | ||||
|         page.on('request', req => { | ||||
|             if (req.method() === 'PUT' && req.url().endsWith(clock.uuid)) { | ||||
|                 putRequestCount += 1; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Open the edit form for the clock object | ||||
|         await page.click('button[title="More options"]'); | ||||
|         await page.click('li[title="Edit properties of this object."]'); | ||||
|  | ||||
|         // Modify the display format from default 12hr -> 24hr and click 'Save' | ||||
|         await page.locator('select[aria-label="12 or 24 hour clock"]').selectOption({ value: 'clock24' }); | ||||
|         await page.click('button[aria-label="Save"]'); | ||||
|  | ||||
|         await expect.poll(() => putRequestCount, { | ||||
|             message: 'Verify a single PUT request was made to persist the object', | ||||
|             timeout: 1000 | ||||
|         }).toEqual(1); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| test.describe('Form Correctness by Object Type', () => { | ||||
|     test.fixme('Verify correct behavior of number object (SWG)', async ({page}) => {}); | ||||
|     test.fixme('Verify correct behavior of number object Timer', async ({page}) => {}); | ||||
|   | ||||
| @@ -81,7 +81,7 @@ test.describe('Move & link item tests', () => { | ||||
|         await page.locator('li.icon-move').click(); | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Expect that Child Folder is in My Items, the root folder | ||||
|         expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); | ||||
| @@ -95,11 +95,11 @@ test.describe('Move & link item tests', () => { | ||||
|         // Create Telemetry Table | ||||
|         let telemetryTable = 'Test Telemetry Table'; | ||||
|         await page.locator('button:has-text("Create")').click(); | ||||
|         await page.locator('li:has-text("Telemetry Table")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Telemetry Table")').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill(telemetryTable); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Finish editing and save Telemetry Table | ||||
|         await page.locator('.c-button--menu.c-button--major.icon-save').click(); | ||||
| @@ -108,7 +108,7 @@ test.describe('Move & link item tests', () => { | ||||
|         // Create New Folder Basic Domain Object | ||||
|         let folder = 'Test Folder'; | ||||
|         await page.locator('button:has-text("Create")').click(); | ||||
|         await page.locator('li:has-text("Folder")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Folder")').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder); | ||||
|  | ||||
| @@ -120,7 +120,7 @@ test.describe('Move & link item tests', () => { | ||||
|  | ||||
|         // Continue test regardless of assertion and create it in My Items | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Open My Items | ||||
|         await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); | ||||
| @@ -196,7 +196,7 @@ test.describe('Move & link item tests', () => { | ||||
|         await page.locator('li.icon-link').click(); | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Expect that Child Folder is in My Items, the root folder | ||||
|         expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); | ||||
|   | ||||
| @@ -40,11 +40,11 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         await page.locator('li:has-text("Condition Set")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Condition Set")').click(); | ||||
|  | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         //Save localStorage for future test execution | ||||
| @@ -163,9 +163,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         // Click hamburger button | ||||
|         await page.locator('[title="More options"]').click(); | ||||
|  | ||||
|         // Click text=Remove | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         // Click 'Remove' and press OK | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         //Expect Unnamed Condition Set to be removed in Main View | ||||
|         const numberOfConditionSetsAtEnd = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Testing Display Layout @unstable', () => { | ||||
| test.describe('Display Layout', () => { | ||||
|     let sineWaveObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
| @@ -55,12 +55,12 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|         // On getting data, check if the value found in the  Display Layout is the most recent value | ||||
|         // from the Sine Wave Generator | ||||
|         const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); | ||||
|         const formattedTelemetryValue = await getTelemValuePromise; | ||||
|         const formattedTelemetryValue = getTelemValuePromise; | ||||
|         const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); | ||||
|         const displayLayoutValue = await displayLayoutValuePromise.textContent(); | ||||
|         const trimmedDisplayValue = displayLayoutValue.trim(); | ||||
|  | ||||
|         await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|         expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|     }); | ||||
|     test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in fixed time', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
| @@ -86,12 +86,12 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|  | ||||
|         // On getting data, check if the value found in the Display Layout is the most recent value | ||||
|         // from the Sine Wave Generator | ||||
|         const formattedTelemetryValue = await getTelemValuePromise; | ||||
|         const formattedTelemetryValue = getTelemValuePromise; | ||||
|         const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); | ||||
|         const displayLayoutValue = await displayLayoutValuePromise.textContent(); | ||||
|         const trimmedDisplayValue = displayLayoutValue.trim(); | ||||
|  | ||||
|         await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|         expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|     }); | ||||
|     test('items in a display layout can be removed with object tree context menu when viewing the display layout', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
| @@ -116,16 +116,20 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // delete | ||||
|  | ||||
|         expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|         expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|     }); | ||||
|     test('items in a display layout can be removed with object tree context menu when viewing another item', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/3117' | ||||
|         }); | ||||
|         // Create a Display Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const displayLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Display Layout', | ||||
|             name: "Test Display Layout" | ||||
|         }); | ||||
| @@ -144,18 +148,18 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|         // Expand the Display Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Click the original Sine Wave Generator to navigate away from the Display Layout | ||||
|         await page.locator('.c-tree__item .c-tree__item__name:text("Test Sine Wave Generator")').click(); | ||||
|         // Go to the original Sine Wave Generator to navigate away from the Display Layout | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // navigate back to the display layout to confirm it has been removed | ||||
|         await page.locator('.c-tree__item .c-tree__item__name:text("Test Display Layout")').click(); | ||||
|         await page.goto(displayLayout.url); | ||||
|  | ||||
|         expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|         expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -23,12 +23,13 @@ | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Testing Flexible Layout @unstable', () => { | ||||
| test.describe('Flexible Layout', () => { | ||||
|     let sineWaveObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Sine Wave Generator | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         sineWaveObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: "Test Sine Wave Generator" | ||||
|         }); | ||||
| @@ -54,13 +55,81 @@ test.describe('Testing Flexible Layout @unstable', () => { | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.dragAndDrop('text=Test Clock', '.c-fl__container.is-empty'); | ||||
|         // Check that panes can be dragged while Flexible Layout is in Edit mode | ||||
|         let dragWrapper = await page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         let dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'true'); | ||||
|         // Save Flexible Layout | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|         // Check that panes are not draggable while Flexible Layout is in Browse mode | ||||
|         dragWrapper = await page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'false'); | ||||
|     }); | ||||
|     test('items in a flexible layout can be removed with object tree context menu when viewing the flexible layout', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
|  | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
|         expect.soft(await page.locator('.c-fl-container__frame').count()).toEqual(1); | ||||
|  | ||||
|         // Expand the Flexible Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Verify that the item has been removed from the layout | ||||
|         expect(await page.locator('.c-fl-container__frame').count()).toEqual(0); | ||||
|     }); | ||||
|     test('items in a flexible layout can be removed with object tree context menu when viewing another item', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/3117' | ||||
|         }); | ||||
|         // Create a Flexible Layout | ||||
|         const flexibleLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
|  | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
|         expect.soft(await page.locator('.c-fl-container__frame').count()).toEqual(1); | ||||
|  | ||||
|         // Expand the Flexible Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Go to the original Sine Wave Generator to navigate away from the Flexible Layout | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // navigate back to the display layout to confirm it has been removed | ||||
|         await page.goto(flexibleLayout.url); | ||||
|  | ||||
|         // Verify that the item has been removed from the layout | ||||
|         expect(await page.locator('.c-fl-container__frame').count()).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										93
									
								
								e2e/tests/functional/plugins/gauge/gauge.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,93 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| * This test suite is dedicated to testing the Gauge component. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const uuid = require('uuid').v4; | ||||
|  | ||||
| test.describe('Gauge', () => { | ||||
|     let gauge; | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create the gauge | ||||
|         gauge = await createDomainObjectWithDefaults(page, { type: 'Gauge' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can add and remove telemetry sources @unstable', async ({ page }) => { | ||||
|         const editButtonLocator = page.locator('button[title="Edit"]'); | ||||
|         const saveButtonLocator = page.locator('button[title="Save"]'); | ||||
|  | ||||
|         // Create a sine wave generator within the gauge | ||||
|         const swg1 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: gauge.uuid | ||||
|         }); | ||||
|  | ||||
|         // Navigate to the gauge and verify that | ||||
|         // the SWG appears in the elements pool | ||||
|         await page.goto(gauge.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|         await page.locator('li[title="Save and Finish Editing"]').click(); | ||||
|  | ||||
|         // Create another sine wave generator within the gauge | ||||
|         const swg2 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: gauge.uuid | ||||
|         }); | ||||
|  | ||||
|         // Verify that the 'Replace telemetry source' modal appears and accept it | ||||
|         await expect.soft(page.locator('text=This action will replace the current telemetry source. Do you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Navigate to the gauge and verify that the new SWG | ||||
|         // appears in the elements pool and the old one is gone | ||||
|         await page.goto(gauge.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeHidden(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg2.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|  | ||||
|         // Right click on the new SWG in the elements pool and delete it | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swg2.name}`).click({ | ||||
|             button: 'right' | ||||
|         }); | ||||
|         await page.locator('li[title="Remove this object from its containing object."]').click(); | ||||
|  | ||||
|         // Verify that the 'Remove object' confirmation modal appears and accept it | ||||
|         await expect.soft(page.locator('text=Warning! This action will remove this object. Are you sure you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Verify that the elements pool shows no elements | ||||
|         await expect(page.locator('text="No contained elements"')).toBeVisible(); | ||||
|     }); | ||||
| }); | ||||
| @@ -40,10 +40,10 @@ test.describe('Example Imagery Object', () => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create a default 'Example Imagery' object | ||||
|         await createDomainObjectWithDefaults(page, { type: 'Example Imagery' }); | ||||
|         const exampleImagery = await createDomainObjectWithDefaults(page, { type: 'Example Imagery' }); | ||||
|  | ||||
|         // Verify that the created object is focused | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|     }); | ||||
|  | ||||
| @@ -188,7 +188,7 @@ test.describe('Example Imagery in Display Layout', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -197,7 +197,7 @@ test.describe('Example Imagery in Display Layout', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
| @@ -275,7 +275,7 @@ test.describe('Example Imagery in Flexible layout', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -284,7 +284,7 @@ test.describe('Example Imagery in Flexible layout', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
| @@ -317,7 +317,7 @@ test.describe('Example Imagery in Tabs View', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -326,7 +326,7 @@ test.describe('Example Imagery in Tabs View', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|   | ||||
| @@ -26,7 +26,7 @@ This test suite is dedicated to tests which verify the basic operations surround | ||||
|  | ||||
| // FIXME: Remove this eslint exception once tests are implemented | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const nbUtils = require('../../../../helper/notebookUtils'); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,271 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Notebook Tests with CouchDB @couchdb', () => { | ||||
|     let testNotebook; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         //Navigate to baseURL | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         testNotebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "TestNotebook" | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     test('Inspect Notebook Entry Network Requests', async ({ page }) => { | ||||
|         // Expand sidebar | ||||
|         await page.locator('.c-notebook__toggle-nav-button').click(); | ||||
|  | ||||
|         // Collect all request events to count and assert after notebook action | ||||
|         let addingNotebookElementsRequests = []; | ||||
|         page.on('request', (request) => addingNotebookElementsRequests.push(request)); | ||||
|  | ||||
|         let [notebookUrlRequest, allDocsRequest] = await Promise.all([ | ||||
|             // Waits for the next request with the specified url | ||||
|             page.waitForRequest(`**/openmct/${testNotebook.uuid}`), | ||||
|             page.waitForRequest('**/openmct/_all_docs?include_docs=true'), | ||||
|             // Triggers the request | ||||
|             page.click('[aria-label="Add Page"]'), | ||||
|             // Ensures that there are no other network requests | ||||
|             page.waitForLoadState('networkidle') | ||||
|         ]); | ||||
|         // Assert that only two requests are made | ||||
|         // Network Requests are: | ||||
|         // 1) The actual POST to create the page | ||||
|         // 2) The shared worker event from 👆 request | ||||
|         expect(addingNotebookElementsRequests.length).toBe(2); | ||||
|  | ||||
|         // Assert on request object | ||||
|         expect(notebookUrlRequest.postDataJSON().metadata.name).toBe('TestNotebook'); | ||||
|         expect(notebookUrlRequest.postDataJSON().model.persisted).toBeGreaterThanOrEqual(notebookUrlRequest.postDataJSON().model.modified); | ||||
|         expect(allDocsRequest.postDataJSON().keys).toContain(testNotebook.uuid); | ||||
|  | ||||
|         // Add an entry | ||||
|         // Network Requests are: | ||||
|         // 1) The actual POST to create the entry | ||||
|         // 2) The shared worker event from 👆 POST request | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); | ||||
|         await page.waitForLoadState('networkidle'); | ||||
|         expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2); | ||||
|  | ||||
|         // Add some tags | ||||
|         // Network Requests are for each tag creation are: | ||||
|         // 1) Getting the original path of the parent object | ||||
|         // 2) Getting the original path of the grandparent object (recursive call) | ||||
|         // 3) Creating the annotation/tag object | ||||
|         // 4) The shared worker event from 👆 POST request | ||||
|         // 5) Mutate notebook domain object's annotationModified property | ||||
|         // 6) The shared worker event from 👆 POST request | ||||
|         // 7) Notebooks fetching new annotations due to annotationModified changed | ||||
|         // 8) The update of the notebook domain's object's modified property | ||||
|         // 9) The shared worker event from 👆 POST request | ||||
|         // 10) Entry is timestamped | ||||
|         // 11) The shared worker event from 👆 POST request | ||||
|  | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11); | ||||
|  | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11); | ||||
|  | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Science")'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11); | ||||
|  | ||||
|         // Delete all the tags | ||||
|         // Network requests are: | ||||
|         // 1) Send POST to mutate _delete property to true on annotation with tag | ||||
|         // 2) The shared worker event from 👆 POST request | ||||
|         // 3) Timestamp update on entry | ||||
|         // 4) The shared worker event from 👆 POST request | ||||
|         // This happens for 3 tags so 12 requests | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.hover('[aria-label="Tag"]:has-text("Driving")'); | ||||
|         await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")', {state: 'hidden'}); | ||||
|         await page.hover('[aria-label="Tag"]:has-text("Drilling")'); | ||||
|         await page.locator('[aria-label="Tag"]:has-text("Drilling") ~ .c-completed-tag-deletion').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")', {state: 'hidden'}); | ||||
|         page.hover('[aria-label="Tag"]:has-text("Science")'); | ||||
|         await page.locator('[aria-label="Tag"]:has-text("Science") ~ .c-completed-tag-deletion').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Science")', {state: 'hidden'}); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(12); | ||||
|  | ||||
|         // Add two more pages | ||||
|         await page.click('[aria-label="Add Page"]'); | ||||
|         await page.click('[aria-label="Add Page"]'); | ||||
|  | ||||
|         // Add three entries | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); | ||||
|  | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`); | ||||
|  | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`); | ||||
|  | ||||
|         // Add three tags | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Science")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|  | ||||
|         // Add a fourth entry | ||||
|         // Network requests are: | ||||
|         // 1) Send POST to add new entry | ||||
|         // 2) The shared worker event from 👆 POST request | ||||
|         // 3) Timestamp update on entry | ||||
|         // 4) The shared worker event from 👆 POST request | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').fill(`Fourth Entry`); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').press('Enter'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|  | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4); | ||||
|  | ||||
|         // Add a fifth entry | ||||
|         // Network requests are: | ||||
|         // 1) Send POST to add new entry | ||||
|         // 2) The shared worker event from 👆 POST request | ||||
|         // 3) Timestamp update on entry | ||||
|         // 4) The shared worker event from 👆 POST request | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').fill(`Fifth Entry`); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').press('Enter'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|  | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4); | ||||
|  | ||||
|         // Add a sixth entry | ||||
|         // 1) Send POST to add new entry | ||||
|         // 2) The shared worker event from 👆 POST request | ||||
|         // 3) Timestamp update on entry | ||||
|         // 4) The shared worker event from 👆 POST request | ||||
|         addingNotebookElementsRequests = []; | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').fill(`Sixth Entry`); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').press('Enter'); | ||||
|         page.waitForLoadState('networkidle'); | ||||
|  | ||||
|         expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4); | ||||
|     }); | ||||
|  | ||||
|     test('Search tests', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/akhenry/openmct-yamcs/issues/69' | ||||
|         }); | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').press('Enter'); | ||||
|  | ||||
|         // Add three tags | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Science")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")'); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc'); | ||||
|         await expect(page.locator('[aria-label="Search Result"]').first()).toContainText("Science"); | ||||
|         await expect(page.locator('[aria-label="Search Result"]').first()).not.toContainText("Driving"); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq'); | ||||
|         await expect(page.locator('text=No results found')).toBeVisible(); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| // Try to reduce indeterminism of browser requests by only returning fetch requests. | ||||
| // Filter out preflight CORS, fetching stylesheets, page icons, etc. that can occur during tests | ||||
| function filterNonFetchRequests(requests) { | ||||
|     return requests.filter(request => { | ||||
|         return (request.resourceType() === 'fetch'); | ||||
|     }); | ||||
| } | ||||
| @@ -36,27 +36,27 @@ test.describe('Restricted Notebook', () => { | ||||
|     }); | ||||
|  | ||||
|     test('Can be renamed @addInit', async ({ page }) => { | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(`Unnamed ${CUSTOM_NAME}`); | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(`${notebook.name}`); | ||||
|     }); | ||||
|  | ||||
|     test('Can be deleted if there are no locked pages @addInit', async ({ page, openmctConfig }) => { | ||||
|     test('Can be deleted if there are no locked pages @addInit', async ({ page }) => { | ||||
|         await openObjectTreeContextMenu(page, notebook.url); | ||||
|  | ||||
|         const menuOptions = page.locator('.c-menu ul'); | ||||
|         await expect.soft(menuOptions).toContainText('Remove'); | ||||
|  | ||||
|         const restrictedNotebookTreeObject = page.locator(`a:has-text("Unnamed ${CUSTOM_NAME}")`); | ||||
|         const restrictedNotebookTreeObject = page.locator(`a:has-text("${notebook.name}")`); | ||||
|  | ||||
|         // notebook tree object exists | ||||
|         expect.soft(await restrictedNotebookTreeObject.count()).toEqual(1); | ||||
|  | ||||
|         // Click Remove Text | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|  | ||||
|         // Click 'OK' on confirmation window and wait for save banner to appear | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.locator('text=OK').click(), | ||||
|             page.locator('button:has-text("OK")').click(), | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|  | ||||
| @@ -134,7 +134,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|         // Click text=Ok | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.locator('text=Ok').click() | ||||
|             page.locator('button:has-text("OK")').click() | ||||
|         ]); | ||||
|  | ||||
|         // deleted page, should no longer exist | ||||
| @@ -145,10 +145,9 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|  | ||||
| test.describe('Restricted Notebook with a page locked and with an embed @addInit', () => { | ||||
|  | ||||
|     test.beforeEach(async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|         await startAndAddRestrictedNotebookObject(page); | ||||
|         await nbUtils.dragAndDropEmbed(page, myItemsFolderName); | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         const notebook = await startAndAddRestrictedNotebookObject(page); | ||||
|         await nbUtils.dragAndDropEmbed(page, notebook); | ||||
|     }); | ||||
|  | ||||
|     test('Allows embeds to be deleted if page unlocked @addInit', async ({ page }) => { | ||||
|   | ||||
| @@ -36,15 +36,17 @@ async function createNotebookAndEntry(page, iterations = 1) { | ||||
|     //Go to baseURL | ||||
|     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     createDomainObjectWithDefaults(page, { type: 'Notebook' }); | ||||
|     const notebook = createDomainObjectWithDefaults(page, { type: 'Notebook' }); | ||||
|  | ||||
|     for (let iteration = 0; iteration < iterations; iteration++) { | ||||
|         // Click text=To start a new entry, click here or drag and drop any object | ||||
|         // Create an entry | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = ${iteration}`; | ||||
|         await page.locator(entryLocator).click(); | ||||
|         await page.locator(entryLocator).fill(`Entry ${iteration}`); | ||||
|     } | ||||
|  | ||||
|     return notebook; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -53,7 +55,7 @@ async function createNotebookAndEntry(page, iterations = 1) { | ||||
|   * @param {number} [iterations = 1] - the number of entries (and tags) to create | ||||
|   */ | ||||
| async function createNotebookEntryAndTags(page, iterations = 1) { | ||||
|     await createNotebookAndEntry(page, iterations); | ||||
|     const notebook = await createNotebookAndEntry(page, iterations); | ||||
|  | ||||
|     for (let iteration = 0; iteration < iterations; iteration++) { | ||||
|         // Hover and click "Add Tag" button | ||||
| @@ -75,16 +77,16 @@ async function createNotebookEntryAndTags(page, iterations = 1) { | ||||
|         // Select the "Science" tag | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|     } | ||||
|  | ||||
|     return notebook; | ||||
| } | ||||
|  | ||||
| test.describe('Tagging in Notebooks @addInit', () => { | ||||
|     test('Can load tags', async ({ page }) => { | ||||
|  | ||||
|         await createNotebookAndEntry(page); | ||||
|         // Click text=To start a new entry, click here or drag and drop any object | ||||
|         await page.locator('button:has-text("Add Tag")').click(); | ||||
|  | ||||
|         // Click [placeholder="Type to select tag"] | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|  | ||||
|         await expect(page.locator('[aria-label="Autocomplete Options"]')).toContainText("Science"); | ||||
| @@ -97,9 +99,7 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|         await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science"); | ||||
|         await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Driving"); | ||||
|  | ||||
|         // Click button:has-text("Add Tag") | ||||
|         await page.locator('button:has-text("Add Tag")').click(); | ||||
|         // Click [placeholder="Type to select tag"] | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|  | ||||
|         await expect(page.locator('[aria-label="Autocomplete Options"]')).not.toContainText("Science"); | ||||
| @@ -108,43 +108,56 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|     }); | ||||
|     test('Can search for tags', async ({ page }) => { | ||||
|         await createNotebookEntryAndTags(page); | ||||
|         // Click [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
|         // Fill [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science"); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving"); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); | ||||
|  | ||||
|         // Click [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
|         // Fill [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc'); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science"); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving"); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); | ||||
|  | ||||
|         // Click [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
|         // Fill [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq'); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toBeHidden(); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).toBeHidden(); | ||||
|         await expect(page.locator('text=No results found')).toBeVisible(); | ||||
|     }); | ||||
|  | ||||
|     test('Can delete tags', async ({ page }) => { | ||||
|         await createNotebookEntryAndTags(page); | ||||
|         await page.locator('[aria-label="Notebook Entries"]').click(); | ||||
|         // Delete Driving | ||||
|         await page.hover('.c-tag__label:has-text("Driving")'); | ||||
|         await page.locator('.c-tag__label:has-text("Driving") ~ .c-completed-tag-deletion').click(); | ||||
|         await page.hover('[aria-label="Tag"]:has-text("Driving")'); | ||||
|         await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click(); | ||||
|  | ||||
|         await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science"); | ||||
|         await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving"); | ||||
|  | ||||
|         // Fill [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); | ||||
|         await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); | ||||
|     }); | ||||
|  | ||||
|     test('Can delete entries without tags', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/5823' | ||||
|         }); | ||||
|  | ||||
|         await createNotebookEntryAndTags(page); | ||||
|  | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = 1`; | ||||
|         await page.locator(entryLocator).click(); | ||||
|         await page.locator(entryLocator).fill(`An entry without tags`); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter'); | ||||
|  | ||||
|         await page.hover('[aria-label="Notebook Entry Input"] >> nth=1'); | ||||
|         await page.locator('button[title="Delete this entry"]').last().click(); | ||||
|         await expect(page.locator('text=This action will permanently delete this entry. Do you wish to continue?')).toBeVisible(); | ||||
|         await page.locator('button:has-text("Ok")').click(); | ||||
|         await expect(page.locator('text=This action will permanently delete this entry. Do you wish to continue?')).toBeHidden(); | ||||
|     }); | ||||
|  | ||||
|     test('Can delete objects with tags and neither return in search', async ({ page }) => { | ||||
|         await createNotebookEntryAndTags(page); | ||||
|         // Delete Notebook | ||||
| @@ -153,7 +166,6 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Fill [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed'); | ||||
|         await expect(page.locator('text=No results found')).toBeVisible(); | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sci'); | ||||
| @@ -165,10 +177,10 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|         //Go to baseURL | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         await createDomainObjectWithDefaults(page, { type: 'Clock' }); | ||||
|         const clock = await createDomainObjectWithDefaults(page, { type: 'Clock' }); | ||||
|  | ||||
|         const ITERATIONS = 4; | ||||
|         await createNotebookEntryAndTags(page, ITERATIONS); | ||||
|         const notebook = await createNotebookEntryAndTags(page, ITERATIONS); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
| @@ -181,11 +193,11 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             page.goto('./#/browse/mine?hideTree=false'), | ||||
|             page.click('.c-disclosure-triangle') | ||||
|         ]); | ||||
|         // Click Unnamed Clock | ||||
|         await page.click('text="Unnamed Clock"'); | ||||
|         // Click Clock | ||||
|         await page.click(`text=${clock.name}`); | ||||
|  | ||||
|         // Click Unnamed Notebook | ||||
|         await page.click('text="Unnamed Notebook"'); | ||||
|         // Click Notebook | ||||
|         await page.click(`text=${notebook.name}`); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
| @@ -199,14 +211,13 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             page.waitForLoadState('networkidle') | ||||
|         ]); | ||||
|  | ||||
|         // Click Unnamed Notebook | ||||
|         await page.click('text="Unnamed Notebook"'); | ||||
|         // Click Notebook | ||||
|         await page.click(`text="${notebook.name}"`); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
|             await expect(page.locator(entryLocator)).toContainText("Science"); | ||||
|             await expect(page.locator(entryLocator)).toContainText("Driving"); | ||||
|         } | ||||
|  | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -110,10 +110,10 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add overlay plot with defaults | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear1 | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -129,10 +129,10 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add sine wave generator with defaults | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear1 | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| @@ -88,10 +88,10 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|     // create overlay plot | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -106,7 +106,7 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|     // create a sinewave generator | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     // set amplitude to 6, offset 4, period 2 | ||||
|  | ||||
| @@ -123,7 +123,7 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| @@ -88,11 +88,11 @@ async function makeStackedPlot(page, myItemsFolderName) { | ||||
|  | ||||
|     // create stacked plot | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Stacked Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Stacked Plot")').click(); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -146,11 +146,11 @@ async function saveStackedPlot(page) { | ||||
| async function createSineWaveGenerator(page) { | ||||
|     //Create sine wave generator | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| @@ -68,10 +68,10 @@ async function makeOverlayPlot(page) { | ||||
|     // create overlay plot | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -86,13 +86,13 @@ async function makeOverlayPlot(page) { | ||||
|     // create a sinewave generator | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     // Click OK to make generator | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
							
								
								
									
										54
									
								
								e2e/tests/functional/plugins/plot/plotRendering.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| * This test suite is dedicated to testing the rendering and interaction of plots. | ||||
| * | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Plot Integrity Testing @unstable', () => { | ||||
|     let sineWaveGeneratorObject; | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         //Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|         sineWaveGeneratorObject = await createDomainObjectWithDefaults(page, { type: 'Sine Wave Generator' }); | ||||
|     }); | ||||
|  | ||||
|     test('Plots do not re-request data when a plot is clicked', async ({ page }) => { | ||||
|         //Navigate to Sine Wave Generator | ||||
|         await page.goto(sineWaveGeneratorObject.url); | ||||
|         //Capture the number of plots points and store as const name numberOfPlotPoints | ||||
|         //Click on the plot canvas | ||||
|         await page.locator('canvas').nth(1).click(); | ||||
|         //No request was made to get historical data | ||||
|         const createMineFolderRequests = []; | ||||
|         page.on('request', req => { | ||||
|             // eslint-disable-next-line playwright/no-conditional-in-test | ||||
|             createMineFolderRequests.push(req); | ||||
|         }); | ||||
|         expect(createMineFolderRequests.length).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										93
									
								
								e2e/tests/functional/plugins/plot/scatterPlot.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,93 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| * This test suite is dedicated to testing the Scatter Plot component. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const uuid = require('uuid').v4; | ||||
|  | ||||
| test.describe('Scatter Plot', () => { | ||||
|     let scatterPlot; | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create the Scatter Plot | ||||
|         scatterPlot = await createDomainObjectWithDefaults(page, { type: 'Scatter Plot' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can add and remove telemetry sources', async ({ page }) => { | ||||
|         const editButtonLocator = page.locator('button[title="Edit"]'); | ||||
|         const saveButtonLocator = page.locator('button[title="Save"]'); | ||||
|  | ||||
|         // Create a sine wave generator within the scatter plot | ||||
|         const swg1 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: scatterPlot.uuid | ||||
|         }); | ||||
|  | ||||
|         // Navigate to the scatter plot and verify that | ||||
|         // the SWG appears in the elements pool | ||||
|         await page.goto(scatterPlot.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|         await page.locator('li[title="Save and Finish Editing"]').click(); | ||||
|  | ||||
|         // Create another sine wave generator within the scatter plot | ||||
|         const swg2 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: scatterPlot.uuid | ||||
|         }); | ||||
|  | ||||
|         // Verify that the 'Replace telemetry source' modal appears and accept it | ||||
|         await expect.soft(page.locator('text=This action will replace the current telemetry source. Do you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Navigate to the scatter plot and verify that the new SWG | ||||
|         // appears in the elements pool and the old one is gone | ||||
|         await page.goto(scatterPlot.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeHidden(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg2.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|  | ||||
|         // Right click on the new SWG in the elements pool and delete it | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swg2.name}`).click({ | ||||
|             button: 'right' | ||||
|         }); | ||||
|         await page.locator('li[title="Remove this object from its containing object."]').click(); | ||||
|  | ||||
|         // Verify that the 'Remove object' confirmation modal appears and accept it | ||||
|         await expect.soft(page.locator('text=Warning! This action will remove this object. Are you sure you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Verify that the elements pool shows no elements | ||||
|         await expect(page.locator('text="No contained elements"')).toBeVisible(); | ||||
|     }); | ||||
| }); | ||||
| @@ -20,7 +20,7 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { setFixedTimeMode, setRealTimeMode, setStartOffset, setEndOffset } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Time conductor operations', () => { | ||||
| @@ -168,3 +168,23 @@ test.describe('Time conductor input fields real-time mode', () => { | ||||
|         // select an option and verify the offsets are updated correctly | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| test.describe('Time Conductor History', () => { | ||||
|     test("shows milliseconds on hover @unstable", async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/4386' | ||||
|         }); | ||||
|         // Navigate to Open MCT in Fixed Time Mode, UTC Time System | ||||
|         // with startBound at 2022-01-01 00:00:00.000Z | ||||
|         // and endBound at 2022-01-01 00:00:00.200Z | ||||
|         await page.goto('./#/browse/mine?view=grid&tc.mode=fixed&tc.startBound=1640995200000&tc.endBound=1640995200200&tc.timeSystem=utc&hideInspector=true', { waitUntil: 'networkidle' }); | ||||
|         await page.locator("[aria-label='Time Conductor History']").hover({ trial: true}); | ||||
|         await page.locator("[aria-label='Time Conductor History']").click(); | ||||
|  | ||||
|         // Validate history item format | ||||
|         const historyItem = page.locator('text="2022-01-01 00:00:00 + 200ms"'); | ||||
|         await expect(historyItem).toBeEnabled(); | ||||
|         await expect(historyItem).toHaveAttribute('title', '2022-01-01 00:00:00.000 - 2022-01-01 00:00:00.200'); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -30,7 +30,7 @@ test.describe('Timer', () => { | ||||
|         timer = await createDomainObjectWithDefaults(page, { type: 'timer' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can perform actions on the Timer', async ({ page, openmctConfig }) => { | ||||
|     test('Can perform actions on the Timer', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/4313' | ||||
|   | ||||
| @@ -31,7 +31,7 @@ test.describe('Grand Search', () => { | ||||
|     test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|  | ||||
|         await createObjectsForSearch(page, myItemsFolderName); | ||||
|         const createdObjects = await createObjectsForSearch(page); | ||||
|  | ||||
|         // Click [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
| @@ -41,8 +41,8 @@ test.describe('Grand Search', () => { | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=1')).toContainText(`Clock B ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=2')).toContainText(`Clock C ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=3')).toContainText(`Clock D ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         // Click text=Elements >> nth=0 | ||||
|         await page.locator('text=Elements').first().click(); | ||||
|         // Click the Elements pool to dismiss the search menu | ||||
|         await page.locator('.l-pane__label:has-text("Elements")').click(); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toBeHidden(); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click(); | ||||
| @@ -77,7 +77,7 @@ test.describe('Grand Search', () => { | ||||
|         await expect(page.locator('.is-object-type-clock')).toBeVisible(); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Disp'); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toContainText('Unnamed Display Layout'); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toContainText(createdObjects.displayLayout.name); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).not.toContainText('Folder'); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Clock C'); | ||||
| @@ -185,7 +185,7 @@ async function createFolderObject(page, folderName) { | ||||
|     await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folderName); | ||||
|  | ||||
|     // Create folder object | ||||
|     await page.locator('text=OK').click(); | ||||
|     await page.locator('button:has-text("OK")').click(); | ||||
| } | ||||
|  | ||||
| async function waitForSearchCompletion(page) { | ||||
| @@ -197,75 +197,56 @@ async function waitForSearchCompletion(page) { | ||||
|   * Creates some domain objects for searching | ||||
|   * @param {import('@playwright/test').Page} page | ||||
|   */ | ||||
| async function createObjectsForSearch(page, myItemsFolderName) { | ||||
| async function createObjectsForSearch(page) { | ||||
|     //Go to baseURL | ||||
|     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li:has-text("Folder") >> nth=1').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill('Red Folder'), | ||||
|         await page.locator(`text=Save In Open MCT ${myItemsFolderName} >> span`).nth(3).click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const redFolder = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Folder', | ||||
|         name: 'Red Folder' | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li:has-text("Folder") >> nth=2').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill('Blue Folder'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Red Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const blueFolder = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Folder', | ||||
|         name: 'Blue Folder', | ||||
|         parent: redFolder.uuid | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock A'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const clockA = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock A', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockB = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock B', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockC = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock C', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockD = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock D', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock B'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const displayLayout = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Display Layout' | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock C'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     // Go back into edit mode for the display layout | ||||
|     await page.locator('button[title="Edit"]').click(); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock D'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator(`a:has-text("${myItemsFolderName}") >> nth=0`).click() | ||||
|     ]); | ||||
|     // Click button:has-text("Create") | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     // Click li:has-text("Notebook") | ||||
|     await page.locator('li:has-text("Display Layout")').click(); | ||||
|     // Click button:has-text("OK") | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     return { | ||||
|         redFolder, | ||||
|         blueFolder, | ||||
|         clockA, | ||||
|         clockB, | ||||
|         clockC, | ||||
|         clockD, | ||||
|         displayLayout | ||||
|     }; | ||||
| } | ||||
|   | ||||
| @@ -53,7 +53,7 @@ test.describe('Performance tests', () => { | ||||
|         await page.setInputFiles('#fileElem', filePath); | ||||
|  | ||||
|         // Click text=OK | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         await expect(page.locator('a:has-text("Performance Display Layout Display Layout")')).toBeVisible(); | ||||
|  | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|  | ||||
| /* | ||||
| Collection of Visual Tests set to run in a default context. The tests within this suite | ||||
| are only meant to run against openmct's app.js started by `npm run start` within the | ||||
| are only meant to run against openmct started by `npm start` within the | ||||
| `./e2e/playwright-visual.config.js` file. | ||||
|  | ||||
| */ | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|  | ||||
| /* | ||||
| Collection of Visual Tests set to run in a default context. The tests within this suite | ||||
| are only meant to run against openmct's app.js started by `npm run start` within the | ||||
| are only meant to run against openmct started by `npm start` within the | ||||
| `./e2e/playwright-visual.config.js` file. | ||||
|  | ||||
| These should only use functional expect statements to verify assumptions about the state | ||||
|   | ||||
| @@ -75,7 +75,8 @@ | ||||
|         const TWO_HOURS = ONE_HOUR * 2; | ||||
|         const ONE_DAY = ONE_HOUR * 24; | ||||
|  | ||||
|         openmct.install(openmct.plugins.LocalStorage()); | ||||
|         // openmct.install(openmct.plugins.LocalStorage()); | ||||
|         openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct")); | ||||
|  | ||||
|         openmct.install(openmct.plugins.example.Generator()); | ||||
|         openmct.install(openmct.plugins.example.EventGeneratorPlugin()); | ||||
|   | ||||
							
								
								
									
										12
									
								
								jsdoc.json
									
									
									
									
									
								
							
							
						
						| @@ -1,12 +0,0 @@ | ||||
| { | ||||
|     "source": { | ||||
|         "include": [ | ||||
|             "src/" | ||||
|         ], | ||||
|         "includePattern": "src/.+\\.js$", | ||||
|         "excludePattern": ".+\\Spec\\.js$|lib/.+" | ||||
|     }, | ||||
|     "plugins": [ | ||||
|         "plugins/markdown" | ||||
|     ] | ||||
| } | ||||
| @@ -23,14 +23,32 @@ | ||||
| /*global module,process*/ | ||||
|  | ||||
| module.exports = (config) => { | ||||
|     const webpackConfig = require('./webpack.coverage.js'); | ||||
|     let webpackConfig; | ||||
|     let browsers; | ||||
|     let singleRun; | ||||
|  | ||||
|     if (process.env.KARMA_DEBUG) { | ||||
|         webpackConfig = require('./webpack.dev.js'); | ||||
|         browsers = ['ChromeDebugging']; | ||||
|         singleRun = false; | ||||
|     } else { | ||||
|         webpackConfig = require('./webpack.coverage.js'); | ||||
|         browsers = ['ChromeHeadless']; | ||||
|         singleRun = true; | ||||
|     } | ||||
|  | ||||
|     delete webpackConfig.output; | ||||
|     // karma doesn't support webpack entry | ||||
|     delete webpackConfig.entry; | ||||
|  | ||||
|     config.set({ | ||||
|         basePath: '', | ||||
|         frameworks: ['jasmine'], | ||||
|         frameworks: ['jasmine', 'webpack'], | ||||
|         files: [ | ||||
|             'indexTest.js', | ||||
|             // included means: should the files be included in the browser using <script> tag? | ||||
|             // We don't want them as a <script> because the shared worker source | ||||
|             // needs loaded remotely by the shared worker process. | ||||
|             { | ||||
|                 pattern: 'dist/couchDBChangesFeed.js*', | ||||
|                 included: false | ||||
| @@ -46,7 +64,7 @@ module.exports = (config) => { | ||||
|         ], | ||||
|         port: 9876, | ||||
|         reporters: ['spec', 'junit', 'coverage-istanbul'], | ||||
|         browsers: [process.env.NODE_ENV === 'debug' ? 'ChromeDebugging' : 'ChromeHeadless'], | ||||
|         browsers, | ||||
|         client: { | ||||
|             jasmine: { | ||||
|                 random: false, | ||||
| @@ -70,6 +88,7 @@ module.exports = (config) => { | ||||
|         }, | ||||
|         coverageIstanbulReporter: { | ||||
|             fixWebpackSourcePaths: true, | ||||
|             skipFilesWithNoCoverage: true, | ||||
|             dir: "coverage/unit", //Sets coverage file to be consumed by codecov.io | ||||
|             reports: ['lcovonly'] | ||||
|         }, | ||||
| @@ -90,7 +109,7 @@ module.exports = (config) => { | ||||
|             stats: 'errors-warnings' | ||||
|         }, | ||||
|         concurrency: 1, | ||||
|         singleRun: true, | ||||
|         singleRun, | ||||
|         browserNoActivityTimeout: 400000 | ||||
|     }); | ||||
| }; | ||||
|   | ||||
| @@ -1,96 +0,0 @@ | ||||
| --- | ||||
| ci: | ||||
|   collect: | ||||
|     urls: | ||||
|     - http://localhost/ | ||||
|     numberOfRuns: 5 | ||||
|     settings: | ||||
|       onlyCategories: | ||||
|       - performance | ||||
|       - best-practices | ||||
|   upload: | ||||
|     target: temporary-public-storage | ||||
|   assert: | ||||
|     preset: lighthouse:recommended | ||||
|     assertions: | ||||
|       ### Applicable assertions | ||||
|       bootup-time: | ||||
|       - warn | ||||
|       - minScore: 0.88 #Original value was calculated at 0.88 | ||||
|       dom-size:  | ||||
|       - error | ||||
|       - maxNumericValue: 200 #Original value was calculated at 188 | ||||
|       first-contentful-paint: | ||||
|       - error | ||||
|       - minScore: 0.07 #Original value was calculated at 0.08 | ||||
|       mainthread-work-breakdown: | ||||
|       - warn | ||||
|       - minScore: 0.8 #Original value was calculated at 0.8 | ||||
|       unused-javascript: | ||||
|       - warn | ||||
|       - maxLength: 1 | ||||
|       - error | ||||
|       - maxNumericValue: 2000 #Original value was calculated at 1855 | ||||
|       unused-css-rules: warn | ||||
|       installable-manifest: warn | ||||
|       service-worker: warn | ||||
|       ### Disabled seo, accessibility, and pwa assertions, below | ||||
|       categories:seo: 'off' | ||||
|       categories:accessibility: 'off' | ||||
|       categories:pwa: 'off' | ||||
|       accesskeys: 'off' | ||||
|       apple-touch-icon: 'off' | ||||
|       aria-allowed-attr: 'off' | ||||
|       aria-command-name: 'off' | ||||
|       aria-hidden-body: 'off' | ||||
|       aria-hidden-focus: 'off' | ||||
|       aria-input-field-name: 'off' | ||||
|       aria-meter-name: 'off' | ||||
|       aria-progressbar-name: 'off' | ||||
|       aria-required-attr: 'off' | ||||
|       aria-required-children: 'off' | ||||
|       aria-required-parent: 'off' | ||||
|       aria-roles: 'off' | ||||
|       aria-toggle-field-name: 'off' | ||||
|       aria-tooltip-name: 'off' | ||||
|       aria-treeitem-name: 'off' | ||||
|       aria-valid-attr: 'off' | ||||
|       aria-valid-attr-value: 'off' | ||||
|       button-name: 'off' | ||||
|       bypass: 'off' | ||||
|       canonical: 'off' | ||||
|       color-contrast: 'off' | ||||
|       content-width: 'off' | ||||
|       crawlable-anchors: 'off' | ||||
|       csp-xss: 'off' | ||||
|       font-display: 'off' | ||||
|       font-size: 'off' | ||||
|       maskable-icon: 'off' | ||||
|       heading-order: 'off' | ||||
|       hreflang: 'off' | ||||
|       html-has-lang: 'off' | ||||
|       html-lang-valid: 'off' | ||||
|       http-status-code: 'off' | ||||
|       image-alt: 'off' | ||||
|       input-image-alt: 'off' | ||||
|       is-crawlable: 'off' | ||||
|       label: 'off' | ||||
|       link-name: 'off' | ||||
|       link-text: 'off' | ||||
|       list: 'off' | ||||
|       listitem: 'off' | ||||
|       meta-description: 'off' | ||||
|       meta-refresh: 'off' | ||||
|       meta-viewport: 'off' | ||||
|       object-alt: 'off' | ||||
|       plugins: 'off' | ||||
|       robots-txt: 'off' | ||||
|       splash-screen: 'off' | ||||
|       tabindex: 'off' | ||||
|       tap-targets: 'off' | ||||
|       td-headers-attr: 'off' | ||||
|       th-has-data-cells: 'off' | ||||
|       themed-omnibox: 'off' | ||||
|       valid-lang: 'off' | ||||
|       video-caption: 'off' | ||||
|       viewport: 'off' | ||||
							
								
								
									
										45
									
								
								openmct.js
									
									
									
									
									
								
							
							
						
						| @@ -30,8 +30,53 @@ if (document.currentScript) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} BuildInfo | ||||
|  * @property {string} version | ||||
|  * @property {string} buildDate | ||||
|  * @property {string} revision | ||||
|  * @property {string} branch | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} OpenMCT | ||||
|  * @property {BuildInfo} buildInfo | ||||
|  * @property {*} selection | ||||
|  * @property {import('./src/api/time/TimeAPI').default} time | ||||
|  * @property {import('./src/api/composition/CompositionAPI').default} composition | ||||
|  * @property {*} objectViews | ||||
|  * @property {*} inspectorViews | ||||
|  * @property {*} propertyEditors | ||||
|  * @property {*} toolbars | ||||
|  * @property {*} types | ||||
|  * @property {import('./src/api/objects/ObjectAPI').default} objects | ||||
|  * @property {import('./src/api/telemetry/TelemetryAPI').default} telemetry | ||||
|  * @property {import('./src/api/indicators/IndicatorAPI').default} indicators | ||||
|  * @property {import('./src/api/user/UserAPI').default} user | ||||
|  * @property {import('./src/api/notifications/NotificationAPI').default} notifications | ||||
|  * @property {import('./src/api/Editor').default} editor | ||||
|  * @property {import('./src/api/overlays/OverlayAPI')} overlays | ||||
|  * @property {import('./src/api/menu/MenuAPI').default} menus | ||||
|  * @property {import('./src/api/actions/ActionsAPI').default} actions | ||||
|  * @property {import('./src/api/status/StatusAPI').default} status | ||||
|  * @property {*} priority | ||||
|  * @property {import('./src/ui/router/ApplicationRouter')} router | ||||
|  * @property {import('./src/api/faultmanagement/FaultManagementAPI').default} faults | ||||
|  * @property {import('./src/api/forms/FormsAPI').default} forms | ||||
|  * @property {import('./src/api/Branding').default} branding | ||||
|  * @property {import('./src/api/annotation/AnnotationAPI').default} annotation | ||||
|  * @property {{(plugin: OpenMCTPlugin) => void}} install | ||||
|  * @property {{() => string}} getAssetPath | ||||
|  * @property {{(domElement: HTMLElement, isHeadlessMode: boolean) => void}} start | ||||
|  * @property {{() => void}} startHeadless | ||||
|  * @property {{() => void}} destroy | ||||
|  * @property {OpenMCTPlugin[]} plugins | ||||
|  * @property {OpenMCTComponent[]} components | ||||
|  */ | ||||
|  | ||||
| const MCT = require('./src/MCT'); | ||||
|  | ||||
| /** @type {OpenMCT} */ | ||||
| const openmct = new MCT(); | ||||
|  | ||||
| module.exports = openmct; | ||||
|   | ||||
							
								
								
									
										67
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,40 +1,35 @@ | ||||
| { | ||||
|   "name": "openmct", | ||||
|   "version": "2.1.1-SNAPSHOT", | ||||
|   "version": "2.1.4-SNAPSHOT", | ||||
|   "description": "The Open MCT core platform", | ||||
|   "devDependencies": { | ||||
|     "@babel/eslint-parser": "7.18.9", | ||||
|     "@braintree/sanitize-url": "6.0.0", | ||||
|     "@percy/cli": "1.10.3", | ||||
|     "@braintree/sanitize-url": "6.0.2", | ||||
|     "@percy/cli": "1.16.0", | ||||
|     "@percy/playwright": "1.0.4", | ||||
|     "@playwright/test": "1.25.2", | ||||
|     "@types/eventemitter3": "^1.0.0", | ||||
|     "@types/jasmine": "^4.0.1", | ||||
|     "@types/karma": "^6.3.2", | ||||
|     "@types/lodash": "^4.14.178", | ||||
|     "@types/mocha": "^9.1.0", | ||||
|     "babel-loader": "8.2.5", | ||||
|     "@types/jasmine": "4.3.0", | ||||
|     "@types/lodash": "4.14.189", | ||||
|     "babel-loader": "9.0.0", | ||||
|     "babel-plugin-istanbul": "6.1.1", | ||||
|     "codecov": "3.8.3", | ||||
|     "comma-separated-values": "3.6.4", | ||||
|     "codecov":"3.8.3", | ||||
|     "copy-webpack-plugin": "11.0.0", | ||||
|     "cross-env": "7.0.3", | ||||
|     "css-loader": "6.7.1", | ||||
|     "d3-axis": "3.0.0", | ||||
|     "d3-scale": "3.3.0", | ||||
|     "d3-selection": "3.0.0", | ||||
|     "eslint": "8.23.1", | ||||
|     "eslint": "8.27.0", | ||||
|     "eslint-plugin-compat": "4.0.2", | ||||
|     "eslint-plugin-playwright": "0.11.2", | ||||
|     "eslint-plugin-vue": "9.3.0", | ||||
|     "eslint-plugin-vue": "9.7.0", | ||||
|     "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", | ||||
|     "eventemitter3": "1.2.0", | ||||
|     "express": "4.13.1", | ||||
|     "file-saver": "2.0.5", | ||||
|     "git-rev-sync": "3.0.2", | ||||
|     "html2canvas": "1.4.1", | ||||
|     "imports-loader": "4.0.1", | ||||
|     "jasmine-core": "4.4.0", | ||||
|     "jasmine-core": "4.5.0", | ||||
|     "karma": "6.3.20", | ||||
|     "karma-chrome-launcher": "3.1.1", | ||||
|     "karma-cli": "2.0.0", | ||||
| @@ -50,42 +45,43 @@ | ||||
|     "mini-css-extract-plugin": "2.6.1", | ||||
|     "moment": "2.29.4", | ||||
|     "moment-duration-format": "2.3.2", | ||||
|     "moment-timezone": "0.5.37", | ||||
|     "nyc":"15.1.0", | ||||
|     "moment-timezone": "0.5.38", | ||||
|     "nyc": "15.1.0", | ||||
|     "painterro": "1.2.78", | ||||
|     "playwright-core": "1.25.2", | ||||
|     "plotly.js-basic-dist": "2.14.0", | ||||
|     "plotly.js-gl2d-dist": "2.14.0", | ||||
|     "printj": "1.3.1", | ||||
|     "request": "2.88.2", | ||||
|     "resolve-url-loader": "5.0.0", | ||||
|     "sass": "1.55.0", | ||||
|     "sass": "1.56.1", | ||||
|     "sass-loader": "13.0.2", | ||||
|     "sinon": "14.0.0", | ||||
|     "sinon": "14.0.1", | ||||
|     "style-loader": "^3.3.1", | ||||
|     "typescript": "4.9.3", | ||||
|     "uuid": "9.0.0", | ||||
|     "vue": "2.6.14", | ||||
|     "vue-eslint-parser": "9.1.0", | ||||
|     "vue-loader": "15.9.8", | ||||
|     "vue-template-compiler": "2.6.14", | ||||
|     "webpack": "5.74.0", | ||||
|     "webpack-cli": "4.10.0", | ||||
|     "webpack-dev-middleware": "5.3.3", | ||||
|     "webpack-hot-middleware": "2.25.2", | ||||
|     "webpack-cli": "5.0.0", | ||||
|     "webpack-dev-server": "4.11.1", | ||||
|     "webpack-merge": "5.8.0" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "clean": "rm -rf ./dist ./node_modules ./package-lock.json", | ||||
|     "clean-test-lint": "npm run clean; npm install; npm run test; npm run lint", | ||||
|     "start": "node app.js", | ||||
|     "start": "npx webpack serve --config ./webpack.dev.js", | ||||
|     "start:coverage": "npx webpack serve --config ./webpack.coverage.js", | ||||
|     "lint": "eslint example src e2e --ext .js,.vue openmct.js --max-warnings=0", | ||||
|     "lint:fix": "eslint example src e2e --ext .js,.vue openmct.js --fix", | ||||
|     "build:prod": "cross-env webpack --config webpack.prod.js", | ||||
|     "build:prod": "webpack --config webpack.prod.js", | ||||
|     "build:dev": "webpack --config webpack.dev.js", | ||||
|     "build:coverage": "webpack --config webpack.coverage.js", | ||||
|     "build:watch": "webpack --config webpack.dev.js --watch", | ||||
|     "info": "npx envinfo --system --browsers --npmPackages --binaries --languages --markdown", | ||||
|     "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run", | ||||
|     "test:debug": "cross-env NODE_ENV=debug karma start --no-single-run", | ||||
|     "test": "karma start", | ||||
|     "test:debug": "KARMA_DEBUG=true karma start", | ||||
|     "test:e2e": "npx playwright test", | ||||
|     "test:e2e:couchdb": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @couchdb", | ||||
|     "test:e2e:stable": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep-invert \"@unstable|@couchdb\"", | ||||
| @@ -95,14 +91,13 @@ | ||||
|     "test:e2e:visual": "percy exec --config ./e2e/.percy.yml -- npx playwright test --config=e2e/playwright-visual.config.js --grep-invert @unstable", | ||||
|     "test:e2e:full": "npx playwright test --config=e2e/playwright-ci.config.js --grep-invert @couchdb", | ||||
|     "test:perf": "npx playwright test --config=e2e/playwright-performance.config.js", | ||||
|     "test:watch": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --no-single-run", | ||||
|     "update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2022/gm' ./src/ui/layout/AboutDialog.vue", | ||||
|     "update-copyright-date": "npm run update-about-dialog-copyright && grep -lr --null --include=*.{js,scss,vue,ts,sh,html,md,frag} 'Copyright (c) 20' . | xargs -r0 perl -pi -e 's/Copyright\\s\\(c\\)\\s20\\d\\d\\-20\\d\\d/Copyright \\(c\\)\\ 2014\\-2022/gm'", | ||||
|     "cov:e2e:report":"nyc report --reporter=lcovonly --report-dir=./coverage/e2e", | ||||
|     "cov:e2e:full:publish":"codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-full", | ||||
|     "cov:e2e:stable:publish":"codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-stable", | ||||
|     "cov:unit:publish":"codecov --disable=gcov -f ./coverage/unit/lcov.info -F unit", | ||||
|     "prepare": "npm run build:prod" | ||||
|     "cov:e2e:report": "nyc report --reporter=lcovonly --report-dir=./coverage/e2e", | ||||
|     "cov:e2e:full:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-full", | ||||
|     "cov:e2e:stable:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-stable", | ||||
|     "cov:unit:publish": "codecov --disable=gcov -f ./coverage/unit/lcov.info -F unit", | ||||
|     "prepare": "npm run build:prod && npx tsc" | ||||
|   }, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
| @@ -111,9 +106,6 @@ | ||||
|   "engines": { | ||||
|     "node": ">=14.19.1" | ||||
|   }, | ||||
|   "overrides": { | ||||
|     "core-js": "3.21.1" | ||||
|   }, | ||||
|   "browserslist": [ | ||||
|     "Firefox ESR", | ||||
|     "not IE 11", | ||||
| @@ -122,6 +114,5 @@ | ||||
|     "ios_saf > 15" | ||||
|   ], | ||||
|   "author": "", | ||||
|   "license": "Apache-2.0", | ||||
|   "private": true | ||||
|   "license": "Apache-2.0" | ||||
| } | ||||
|   | ||||
							
								
								
									
										10
									
								
								src/MCT.js
									
									
									
									
									
								
							
							
						
						| @@ -19,7 +19,7 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* eslint-disable no-undef */ | ||||
| define([ | ||||
|     'EventEmitter', | ||||
|     './api/api', | ||||
| @@ -81,13 +81,11 @@ define([ | ||||
|     /** | ||||
|      * The Open MCT application. This may be configured by installing plugins | ||||
|      * or registering extensions before the application is started. | ||||
|      * @class MCT | ||||
|      * @constructor | ||||
|      * @memberof module:openmct | ||||
|      * @augments {EventEmitter} | ||||
|      */ | ||||
|     function MCT() { | ||||
|         EventEmitter.call(this); | ||||
|         /* eslint-disable no-undef */ | ||||
|         this.buildInfo = { | ||||
|             version: __OPENMCT_VERSION__, | ||||
|             buildDate: __OPENMCT_BUILD_DATE__, | ||||
| @@ -101,7 +99,7 @@ define([ | ||||
|             * Tracks current selection state of the application. | ||||
|             * @private | ||||
|             */ | ||||
|             ['selection', () => new Selection(this)], | ||||
|             ['selection', () => new Selection.default(this)], | ||||
|  | ||||
|             /** | ||||
|              * MCT's time conductor, which may be used to synchronize view contents | ||||
| @@ -125,7 +123,7 @@ define([ | ||||
|              * @memberof module:openmct.MCT# | ||||
|              * @name composition | ||||
|              */ | ||||
|             ['composition', () => new api.CompositionAPI(this)], | ||||
|             ['composition', () => new api.CompositionAPI.default(this)], | ||||
|  | ||||
|             /** | ||||
|              * Registry for views of domain objects which should appear in the | ||||
|   | ||||
| @@ -23,8 +23,7 @@ | ||||
| let brandingOptions = {}; | ||||
|  | ||||
| /** | ||||
|  * @typedef {Object} BrandingOptions | ||||
|  * @memberOf openmct/branding | ||||
|  * @typedef {object} BrandingOptions | ||||
|  * @property {string} smallLogoImage URL to the image to use as the applications logo. | ||||
|  * This logo will appear on every screen and when clicked will launch the about dialog. | ||||
|  * @property {string} aboutHtml Custom content for the about screen. When defined the | ||||
|   | ||||
| @@ -56,18 +56,12 @@ export default class Editor extends EventEmitter { | ||||
|      * Save any unsaved changes from this editing session. This will | ||||
|      * end the current transaction. | ||||
|      */ | ||||
|     save() { | ||||
|     async save() { | ||||
|         const transaction = this.openmct.objects.getActiveTransaction(); | ||||
|  | ||||
|         return transaction.commit() | ||||
|             .then(() => { | ||||
|                 this.editing = false; | ||||
|                 this.emit('isEditing', false); | ||||
|             }).catch(error => { | ||||
|                 throw error; | ||||
|             }).finally(() => { | ||||
|                 this.openmct.objects.endTransaction(); | ||||
|             }); | ||||
|         await transaction.commit(); | ||||
|         this.editing = false; | ||||
|         this.emit('isEditing', false); | ||||
|         this.openmct.objects.endTransaction(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
							
								
								
									
										80
									
								
								src/api/EditorSpec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| import { | ||||
|     createOpenMct, resetApplicationState | ||||
| } from '../utils/testing'; | ||||
|  | ||||
| describe('The Editor API', () => { | ||||
|     let openmct; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.on('start', done); | ||||
|  | ||||
|         spyOn(openmct.objects, 'endTransaction'); | ||||
|  | ||||
|         openmct.startHeadless(); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     it('opens a transaction on edit', () => { | ||||
|         expect( | ||||
|             openmct.objects.isTransactionActive() | ||||
|         ).toBeFalse(); | ||||
|         openmct.editor.edit(); | ||||
|         expect( | ||||
|             openmct.objects.isTransactionActive() | ||||
|         ).toBeTrue(); | ||||
|     }); | ||||
|  | ||||
|     it('closes an open transaction on successful save', async () => { | ||||
|         spyOn(openmct.objects, 'getActiveTransaction') | ||||
|             .and.returnValue({ | ||||
|                 commit: () => Promise.resolve(true) | ||||
|             }); | ||||
|  | ||||
|         openmct.editor.edit(); | ||||
|         await openmct.editor.save(); | ||||
|  | ||||
|         expect( | ||||
|             openmct.objects.endTransaction | ||||
|         ).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('does not close an open transaction on failed save', async () => { | ||||
|         spyOn(openmct.objects, 'getActiveTransaction') | ||||
|             .and.returnValue({ | ||||
|                 commit: () => Promise.reject() | ||||
|             }); | ||||
|  | ||||
|         openmct.editor.edit(); | ||||
|         await openmct.editor.save().catch(() => {}); | ||||
|  | ||||
|         expect( | ||||
|             openmct.objects.endTransaction | ||||
|         ).not.toHaveBeenCalled(); | ||||
|     }); | ||||
| }); | ||||
| @@ -22,6 +22,7 @@ | ||||
|  | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import EventEmitter from 'EventEmitter'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| /** | ||||
|  * @readonly | ||||
| @@ -42,19 +43,28 @@ const ANNOTATION_TYPES = Object.freeze({ | ||||
|  | ||||
| const ANNOTATION_TYPE = 'annotation'; | ||||
|  | ||||
| const ANNOTATION_LAST_CREATED = 'annotationLastCreated'; | ||||
|  | ||||
| /** | ||||
|  * @typedef {Object} Tag | ||||
|  * @property {String} key a unique identifier for the tag | ||||
|  * @property {String} backgroundColor eg. "#cc0000" | ||||
|  * @property {String} foregroundColor eg. "#ffffff" | ||||
|  */ | ||||
|  | ||||
| export default class AnnotationAPI extends EventEmitter { | ||||
|  | ||||
|     /** | ||||
|      * @param {OpenMCT} openmct | ||||
|      */ | ||||
|     constructor(openmct) { | ||||
|         super(); | ||||
|         this.openmct = openmct; | ||||
|         this.availableTags = {}; | ||||
|  | ||||
|         this.ANNOTATION_TYPES = ANNOTATION_TYPES; | ||||
|         this.ANNOTATION_TYPE = ANNOTATION_TYPE; | ||||
|         this.ANNOTATION_LAST_CREATED = ANNOTATION_LAST_CREATED; | ||||
|  | ||||
|         this.openmct.types.addType(ANNOTATION_TYPE, { | ||||
|             name: 'Annotation', | ||||
| @@ -63,6 +73,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|             cssClass: 'icon-notebook', | ||||
|             initialize: function (domainObject) { | ||||
|                 domainObject.targets = domainObject.targets || {}; | ||||
|                 domainObject._deleted = domainObject._deleted || false; | ||||
|                 domainObject.originalContextPath = domainObject.originalContextPath || ''; | ||||
|                 domainObject.tags = domainObject.tags || []; | ||||
|                 domainObject.contentText = domainObject.contentText || ''; | ||||
| @@ -112,6 +123,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|                 namespace | ||||
|             }, | ||||
|             tags, | ||||
|             _deleted: false, | ||||
|             annotationType, | ||||
|             contentText, | ||||
|             originalContextPath | ||||
| @@ -127,6 +139,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         const success = await this.openmct.objects.save(createdObject); | ||||
|         if (success) { | ||||
|             this.emit('annotationCreated', createdObject); | ||||
|             this.#updateAnnotationModified(domainObject); | ||||
|  | ||||
|             return createdObject; | ||||
|         } else { | ||||
| @@ -134,14 +147,32 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #updateAnnotationModified(domainObject) { | ||||
|         this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method defineTag | ||||
|     * @param {String} key a unique identifier for the tag | ||||
|     * @param {Tag} tagsDefinition the definition of the tag to add | ||||
|     */ | ||||
|     defineTag(tagKey, tagsDefinition) { | ||||
|         this.availableTags[tagKey] = tagsDefinition; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method isAnnotation | ||||
|     * @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question | ||||
|     * @returns {Boolean} Returns true if the domain object is an annotation | ||||
|     */ | ||||
|     isAnnotation(domainObject) { | ||||
|         return domainObject && (domainObject.type === ANNOTATION_TYPE); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method getAvailableTags | ||||
|     * @returns {Tag[]} Returns an array of the available tags that have been loaded | ||||
|     */ | ||||
|     getAvailableTags() { | ||||
|         if (this.availableTags) { | ||||
|             const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => { | ||||
| @@ -157,18 +188,26 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async getAnnotation(query, searchType) { | ||||
|         let foundAnnotation = null; | ||||
|     /** | ||||
|     * @method getAnnotations | ||||
|     * @param {String} query - The keystring of the domain object to search for annotations for | ||||
|     * @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query | ||||
|     */ | ||||
|     async getAnnotations(query) { | ||||
|         const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat(); | ||||
|  | ||||
|         const searchResults = (await Promise.all(this.openmct.objects.search(query, null, searchType))).flat(); | ||||
|         if (searchResults) { | ||||
|             foundAnnotation = searchResults[0]; | ||||
|         } | ||||
|  | ||||
|         return foundAnnotation; | ||||
|         return searchResults; | ||||
|     } | ||||
|  | ||||
|     async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) { | ||||
|     /** | ||||
|     * @method addSingleAnnotationTag | ||||
|     * @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation. | ||||
|     * @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to. | ||||
|     * @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID | ||||
|     * @param {AnnotationType} annotationType - The type of annotation this is for. | ||||
|     * @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation | ||||
|     */ | ||||
|     async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) { | ||||
|         if (!existingAnnotation) { | ||||
|             const targets = {}; | ||||
|             const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier); | ||||
| @@ -186,27 +225,44 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|  | ||||
|             return newAnnotation; | ||||
|         } else { | ||||
|             const tagArray = [tag, ...existingAnnotation.tags]; | ||||
|             this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray); | ||||
|             if (!existingAnnotation.tags.includes(tag)) { | ||||
|                 throw new Error(`Existing annotation did not contain tag ${tag}`); | ||||
|             } | ||||
|  | ||||
|             if (existingAnnotation._deleted) { | ||||
|                 this.unDeleteAnnotation(existingAnnotation); | ||||
|             } | ||||
|  | ||||
|             return existingAnnotation; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     removeAnnotationTag(existingAnnotation, tagToRemove) { | ||||
|         if (existingAnnotation && existingAnnotation.tags.includes(tagToRemove)) { | ||||
|             const cleanedArray = existingAnnotation.tags.filter(extantTag => extantTag !== tagToRemove); | ||||
|             this.openmct.objects.mutate(existingAnnotation, 'tags', cleanedArray); | ||||
|         } else { | ||||
|             throw new Error(`Asked to remove tag (${tagToRemove}) that doesn't exist`, existingAnnotation); | ||||
|     /** | ||||
|     * @method deleteAnnotations | ||||
|     * @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true) | ||||
|     */ | ||||
|     deleteAnnotations(annotations) { | ||||
|         if (!annotations) { | ||||
|             throw new Error('Asked to delete null annotations! 🙅♂️'); | ||||
|         } | ||||
|  | ||||
|         annotations.forEach(annotation => { | ||||
|             if (!annotation._deleted) { | ||||
|                 this.openmct.objects.mutate(annotation, '_deleted', true); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     removeAnnotationTags(existingAnnotation) { | ||||
|         // just removes tags on the annotation as we can't really delete objects | ||||
|         if (existingAnnotation && existingAnnotation.tags) { | ||||
|             this.openmct.objects.mutate(existingAnnotation, 'tags', []); | ||||
|     /** | ||||
|     * @method deleteAnnotations | ||||
|     * @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false) | ||||
|     */ | ||||
|     unDeleteAnnotation(annotation) { | ||||
|         if (!annotation) { | ||||
|             throw new Error('Asked to undelete null annotation! 🙅♂️'); | ||||
|         } | ||||
|  | ||||
|         this.openmct.objects.mutate(annotation, '_deleted', false); | ||||
|     } | ||||
|  | ||||
|     #getMatchingTags(query) { | ||||
| @@ -266,16 +322,40 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         return modelAddedToResults; | ||||
|     } | ||||
|  | ||||
|     #combineSameTargets(results) { | ||||
|         const combinedResults = []; | ||||
|         results.forEach(currentAnnotation => { | ||||
|             const existingAnnotation = combinedResults.find((annotationToFind) => { | ||||
|                 return _.isEqual(currentAnnotation.targets, annotationToFind.targets); | ||||
|             }); | ||||
|             if (!existingAnnotation) { | ||||
|                 combinedResults.push(currentAnnotation); | ||||
|             } else { | ||||
|                 existingAnnotation.tags.push(...currentAnnotation.tags); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         return combinedResults; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method searchForTags | ||||
|     * @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving" | ||||
|     * @param {Object} abortController An optional abort method to stop the query | ||||
|     * @param {Object} [abortController] An optional abort method to stop the query | ||||
|     * @returns {Promise} returns a model of matching tags with their target domain objects attached | ||||
|     */ | ||||
|     async searchForTags(query, abortController) { | ||||
|         const matchingTagKeys = this.#getMatchingTags(query); | ||||
|         if (!matchingTagKeys.length) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat(); | ||||
|         const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys); | ||||
|         const filteredDeletedResults = searchResults.filter((result) => { | ||||
|             return !(result._deleted); | ||||
|         }); | ||||
|         const combinedSameTargets = this.#combineSameTargets(filteredDeletedResults); | ||||
|         const appliedTagSearchResults = this.#addTagMetaInformationToResults(combinedSameTargets, matchingTagKeys); | ||||
|         const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults); | ||||
|         const resultsWithValidPath = appliedTargetsModels.filter(result => { | ||||
|             return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath); | ||||
|   | ||||
| @@ -94,7 +94,6 @@ describe("The Annotation API", () => { | ||||
|         openmct.startHeadless(); | ||||
|     }); | ||||
|     afterEach(async () => { | ||||
|         openmct.objects.providers = {}; | ||||
|         await resetApplicationState(openmct); | ||||
|     }); | ||||
|     it("is defined", () => { | ||||
| @@ -126,34 +125,44 @@ describe("The Annotation API", () => { | ||||
|  | ||||
|     describe("Tagging", () => { | ||||
|         it("can create a tag", async () => { | ||||
|             const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(annotationObject.type).toEqual('annotation'); | ||||
|             expect(annotationObject.tags).toContain('aWonderfulTag'); | ||||
|         }); | ||||
|         it("can delete a tag", async () => { | ||||
|             const originalAnnotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             const annotationObject = await openmct.annotation.addAnnotationTag(originalAnnotationObject, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'anotherTagToRemove'); | ||||
|             const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove'); | ||||
|             expect(annotationObject.tags).toEqual(['aWonderfulTag']); | ||||
|             openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag'); | ||||
|             expect(annotationObject.tags).toEqual([]); | ||||
|             openmct.annotation.deleteAnnotations([annotationObject]); | ||||
|             expect(annotationObject._deleted).toBeTrue(); | ||||
|         }); | ||||
|         it("throws an error if deleting non-existent tag", async () => { | ||||
|             const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(() => { | ||||
|                 openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist'); | ||||
|             }).toThrow(); | ||||
|         }); | ||||
|         it("can remove all tags", async () => { | ||||
|             const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(() => { | ||||
|                 openmct.annotation.removeAnnotationTags(annotationObject); | ||||
|                 openmct.annotation.deleteAnnotations([annotationObject]); | ||||
|             }).not.toThrow(); | ||||
|             expect(annotationObject.tags).toEqual([]); | ||||
|             expect(annotationObject._deleted).toBeTrue(); | ||||
|         }); | ||||
|         it("can add/delete/add a tag", async () => { | ||||
|             let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(annotationObject.type).toEqual('annotation'); | ||||
|             expect(annotationObject.tags).toContain('aWonderfulTag'); | ||||
|             openmct.annotation.deleteAnnotations([annotationObject]); | ||||
|             expect(annotationObject._deleted).toBeTrue(); | ||||
|             annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(annotationObject.type).toEqual('annotation'); | ||||
|             expect(annotationObject.tags).toContain('aWonderfulTag'); | ||||
|             expect(annotationObject._deleted).toBeFalse(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
| @@ -175,16 +184,10 @@ describe("The Annotation API", () => { | ||||
|             expect(results).toBeDefined(); | ||||
|             expect(results.length).toEqual(1); | ||||
|         }); | ||||
|         it("can get notebook annotations", async () => { | ||||
|             const targetKeyString = openmct.objects.makeKeyString(mockDomainObject.identifier); | ||||
|             const query = { | ||||
|                 targetKeyString, | ||||
|                 entryId: 'fooBarEntry' | ||||
|             }; | ||||
|  | ||||
|             const results = await openmct.annotation.getAnnotation(query, openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS); | ||||
|         it("returns no tags for empty search", async () => { | ||||
|             const results = await openmct.annotation.searchForTags('q'); | ||||
|             expect(results).toBeDefined(); | ||||
|             expect(results.tags.length).toEqual(2); | ||||
|             expect(results.length).toEqual(0); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -37,7 +37,9 @@ define([ | ||||
|     './types/TypeRegistry', | ||||
|     './user/UserAPI', | ||||
|     './annotation/AnnotationAPI' | ||||
| ], function ( | ||||
| ], | ||||
|  | ||||
| function ( | ||||
|     ActionsAPI, | ||||
|     CompositionAPI, | ||||
|     EditorAPI, | ||||
|   | ||||
| @@ -20,34 +20,41 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| define([ | ||||
|     'lodash', | ||||
|     'EventEmitter', | ||||
|     './DefaultCompositionProvider', | ||||
|     './CompositionCollection' | ||||
| ], function ( | ||||
|     _, | ||||
|     EventEmitter, | ||||
|     DefaultCompositionProvider, | ||||
|     CompositionCollection | ||||
| ) { | ||||
| import DefaultCompositionProvider from './DefaultCompositionProvider'; | ||||
| import CompositionCollection from './CompositionCollection'; | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('./CompositionProvider').default} CompositionProvider | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * An interface for interacting with the composition of domain objects. | ||||
|  * The composition of a domain object is the list of other domain objects | ||||
|  * it "contains" (for instance, that should be displayed beneath it | ||||
|  * in the tree.) | ||||
|  * @constructor | ||||
|  */ | ||||
| export default class CompositionAPI { | ||||
|     /** | ||||
|      * An interface for interacting with the composition of domain objects. | ||||
|      * The composition of a domain object is the list of other domain objects | ||||
|      * it "contains" (for instance, that should be displayed beneath it | ||||
|      * in the tree.) | ||||
|      * | ||||
|      * @interface CompositionAPI | ||||
|      * @returns {module:openmct.CompositionCollection} | ||||
|      * @memberof module:openmct | ||||
|      * @param {OpenMCT} publicAPI | ||||
|      */ | ||||
|     function CompositionAPI(publicAPI) { | ||||
|     constructor(publicAPI) { | ||||
|         /** @type {CompositionProvider[]} */ | ||||
|         this.registry = []; | ||||
|         /** @type {CompositionPolicy[]} */ | ||||
|         this.policies = []; | ||||
|         this.addProvider(new DefaultCompositionProvider(publicAPI, this)); | ||||
|         /** @type {OpenMCT} */ | ||||
|         this.publicAPI = publicAPI; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add a composition provider. | ||||
|      * | ||||
| @@ -55,21 +62,19 @@ define([ | ||||
|      * behavior for certain domain objects. | ||||
|      * | ||||
|      * @method addProvider | ||||
|      * @param {module:openmct.CompositionProvider} provider the provider to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      * @param {CompositionProvider} provider the provider to add | ||||
|      */ | ||||
|     CompositionAPI.prototype.addProvider = function (provider) { | ||||
|     addProvider(provider) { | ||||
|         this.registry.unshift(provider); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Retrieve the composition (if any) of this domain object. | ||||
|      * | ||||
|      * @method get | ||||
|      * @returns {module:openmct.CompositionCollection} | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @returns {CompositionCollection} | ||||
|      */ | ||||
|     CompositionAPI.prototype.get = function (domainObject) { | ||||
|     get(domainObject) { | ||||
|         const provider = this.registry.find(p => { | ||||
|             return p.appliesTo(domainObject); | ||||
|         }); | ||||
| @@ -79,8 +84,7 @@ define([ | ||||
|         } | ||||
|  | ||||
|         return new CompositionCollection(domainObject, provider, this.publicAPI); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * A composition policy is a function which either allows or disallows | ||||
|      * placing one object in another's composition. | ||||
| @@ -90,52 +94,51 @@ define([ | ||||
|      * generally be written to return true in the default case. | ||||
|      * | ||||
|      * @callback CompositionPolicy | ||||
|      * @memberof module:openmct.CompositionAPI~ | ||||
|      * @param {module:openmct.DomainObject} containingObject the object which | ||||
|      * @param {DomainObject} containingObject the object which | ||||
|      *        would act as a container | ||||
|      * @param {module:openmct.DomainObject} containedObject the object which | ||||
|      * @param {DomainObject} containedObject the object which | ||||
|      *        would be contained | ||||
|      * @returns {boolean} false if this composition should be disallowed | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Add a composition policy. Composition policies may disallow domain | ||||
|      * objects from containing other domain objects. | ||||
|      * | ||||
|      * @method addPolicy | ||||
|      * @param {module:openmct.CompositionAPI~CompositionPolicy} policy | ||||
|      * @param {CompositionPolicy} policy | ||||
|      *        the policy to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      */ | ||||
|     CompositionAPI.prototype.addPolicy = function (policy) { | ||||
|     addPolicy(policy) { | ||||
|         this.policies.push(policy); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Check whether or not a domain object is allowed to contain another | ||||
|      * domain object. | ||||
|      * | ||||
|      * @private | ||||
|      * @method checkPolicy | ||||
|      * @param {module:openmct.DomainObject} containingObject the object which | ||||
|      * @param {DomainObject} container the object which | ||||
|      *        would act as a container | ||||
|      * @param {module:openmct.DomainObject} containedObject the object which | ||||
|      * @param {DomainObject} containee the object which | ||||
|      *        would be contained | ||||
|      * @returns {boolean} false if this composition should be disallowed | ||||
|  | ||||
|      * @param {module:openmct.CompositionAPI~CompositionPolicy} policy | ||||
|      * @param {CompositionPolicy} policy | ||||
|      *        the policy to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      */ | ||||
|     CompositionAPI.prototype.checkPolicy = function (container, containee) { | ||||
|     checkPolicy(container, containee) { | ||||
|         return this.policies.every(function (policy) { | ||||
|             return policy(container, containee); | ||||
|         }); | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     CompositionAPI.prototype.supportsComposition = function (domainObject) { | ||||
|     /** | ||||
|      * Check whether or not a domainObject supports composition | ||||
|      * | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @returns {boolean} true if the domainObject supports composition | ||||
|      */ | ||||
|     supportsComposition(domainObject) { | ||||
|         return this.get(domainObject) !== undefined; | ||||
|     }; | ||||
|     } | ||||
| } | ||||
|  | ||||
|     return CompositionAPI; | ||||
| }); | ||||
|   | ||||
| @@ -1,325 +1,319 @@ | ||||
| define([ | ||||
|     './CompositionAPI', | ||||
|     './CompositionCollection' | ||||
| ], function ( | ||||
|     CompositionAPI, | ||||
|     CompositionCollection | ||||
| ) { | ||||
| import CompositionAPI from './CompositionAPI'; | ||||
| import CompositionCollection from './CompositionCollection'; | ||||
|  | ||||
|     describe('The Composition API', function () { | ||||
|         let publicAPI; | ||||
|         let compositionAPI; | ||||
|         let topicService; | ||||
|         let mutationTopic; | ||||
| describe('The Composition API', function () { | ||||
|     let publicAPI; | ||||
|     let compositionAPI; | ||||
|     let topicService; | ||||
|     let mutationTopic; | ||||
|  | ||||
|     beforeEach(function () { | ||||
|  | ||||
|         mutationTopic = jasmine.createSpyObj('mutationTopic', [ | ||||
|             'listen' | ||||
|         ]); | ||||
|         topicService = jasmine.createSpy('topicService'); | ||||
|         topicService.and.returnValue(mutationTopic); | ||||
|         publicAPI = {}; | ||||
|         publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [ | ||||
|             'get', | ||||
|             'mutate', | ||||
|             'observe', | ||||
|             'areIdsEqual' | ||||
|         ]); | ||||
|  | ||||
|         publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { | ||||
|             return id1.namespace === id2.namespace && id1.key === id2.key; | ||||
|         }); | ||||
|  | ||||
|         publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ | ||||
|             'checkPolicy' | ||||
|         ]); | ||||
|         publicAPI.composition.checkPolicy.and.returnValue(true); | ||||
|  | ||||
|         publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ | ||||
|             'on' | ||||
|         ]); | ||||
|         publicAPI.objects.get.and.callFake(function (identifier) { | ||||
|             return Promise.resolve({identifier: identifier}); | ||||
|         }); | ||||
|         publicAPI.$injector = jasmine.createSpyObj('$injector', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         publicAPI.$injector.get.and.returnValue(topicService); | ||||
|         compositionAPI = new CompositionAPI(publicAPI); | ||||
|     }); | ||||
|  | ||||
|     it('returns falsy if an object does not support composition', function () { | ||||
|         expect(compositionAPI.get({})).toBeFalsy(); | ||||
|     }); | ||||
|  | ||||
|     describe('default composition', function () { | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|  | ||||
|             mutationTopic = jasmine.createSpyObj('mutationTopic', [ | ||||
|                 'listen' | ||||
|             ]); | ||||
|             topicService = jasmine.createSpy('topicService'); | ||||
|             topicService.and.returnValue(mutationTopic); | ||||
|             publicAPI = {}; | ||||
|             publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [ | ||||
|                 'get', | ||||
|                 'mutate', | ||||
|                 'observe', | ||||
|                 'areIdsEqual' | ||||
|             ]); | ||||
|  | ||||
|             publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { | ||||
|                 return id1.namespace === id2.namespace && id1.key === id2.key; | ||||
|             }); | ||||
|  | ||||
|             publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ | ||||
|                 'checkPolicy' | ||||
|             ]); | ||||
|             publicAPI.composition.checkPolicy.and.returnValue(true); | ||||
|  | ||||
|             publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ | ||||
|                 'on' | ||||
|             ]); | ||||
|             publicAPI.objects.get.and.callFake(function (identifier) { | ||||
|                 return Promise.resolve({identifier: identifier}); | ||||
|             }); | ||||
|             publicAPI.$injector = jasmine.createSpyObj('$injector', [ | ||||
|                 'get' | ||||
|             ]); | ||||
|             publicAPI.$injector.get.and.returnValue(topicService); | ||||
|             compositionAPI = new CompositionAPI(publicAPI); | ||||
|             domainObject = { | ||||
|                 name: 'test folder', | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 composition: [ | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'a' | ||||
|                     }, | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'b' | ||||
|                     }, | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'c' | ||||
|                     } | ||||
|                 ] | ||||
|             }; | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('returns falsy if an object does not support composition', function () { | ||||
|             expect(compositionAPI.get({})).toBeFalsy(); | ||||
|         it('returns composition collection', function () { | ||||
|             expect(composition).toBeDefined(); | ||||
|             expect(composition).toEqual(jasmine.any(CompositionCollection)); | ||||
|         }); | ||||
|  | ||||
|         describe('default composition', function () { | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|         it('correctly reflects composability', function () { | ||||
|             expect(compositionAPI.supportsComposition(domainObject)).toBe(true); | ||||
|             delete domainObject.composition; | ||||
|             expect(compositionAPI.supportsComposition(domainObject)).toBe(false); | ||||
|         }); | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 domainObject = { | ||||
|                     name: 'test folder', | ||||
|         it('loads composition from domain object', function () { | ||||
|             const listener = jasmine.createSpy('addListener'); | ||||
|             composition.on('add', listener); | ||||
|  | ||||
|             return composition.load().then(function () { | ||||
|                 expect(listener.calls.count()).toBe(3); | ||||
|                 expect(listener).toHaveBeenCalledWith({ | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     composition: [ | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'a' | ||||
|                         }, | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'b' | ||||
|                         }, | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'c' | ||||
|                         } | ||||
|                     ] | ||||
|                 }; | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             }); | ||||
|  | ||||
|             it('returns composition collection', function () { | ||||
|                 expect(composition).toBeDefined(); | ||||
|                 expect(composition).toEqual(jasmine.any(CompositionCollection)); | ||||
|             }); | ||||
|  | ||||
|             it('correctly reflects composability', function () { | ||||
|                 expect(compositionAPI.supportsComposition(domainObject)).toBe(true); | ||||
|                 delete domainObject.composition; | ||||
|                 expect(compositionAPI.supportsComposition(domainObject)).toBe(false); | ||||
|             }); | ||||
|  | ||||
|             it('loads composition from domain object', function () { | ||||
|                 const listener = jasmine.createSpy('addListener'); | ||||
|                 composition.on('add', listener); | ||||
|  | ||||
|                 return composition.load().then(function () { | ||||
|                     expect(listener.calls.count()).toBe(3); | ||||
|                     expect(listener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'test', | ||||
|                             key: 'a' | ||||
|                         } | ||||
|                     }); | ||||
|                         key: 'a' | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|             describe('supports reordering of composition', function () { | ||||
|                 let listener; | ||||
|                 beforeEach(function () { | ||||
|                     listener = jasmine.createSpy('reorderListener'); | ||||
|                     composition.on('reorder', listener); | ||||
|         }); | ||||
|         describe('supports reordering of composition', function () { | ||||
|             let listener; | ||||
|             beforeEach(function () { | ||||
|                 listener = jasmine.createSpy('reorderListener'); | ||||
|                 composition.on('reorder', listener); | ||||
|  | ||||
|                     return composition.load(); | ||||
|                 }); | ||||
|                 it('', function () { | ||||
|                     composition.reorder(1, 0); | ||||
|                     let newComposition = | ||||
|                 return composition.load(); | ||||
|             }); | ||||
|             it('', function () { | ||||
|                 composition.reorder(1, 0); | ||||
|                 let newComposition = | ||||
|                         publicAPI.objects.mutate.calls.mostRecent().args[2]; | ||||
|                     let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|                 let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|  | ||||
|                     expect(reorderPlan.oldIndex).toBe(1); | ||||
|                     expect(reorderPlan.newIndex).toBe(0); | ||||
|                     expect(newComposition[0].key).toEqual('b'); | ||||
|                     expect(newComposition[1].key).toEqual('a'); | ||||
|                     expect(newComposition[2].key).toEqual('c'); | ||||
|                 }); | ||||
|                 it('', function () { | ||||
|                     composition.reorder(0, 2); | ||||
|                     let newComposition = | ||||
|                 expect(reorderPlan.oldIndex).toBe(1); | ||||
|                 expect(reorderPlan.newIndex).toBe(0); | ||||
|                 expect(newComposition[0].key).toEqual('b'); | ||||
|                 expect(newComposition[1].key).toEqual('a'); | ||||
|                 expect(newComposition[2].key).toEqual('c'); | ||||
|             }); | ||||
|             it('', function () { | ||||
|                 composition.reorder(0, 2); | ||||
|                 let newComposition = | ||||
|                         publicAPI.objects.mutate.calls.mostRecent().args[2]; | ||||
|                     let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|                 let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|  | ||||
|                     expect(reorderPlan.oldIndex).toBe(0); | ||||
|                     expect(reorderPlan.newIndex).toBe(2); | ||||
|                     expect(newComposition[0].key).toEqual('b'); | ||||
|                     expect(newComposition[1].key).toEqual('c'); | ||||
|                     expect(newComposition[2].key).toEqual('a'); | ||||
|                 expect(reorderPlan.oldIndex).toBe(0); | ||||
|                 expect(reorderPlan.newIndex).toBe(2); | ||||
|                 expect(newComposition[0].key).toEqual('b'); | ||||
|                 expect(newComposition[1].key).toEqual('c'); | ||||
|                 expect(newComposition[2].key).toEqual('a'); | ||||
|             }); | ||||
|         }); | ||||
|         it('supports adding an object to composition', function () { | ||||
|             let addListener = jasmine.createSpy('addListener'); | ||||
|             let mockChildObject = { | ||||
|                 identifier: { | ||||
|                     key: 'mock-key', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             }; | ||||
|             composition.on('add', addListener); | ||||
|             composition.add(mockChildObject); | ||||
|  | ||||
|             expect(domainObject.composition.length).toBe(4); | ||||
|             expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('static custom composition', function () { | ||||
|         let customProvider; | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|             // A simple custom provider, returns the same composition for | ||||
|             // all objects of a given type. | ||||
|             customProvider = { | ||||
|                 appliesTo: function (object) { | ||||
|                     return object.type === 'custom-object-type'; | ||||
|                 }, | ||||
|                 load: function (object) { | ||||
|                     return Promise.resolve([ | ||||
|                         { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     ]); | ||||
|                 }, | ||||
|                 add: jasmine.createSpy('add'), | ||||
|                 remove: jasmine.createSpy('remove') | ||||
|             }; | ||||
|             domainObject = { | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 type: 'custom-object-type' | ||||
|             }; | ||||
|             compositionAPI.addProvider(customProvider); | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('supports listening and loading', function () { | ||||
|             const addListener = jasmine.createSpy('addListener'); | ||||
|             composition.on('add', addListener); | ||||
|  | ||||
|             return composition.load().then(function (children) { | ||||
|                 let listenObject; | ||||
|                 const loadedObject = children[0]; | ||||
|  | ||||
|                 expect(addListener).toHaveBeenCalled(); | ||||
|  | ||||
|                 listenObject = addListener.calls.mostRecent().args[0]; | ||||
|                 expect(listenObject).toEqual(loadedObject); | ||||
|                 expect(loadedObject).toEqual({ | ||||
|                     identifier: { | ||||
|                         namespace: 'custom', | ||||
|                         key: 'thing' | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|             it('supports adding an object to composition', function () { | ||||
|                 let addListener = jasmine.createSpy('addListener'); | ||||
|                 let mockChildObject = { | ||||
|         }); | ||||
|         describe('Calling add or remove', function () { | ||||
|             let mockChildObject; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 mockChildObject = { | ||||
|                     identifier: { | ||||
|                         key: 'mock-key', | ||||
|                         namespace: '' | ||||
|                     } | ||||
|                 }; | ||||
|                 composition.on('add', addListener); | ||||
|                 composition.add(mockChildObject); | ||||
|  | ||||
|                 expect(domainObject.composition.length).toBe(4); | ||||
|                 expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('static custom composition', function () { | ||||
|             let customProvider; | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 // A simple custom provider, returns the same composition for | ||||
|                 // all objects of a given type. | ||||
|                 customProvider = { | ||||
|                     appliesTo: function (object) { | ||||
|                         return object.type === 'custom-object-type'; | ||||
|                     }, | ||||
|                     load: function (object) { | ||||
|                         return Promise.resolve([ | ||||
|                             { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         ]); | ||||
|                     }, | ||||
|                     add: jasmine.createSpy('add'), | ||||
|                     remove: jasmine.createSpy('remove') | ||||
|                 }; | ||||
|                 domainObject = { | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     type: 'custom-object-type' | ||||
|                 }; | ||||
|                 compositionAPI.addProvider(customProvider); | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             }); | ||||
|  | ||||
|             it('supports listening and loading', function () { | ||||
|                 const addListener = jasmine.createSpy('addListener'); | ||||
|                 composition.on('add', addListener); | ||||
|  | ||||
|                 return composition.load().then(function (children) { | ||||
|                     let listenObject; | ||||
|                     const loadedObject = children[0]; | ||||
|  | ||||
|                     expect(addListener).toHaveBeenCalled(); | ||||
|  | ||||
|                     listenObject = addListener.calls.mostRecent().args[0]; | ||||
|                     expect(listenObject).toEqual(loadedObject); | ||||
|                     expect(loadedObject).toEqual({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|             describe('Calling add or remove', function () { | ||||
|                 let mockChildObject; | ||||
|  | ||||
|                 beforeEach(function () { | ||||
|                     mockChildObject = { | ||||
|                         identifier: { | ||||
|                             key: 'mock-key', | ||||
|                             namespace: '' | ||||
|                         } | ||||
|                     }; | ||||
|                     composition.add(mockChildObject); | ||||
|                 }); | ||||
|  | ||||
|                 it('calls add on the provider', function () { | ||||
|                     expect(customProvider.add).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|                 }); | ||||
|  | ||||
|                 it('calls remove on the provider', function () { | ||||
|                     composition.remove(mockChildObject); | ||||
|                     expect(customProvider.remove).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('dynamic custom composition', function () { | ||||
|             let customProvider; | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 // A dynamic provider, loads an empty composition and exposes | ||||
|                 // listener functions. | ||||
|                 customProvider = jasmine.createSpyObj('dynamicProvider', [ | ||||
|                     'appliesTo', | ||||
|                     'load', | ||||
|                     'on', | ||||
|                     'off' | ||||
|                 ]); | ||||
|  | ||||
|                 customProvider.appliesTo.and.returnValue('true'); | ||||
|                 customProvider.load.and.returnValue(Promise.resolve([])); | ||||
|  | ||||
|                 domainObject = { | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     type: 'custom-object-type' | ||||
|                 }; | ||||
|                 compositionAPI.addProvider(customProvider); | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             it('calls add on the provider', function () { | ||||
|                 expect(customProvider.add).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|             }); | ||||
|  | ||||
|             it('supports listening and loading', function () { | ||||
|                 const addListener = jasmine.createSpy('addListener'); | ||||
|                 const removeListener = jasmine.createSpy('removeListener'); | ||||
|                 const addPromise = new Promise(function (resolve) { | ||||
|                     addListener.and.callFake(resolve); | ||||
|                 }); | ||||
|                 const removePromise = new Promise(function (resolve) { | ||||
|                     removeListener.and.callFake(resolve); | ||||
|                 }); | ||||
|  | ||||
|                 composition.on('add', addListener); | ||||
|                 composition.on('remove', removeListener); | ||||
|  | ||||
|                 expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     'add', | ||||
|                     jasmine.any(Function), | ||||
|                     jasmine.any(CompositionCollection) | ||||
|                 ); | ||||
|                 expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     'remove', | ||||
|                     jasmine.any(Function), | ||||
|                     jasmine.any(CompositionCollection) | ||||
|                 ); | ||||
|                 const add = customProvider.on.calls.all()[0].args[2]; | ||||
|                 const remove = customProvider.on.calls.all()[1].args[2]; | ||||
|  | ||||
|                 return composition.load() | ||||
|                     .then(function () { | ||||
|                         expect(addListener).not.toHaveBeenCalled(); | ||||
|                         expect(removeListener).not.toHaveBeenCalled(); | ||||
|                         add({ | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         }); | ||||
|  | ||||
|                         return addPromise; | ||||
|                     }).then(function () { | ||||
|                         expect(addListener).toHaveBeenCalledWith({ | ||||
|                             identifier: { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         }); | ||||
|                         remove(addListener.calls.mostRecent().args[0]); | ||||
|  | ||||
|                         return removePromise; | ||||
|                     }).then(function () { | ||||
|                         expect(removeListener).toHaveBeenCalledWith({ | ||||
|                             identifier: { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         }); | ||||
|                     }); | ||||
|             it('calls remove on the provider', function () { | ||||
|                 composition.remove(mockChildObject); | ||||
|                 expect(customProvider.remove).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('dynamic custom composition', function () { | ||||
|         let customProvider; | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|             // A dynamic provider, loads an empty composition and exposes | ||||
|             // listener functions. | ||||
|             customProvider = jasmine.createSpyObj('dynamicProvider', [ | ||||
|                 'appliesTo', | ||||
|                 'load', | ||||
|                 'on', | ||||
|                 'off' | ||||
|             ]); | ||||
|  | ||||
|             customProvider.appliesTo.and.returnValue('true'); | ||||
|             customProvider.load.and.returnValue(Promise.resolve([])); | ||||
|  | ||||
|             domainObject = { | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 type: 'custom-object-type' | ||||
|             }; | ||||
|             compositionAPI.addProvider(customProvider); | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('supports listening and loading', function () { | ||||
|             const addListener = jasmine.createSpy('addListener'); | ||||
|             const removeListener = jasmine.createSpy('removeListener'); | ||||
|             const addPromise = new Promise(function (resolve) { | ||||
|                 addListener.and.callFake(resolve); | ||||
|             }); | ||||
|             const removePromise = new Promise(function (resolve) { | ||||
|                 removeListener.and.callFake(resolve); | ||||
|             }); | ||||
|  | ||||
|             composition.on('add', addListener); | ||||
|             composition.on('remove', removeListener); | ||||
|  | ||||
|             expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 'add', | ||||
|                 jasmine.any(Function), | ||||
|                 jasmine.any(CompositionCollection) | ||||
|             ); | ||||
|             expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 'remove', | ||||
|                 jasmine.any(Function), | ||||
|                 jasmine.any(CompositionCollection) | ||||
|             ); | ||||
|             const add = customProvider.on.calls.all()[0].args[2]; | ||||
|             const remove = customProvider.on.calls.all()[1].args[2]; | ||||
|  | ||||
|             return composition.load() | ||||
|                 .then(function () { | ||||
|                     expect(addListener).not.toHaveBeenCalled(); | ||||
|                     expect(removeListener).not.toHaveBeenCalled(); | ||||
|                     add({ | ||||
|                         namespace: 'custom', | ||||
|                         key: 'thing' | ||||
|                     }); | ||||
|  | ||||
|                     return addPromise; | ||||
|                 }).then(function () { | ||||
|                     expect(addListener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                     remove(addListener.calls.mostRecent().args[0]); | ||||
|  | ||||
|                     return removePromise; | ||||
|                 }).then(function () { | ||||
|                     expect(removeListener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -20,75 +20,98 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| define([ | ||||
|     'lodash' | ||||
| ], function ( | ||||
|     _ | ||||
| ) { | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} ListenerMap | ||||
|  * @property {Array.<any>} add | ||||
|  * @property {Array.<any>} remove | ||||
|  * @property {Array.<any>} load | ||||
|  * @property {Array.<any>} reorder | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * A CompositionCollection represents the list of domain objects contained | ||||
|  * by another domain object. It provides methods for loading this | ||||
|  * list asynchronously, modifying this list, and listening for changes to | ||||
|  * this list. | ||||
|  * | ||||
|  * Usage: | ||||
|  * ```javascript | ||||
|  *  var myViewComposition = MCT.composition.get(myViewObject); | ||||
|  *  myViewComposition.on('add', addObjectToView); | ||||
|  *  myViewComposition.on('remove', removeObjectFromView); | ||||
|  *  myViewComposition.load(); // will trigger `add` for all loaded objects. | ||||
|  *  ``` | ||||
|  */ | ||||
| export default class CompositionCollection { | ||||
|     domainObject; | ||||
|     #provider; | ||||
|     #publicAPI; | ||||
|     #listeners; | ||||
|     #mutables; | ||||
|     /** | ||||
|      * A CompositionCollection represents the list of domain objects contained | ||||
|      * by another domain object. It provides methods for loading this | ||||
|      * list asynchronously, modifying this list, and listening for changes to | ||||
|      * this list. | ||||
|      * | ||||
|      * Usage: | ||||
|      * ```javascript | ||||
|      *  var myViewComposition = MCT.composition.get(myViewObject); | ||||
|      *  myViewComposition.on('add', addObjectToView); | ||||
|      *  myViewComposition.on('remove', removeObjectFromView); | ||||
|      *  myViewComposition.load(); // will trigger `add` for all loaded objects. | ||||
|      *  ``` | ||||
|      * | ||||
|      * @interface CompositionCollection | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @constructor | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        whose composition will be contained | ||||
|      * @param {module:openmct.CompositionProvider} provider the provider | ||||
|      * @param {import('./CompositionProvider').default} provider the provider | ||||
|      *        to use to retrieve other domain objects | ||||
|      * @param {module:openmct.CompositionAPI} api the composition API, for | ||||
|      * @param {OpenMCT} publicAPI the composition API, for | ||||
|      *        policy checks | ||||
|      * @memberof module:openmct | ||||
|      */ | ||||
|     function CompositionCollection(domainObject, provider, publicAPI) { | ||||
|     constructor(domainObject, provider, publicAPI) { | ||||
|         this.domainObject = domainObject; | ||||
|         this.provider = provider; | ||||
|         this.publicAPI = publicAPI; | ||||
|         this.listeners = { | ||||
|         /** @type {import('./CompositionProvider').default} */ | ||||
|         this.#provider = provider; | ||||
|         /** @type {OpenMCT} */ | ||||
|         this.#publicAPI = publicAPI; | ||||
|         /** @type {ListenerMap} */ | ||||
|         this.#listeners = { | ||||
|             add: [], | ||||
|             remove: [], | ||||
|             load: [], | ||||
|             reorder: [] | ||||
|         }; | ||||
|         this.onProviderAdd = this.onProviderAdd.bind(this); | ||||
|         this.onProviderRemove = this.onProviderRemove.bind(this); | ||||
|         this.mutables = {}; | ||||
|         this.onProviderAdd = this.#onProviderAdd.bind(this); | ||||
|         this.onProviderRemove = this.#onProviderRemove.bind(this); | ||||
|         this.#mutables = {}; | ||||
|  | ||||
|         if (this.domainObject.isMutable) { | ||||
|             this.returnMutables = true; | ||||
|             let unobserve = this.domainObject.$on('$_destroy', () => { | ||||
|                 Object.values(this.mutables).forEach(mutable => { | ||||
|                     this.publicAPI.objects.destroyMutable(mutable); | ||||
|                 Object.values(this.#mutables).forEach(mutable => { | ||||
|                     this.#publicAPI.objects.destroyMutable(mutable); | ||||
|                 }); | ||||
|                 unobserve(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Listen for changes to this composition.  Supports 'add', 'remove', and | ||||
|      * 'load' events. | ||||
|      * | ||||
|      * @param event event to listen for, either 'add', 'remove' or 'load'. | ||||
|      * @param callback to trigger when event occurs. | ||||
|      * @param [context] context to use when invoking callback, optional. | ||||
|      * @param {string} event event to listen for, either 'add', 'remove' or 'load'. | ||||
|      * @param {(...args: any[]) => void} callback to trigger when event occurs. | ||||
|      * @param {any} [context] to use when invoking callback, optional. | ||||
|      */ | ||||
|     CompositionCollection.prototype.on = function (event, callback, context) { | ||||
|         if (!this.listeners[event]) { | ||||
|     on(event, callback, context) { | ||||
|         if (!this.#listeners[event]) { | ||||
|             throw new Error('Event not supported by composition: ' + event); | ||||
|         } | ||||
|  | ||||
|         if (this.provider.on && this.provider.off) { | ||||
|         if (this.#provider.on && this.#provider.off) { | ||||
|             if (event === 'add') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'add', | ||||
|                     this.onProviderAdd, | ||||
| @@ -97,7 +120,7 @@ define([ | ||||
|             } | ||||
|  | ||||
|             if (event === 'remove') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'remove', | ||||
|                     this.onProviderRemove, | ||||
| @@ -106,36 +129,34 @@ define([ | ||||
|             } | ||||
|  | ||||
|             if (event === 'reorder') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'reorder', | ||||
|                     this.onProviderReorder, | ||||
|                     this.#onProviderReorder, | ||||
|                     this | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         this.listeners[event].push({ | ||||
|         this.#listeners[event].push({ | ||||
|             callback: callback, | ||||
|             context: context | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a listener.  Must be called with same exact parameters as | ||||
|      * `off`. | ||||
|      * | ||||
|      * @param event | ||||
|      * @param callback | ||||
|      * @param [context] | ||||
|      * @param {string} event | ||||
|      * @param {(...args: any[]) => void} callback | ||||
|      * @param {any} [context] | ||||
|      */ | ||||
|  | ||||
|     CompositionCollection.prototype.off = function (event, callback, context) { | ||||
|         if (!this.listeners[event]) { | ||||
|     off(event, callback, context) { | ||||
|         if (!this.#listeners[event]) { | ||||
|             throw new Error('Event not supported by composition: ' + event); | ||||
|         } | ||||
|  | ||||
|         const index = this.listeners[event].findIndex(l => { | ||||
|         const index = this.#listeners[event].findIndex(l => { | ||||
|             return l.callback === callback && l.context === context; | ||||
|         }); | ||||
|  | ||||
| @@ -143,125 +164,116 @@ define([ | ||||
|             throw new Error('Tried to remove a listener that does not exist'); | ||||
|         } | ||||
|  | ||||
|         this.listeners[event].splice(index, 1); | ||||
|         if (this.listeners[event].length === 0) { | ||||
|         this.#listeners[event].splice(index, 1); | ||||
|         if (this.#listeners[event].length === 0) { | ||||
|             this._destroy(); | ||||
|  | ||||
|             // Remove provider listener if this is the last callback to | ||||
|             // be removed. | ||||
|             if (this.provider.off && this.provider.on) { | ||||
|             if (this.#provider.off && this.#provider.on) { | ||||
|                 if (event === 'add') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'add', | ||||
|                         this.onProviderAdd, | ||||
|                         this | ||||
|                     ); | ||||
|                 } else if (event === 'remove') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'remove', | ||||
|                         this.onProviderRemove, | ||||
|                         this | ||||
|                     ); | ||||
|                 } else if (event === 'reorder') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'reorder', | ||||
|                         this.onProviderReorder, | ||||
|                         this.#onProviderReorder, | ||||
|                         this | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Add a domain object to this composition. | ||||
|      * | ||||
|      * A call to [load]{@link module:openmct.CompositionCollection#load} | ||||
|      * must have resolved before using this method. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} child the domain object to add | ||||
|      * @param {boolean} skipMutate true if the underlying provider should | ||||
|      *        not be updated | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * @name add | ||||
|      * **TODO:** Remove `skipMutate` parameter. | ||||
|      * | ||||
|      * @param {DomainObject} child the domain object to add | ||||
|      * @param {boolean} skipMutate | ||||
|      * **Intended for internal use ONLY.** | ||||
|      * true if the underlying provider should not be updated. | ||||
|      */ | ||||
|     CompositionCollection.prototype.add = function (child, skipMutate) { | ||||
|     add(child, skipMutate) { | ||||
|         if (!skipMutate) { | ||||
|             if (!this.publicAPI.composition.checkPolicy(this.domainObject, child)) { | ||||
|             if (!this.#publicAPI.composition.checkPolicy(this.domainObject, child)) { | ||||
|                 throw `Object of type ${child.type} cannot be added to object of type ${this.domainObject.type}`; | ||||
|             } | ||||
|  | ||||
|             this.provider.add(this.domainObject, child.identifier); | ||||
|             this.#provider.add(this.domainObject, child.identifier); | ||||
|         } else { | ||||
|             if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) { | ||||
|                 let keyString = this.publicAPI.objects.makeKeyString(child.identifier); | ||||
|             if (this.returnMutables && this.#publicAPI.objects.supportsMutation(child.identifier)) { | ||||
|                 let keyString = this.#publicAPI.objects.makeKeyString(child.identifier); | ||||
|  | ||||
|                 child = this.publicAPI.objects._toMutable(child); | ||||
|                 this.mutables[keyString] = child; | ||||
|                 child = this.#publicAPI.objects.toMutable(child); | ||||
|                 this.#mutables[keyString] = child; | ||||
|             } | ||||
|  | ||||
|             this.emit('add', child); | ||||
|             this.#emit('add', child); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Load the domain objects in this composition. | ||||
|      * | ||||
|      * @returns {Promise.<Array.<module:openmct.DomainObject>>} a promise for | ||||
|      * @param {AbortSignal} abortSignal | ||||
|      * @returns {Promise.<Array.<DomainObject>>} a promise for | ||||
|      *          the domain objects in this composition | ||||
|      * @memberof {module:openmct.CompositionCollection#} | ||||
|      * @name load | ||||
|      */ | ||||
|     CompositionCollection.prototype.load = function (abortSignal) { | ||||
|         this.cleanUpMutables(); | ||||
|  | ||||
|         return this.provider.load(this.domainObject) | ||||
|             .then(function (children) { | ||||
|                 return Promise.all(children.map((c) => this.publicAPI.objects.get(c, abortSignal))); | ||||
|             }.bind(this)) | ||||
|             .then(function (childObjects) { | ||||
|                 childObjects.forEach(c => this.add(c, true)); | ||||
|  | ||||
|                 return childObjects; | ||||
|             }.bind(this)) | ||||
|             .then(function (children) { | ||||
|                 this.emit('load'); | ||||
|  | ||||
|                 return children; | ||||
|             }.bind(this)); | ||||
|     }; | ||||
|     async load(abortSignal) { | ||||
|         this.#cleanUpMutables(); | ||||
|         const children = await this.#provider.load(this.domainObject); | ||||
|         const childObjects = await Promise.all(children.map((c) => this.#publicAPI.objects.get(c, abortSignal))); | ||||
|         childObjects.forEach(c => this.add(c, true)); | ||||
|         this.#emit('load'); | ||||
|  | ||||
|         return childObjects; | ||||
|     } | ||||
|     /** | ||||
|      * Remove a domain object from this composition. | ||||
|      * | ||||
|      * A call to [load]{@link module:openmct.CompositionCollection#load} | ||||
|      * must have resolved before using this method. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} child the domain object to remove | ||||
|      * @param {boolean} skipMutate true if the underlying provider should | ||||
|      *        not be updated | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * **TODO:** Remove `skipMutate` parameter. | ||||
|      * | ||||
|      * @param {DomainObject} child the domain object to remove | ||||
|      * @param {boolean} skipMutate | ||||
|      * **Intended for internal use ONLY.** | ||||
|      * true if the underlying provider should not be updated. | ||||
|      * @name remove | ||||
|      */ | ||||
|     CompositionCollection.prototype.remove = function (child, skipMutate) { | ||||
|     remove(child, skipMutate) { | ||||
|         if (!skipMutate) { | ||||
|             this.provider.remove(this.domainObject, child.identifier); | ||||
|             this.#provider.remove(this.domainObject, child.identifier); | ||||
|         } else { | ||||
|             if (this.returnMutables) { | ||||
|                 let keyString = this.publicAPI.objects.makeKeyString(child); | ||||
|                 if (this.mutables[keyString] !== undefined && this.mutables[keyString].isMutable) { | ||||
|                     this.publicAPI.objects.destroyMutable(this.mutables[keyString]); | ||||
|                     delete this.mutables[keyString]; | ||||
|                 let keyString = this.#publicAPI.objects.makeKeyString(child); | ||||
|                 if (this.#mutables[keyString] !== undefined && this.#mutables[keyString].isMutable) { | ||||
|                     this.#publicAPI.objects.destroyMutable(this.#mutables[keyString]); | ||||
|                     delete this.#mutables[keyString]; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             this.emit('remove', child); | ||||
|             this.#emit('remove', child); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Reorder the domain objects in this composition. | ||||
|      * | ||||
| @@ -270,67 +282,75 @@ define([ | ||||
|      * | ||||
|      * @param {number} oldIndex | ||||
|      * @param {number} newIndex | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * @name remove | ||||
|      */ | ||||
|     CompositionCollection.prototype.reorder = function (oldIndex, newIndex, skipMutate) { | ||||
|         this.provider.reorder(this.domainObject, oldIndex, newIndex); | ||||
|     }; | ||||
|  | ||||
|     reorder(oldIndex, newIndex, _skipMutate) { | ||||
|         this.#provider.reorder(this.domainObject, oldIndex, newIndex); | ||||
|     } | ||||
|     /** | ||||
|      * Handle reorder from provider. | ||||
|      * @private | ||||
|      * Destroy mutationListener | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderReorder = function (reorderMap) { | ||||
|         this.emit('reorder', reorderMap); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handle adds from provider. | ||||
|      * @private | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderAdd = function (childId) { | ||||
|         return this.publicAPI.objects.get(childId).then(function (child) { | ||||
|             this.add(child, true); | ||||
|  | ||||
|             return child; | ||||
|         }.bind(this)); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handle removal from provider. | ||||
|      * @private | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderRemove = function (child) { | ||||
|         this.remove(child, true); | ||||
|     }; | ||||
|  | ||||
|     CompositionCollection.prototype._destroy = function () { | ||||
|     _destroy() { | ||||
|         if (this.mutationListener) { | ||||
|             this.mutationListener(); | ||||
|             delete this.mutationListener; | ||||
|         } | ||||
|     }; | ||||
|     } | ||||
|     /** | ||||
|      * Handle reorder from provider. | ||||
|      * @private | ||||
|      * @param {object} reorderMap | ||||
|      */ | ||||
|     #onProviderReorder(reorderMap) { | ||||
|         this.#emit('reorder', reorderMap); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle adds from provider. | ||||
|      * @private | ||||
|      * @param {import('../objects/ObjectAPI').Identifier} childId | ||||
|      * @returns {DomainObject} | ||||
|      */ | ||||
|     #onProviderAdd(childId) { | ||||
|         return this.#publicAPI.objects.get(childId).then(function (child) { | ||||
|             this.add(child, true); | ||||
|  | ||||
|             return child; | ||||
|         }.bind(this)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle removal from provider. | ||||
|      * @param {DomainObject} child | ||||
|      */ | ||||
|     #onProviderRemove(child) { | ||||
|         this.remove(child, true); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Emit events. | ||||
|      * | ||||
|      * @private | ||||
|      * @param {string} event | ||||
|      * @param {...args.<any>} payload | ||||
|      */ | ||||
|     CompositionCollection.prototype.emit = function (event, ...payload) { | ||||
|         this.listeners[event].forEach(function (l) { | ||||
|     #emit(event, ...payload) { | ||||
|         this.#listeners[event].forEach(function (l) { | ||||
|             if (l.context) { | ||||
|                 l.callback.apply(l.context, payload); | ||||
|             } else { | ||||
|                 l.callback(...payload); | ||||
|             } | ||||
|         }); | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     CompositionCollection.prototype.cleanUpMutables = function () { | ||||
|         Object.values(this.mutables).forEach(mutable => { | ||||
|             this.publicAPI.objects.destroyMutable(mutable); | ||||
|     /** | ||||
|      * Destroy all mutables. | ||||
|      * @private | ||||
|      */ | ||||
|     #cleanUpMutables() { | ||||
|         Object.values(this.#mutables).forEach(mutable => { | ||||
|             this.#publicAPI.objects.destroyMutable(mutable); | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     return CompositionCollection; | ||||
| }); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										262
									
								
								src/api/composition/CompositionProvider.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,262 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| import _ from 'lodash'; | ||||
| import objectUtils from "../objects/object-utils"; | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../objects/ObjectAPI').Identifier} Identifier | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * A CompositionProvider provides the underlying implementation of | ||||
|   * composition-related behavior for certain types of domain object. | ||||
|   * | ||||
|   * By default, a composition provider will not support composition | ||||
|   * modification.  You can add support for mutation of composition by | ||||
|   * defining `add` and/or `remove` methods. | ||||
|   * | ||||
|   * If the composition of an object can change over time-- perhaps via | ||||
|   * server updates or mutation via the add/remove methods, then one must | ||||
|   * trigger events as necessary. | ||||
|   * | ||||
|   */ | ||||
| export default class CompositionProvider { | ||||
|     #publicAPI; | ||||
|     #listeningTo; | ||||
|  | ||||
|     /** | ||||
|       * @param {OpenMCT} publicAPI | ||||
|       * @param {CompositionAPI} compositionAPI | ||||
|       */ | ||||
|     constructor(publicAPI, compositionAPI) { | ||||
|         this.#publicAPI = publicAPI; | ||||
|         this.#listeningTo = {}; | ||||
|  | ||||
|         compositionAPI.addPolicy(this.#cannotContainItself.bind(this)); | ||||
|         compositionAPI.addPolicy(this.#supportsComposition.bind(this)); | ||||
|     } | ||||
|  | ||||
|     get listeningTo() { | ||||
|         return this.#listeningTo; | ||||
|     } | ||||
|  | ||||
|     get establishTopicListener() { | ||||
|         return this.#establishTopicListener.bind(this); | ||||
|     } | ||||
|  | ||||
|     get publicAPI() { | ||||
|         return this.#publicAPI; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Check if this provider should be used to load composition for a | ||||
|       * particular domain object. | ||||
|       * @method appliesTo | ||||
|       * @param {import('../objects/ObjectAPI').DomainObject} domainObject the domain object | ||||
|       *        to check | ||||
|       * @returns {boolean} true if this provider can provide composition for a given domain object | ||||
|       */ | ||||
|     appliesTo(domainObject) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Load any domain objects contained in the composition of this domain | ||||
|       * object. | ||||
|       * @param {DomainObject} domainObject the domain object | ||||
|       *        for which to load composition | ||||
|       * @returns {Promise<Identifier[]>} a promise for | ||||
|       *          the Identifiers in this composition | ||||
|       * @method load | ||||
|       */ | ||||
|     load(domainObject) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Attach listeners for changes to the composition of a given domain object. | ||||
|       * Supports `add` and `remove` events. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject to listen to | ||||
|       * @param {string} event the event to bind to, either `add` or `remove`. | ||||
|       * @param {Function} callback callback to invoke when event is triggered. | ||||
|       * @param {any} [context] to use when invoking callback. | ||||
|       */ | ||||
|     on(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Remove a listener that was previously added for a given domain object. | ||||
|       * event name, callback, and context must be the same as when the listener | ||||
|       * was originally attached. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject to remove listener for | ||||
|       * @param {string} event event to stop listening to: `add` or `remove`. | ||||
|       * @param {Function} callback callback to remove. | ||||
|       * @param {any} context of callback to remove. | ||||
|       */ | ||||
|     off(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Remove a domain object from another domain object's composition. | ||||
|       * | ||||
|       * This method is optional; if not present, adding to a domain object's | ||||
|       * composition using this provider will be disallowed. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject the domain object | ||||
|       *        which should have its composition modified | ||||
|       * @param {Identifier} childId the domain object to remove | ||||
|       * @method remove | ||||
|       */ | ||||
|     remove(domainObject, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Add a domain object to another domain object's composition. | ||||
|       * | ||||
|       * This method is optional; if not present, adding to a domain object's | ||||
|       * composition using this provider will be disallowed. | ||||
|       * | ||||
|       * @param {DomainObject} parent the domain object | ||||
|       *        which should have its composition modified | ||||
|       * @param {Identifier} childId the domain object to add | ||||
|       * @method add | ||||
|       */ | ||||
|     add(parent, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @param {DomainObject} parent | ||||
|       * @param {Identifier} childId | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     includes(parent, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @param {DomainObject} domainObject | ||||
|       * @param {number} oldIndex | ||||
|       * @param {number} newIndex | ||||
|       * @returns | ||||
|       */ | ||||
|     reorder(domainObject, oldIndex, newIndex) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Listens on general mutation topic, using injector to fetch to avoid | ||||
|       * circular dependencies. | ||||
|       * @private | ||||
|       */ | ||||
|     #establishTopicListener() { | ||||
|         if (this.topicListener) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.#publicAPI.objects.eventEmitter.on('mutation', this.#onMutation.bind(this)); | ||||
|         this.topicListener = () => { | ||||
|             this.#publicAPI.objects.eventEmitter.off('mutation', this.#onMutation.bind(this)); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @private | ||||
|       * @param {DomainObject} parent | ||||
|       * @param {DomainObject} child | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     #cannotContainItself(parent, child) { | ||||
|         return !(parent.identifier.namespace === child.identifier.namespace | ||||
|                && parent.identifier.key === child.identifier.key); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @private | ||||
|       * @param {DomainObject} parent | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     #supportsComposition(parent, _child) { | ||||
|         return this.#publicAPI.composition.supportsComposition(parent); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Handles mutation events.  If there are active listeners for the mutated | ||||
|       * object, detects changes to composition and triggers necessary events. | ||||
|       * | ||||
|       * @private | ||||
|       * @param {DomainObject} oldDomainObject | ||||
|       */ | ||||
|     #onMutation(oldDomainObject) { | ||||
|         const id = objectUtils.makeKeyString(oldDomainObject.identifier); | ||||
|         const listeners = this.#listeningTo[id]; | ||||
|  | ||||
|         if (!listeners) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const oldComposition = listeners.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|  | ||||
|         const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); | ||||
|         const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); | ||||
|  | ||||
|         function notify(value) { | ||||
|             return function (listener) { | ||||
|                 if (listener.context) { | ||||
|                     listener.callback.call(listener.context, value); | ||||
|                 } else { | ||||
|                     listener.callback(value); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         listeners.composition = newComposition.map(objectUtils.parseKeyString); | ||||
|  | ||||
|         added.forEach(function (addedChild) { | ||||
|             listeners.add.forEach(notify(addedChild)); | ||||
|         }); | ||||
|  | ||||
|         removed.forEach(function (removedChild) { | ||||
|             listeners.remove.forEach(notify(removedChild)); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -19,102 +19,79 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| import objectUtils from "../objects/object-utils"; | ||||
| import CompositionProvider from './CompositionProvider'; | ||||
|  | ||||
| define([ | ||||
|     'lodash', | ||||
|     'objectUtils' | ||||
| ], function ( | ||||
|     _, | ||||
|     objectUtils | ||||
| ) { | ||||
|     /** | ||||
|      * A CompositionProvider provides the underlying implementation of | ||||
|      * composition-related behavior for certain types of domain object. | ||||
|      * | ||||
|      * By default, a composition provider will not support composition | ||||
|      * modification.  You can add support for mutation of composition by | ||||
|      * defining `add` and/or `remove` methods. | ||||
|      * | ||||
|      * If the composition of an object can change over time-- perhaps via | ||||
|      * server updates or mutation via the add/remove methods, then one must | ||||
|      * trigger events as necessary. | ||||
|      * | ||||
|      * @interface CompositionProvider | ||||
|      * @memberof module:openmct | ||||
|      */ | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
|     function DefaultCompositionProvider(publicAPI, compositionAPI) { | ||||
|         this.publicAPI = publicAPI; | ||||
|         this.listeningTo = {}; | ||||
|         this.onMutation = this.onMutation.bind(this); | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').Identifier} Identifier | ||||
|  */ | ||||
|  | ||||
|         this.cannotContainItself = this.cannotContainItself.bind(this); | ||||
|         this.supportsComposition = this.supportsComposition.bind(this); | ||||
| /** | ||||
|  * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|  */ | ||||
|  | ||||
|         compositionAPI.addPolicy(this.cannotContainItself); | ||||
|         compositionAPI.addPolicy(this.supportsComposition); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.cannotContainItself = function (parent, child) { | ||||
|         return !(parent.identifier.namespace === child.identifier.namespace | ||||
|             && parent.identifier.key === child.identifier.key); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.supportsComposition = function (parent, child) { | ||||
|         return this.publicAPI.composition.supportsComposition(parent); | ||||
|     }; | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * A CompositionProvider provides the underlying implementation of | ||||
|  * composition-related behavior for certain types of domain object. | ||||
|  * | ||||
|  * By default, a composition provider will not support composition | ||||
|  * modification.  You can add support for mutation of composition by | ||||
|  * defining `add` and/or `remove` methods. | ||||
|  * | ||||
|  * If the composition of an object can change over time-- perhaps via | ||||
|  * server updates or mutation via the add/remove methods, then one must | ||||
|  * trigger events as necessary. | ||||
|  * @extends CompositionProvider | ||||
|  */ | ||||
| export default class DefaultCompositionProvider extends CompositionProvider { | ||||
|     /** | ||||
|      * Check if this provider should be used to load composition for a | ||||
|      * particular domain object. | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        to check | ||||
|      * @returns {boolean} true if this provider can provide | ||||
|      *          composition for a given domain object | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @method appliesTo | ||||
|      * @returns {boolean} true if this provider can provide composition for a given domain object | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.appliesTo = function (domainObject) { | ||||
|     appliesTo(domainObject) { | ||||
|         return Boolean(domainObject.composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Load any domain objects contained in the composition of this domain | ||||
|      * object. | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        for which to load composition | ||||
|      * @returns {Promise.<Array.<module:openmct.Identifier>>} a promise for | ||||
|      * @returns {Promise<Identifier[]>} a promise for | ||||
|      *          the Identifiers in this composition | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @method load | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.load = function (domainObject) { | ||||
|     load(domainObject) { | ||||
|         return Promise.all(domainObject.composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Attach listeners for changes to the composition of a given domain object. | ||||
|      * Supports `add` and `remove` events. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject to listen to | ||||
|      * @param String event the event to bind to, either `add` or `remove`. | ||||
|      * @param Function callback callback to invoke when event is triggered. | ||||
|      * @param [context] context to use when invoking callback. | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject to listen to | ||||
|      * @param {string} event the event to bind to, either `add` or `remove`. | ||||
|      * @param {Function} callback callback to invoke when event is triggered. | ||||
|      * @param {any} [context] to use when invoking callback. | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.on = function ( | ||||
|         domainObject, | ||||
|     on(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context | ||||
|     ) { | ||||
|         context) { | ||||
|         this.establishTopicListener(); | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         const keyString = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         let objectListeners = this.listeningTo[keyString]; | ||||
|  | ||||
| @@ -131,24 +108,24 @@ define([ | ||||
|             callback: callback, | ||||
|             context: context | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a listener that was previously added for a given domain object. | ||||
|      * event name, callback, and context must be the same as when the listener | ||||
|      * was originally attached. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject to remove listener for | ||||
|      * @param String event event to stop listening to: `add` or `remove`. | ||||
|      * @param Function callback callback to remove. | ||||
|      * @param [context] context of callback to remove. | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject to remove listener for | ||||
|      * @param {string} event event to stop listening to: `add` or `remove`. | ||||
|      * @param {Function} callback callback to remove. | ||||
|      * @param {any} context of callback to remove. | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.off = function ( | ||||
|         domainObject, | ||||
|     off(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context | ||||
|     ) { | ||||
|         context) { | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         const keyString = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         const objectListeners = this.listeningTo[keyString]; | ||||
|  | ||||
| @@ -160,57 +137,64 @@ define([ | ||||
|         if (!objectListeners.add.length && !objectListeners.remove.length && !objectListeners.reorder.length) { | ||||
|             delete this.listeningTo[keyString]; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a domain object from another domain object's composition. | ||||
|      * | ||||
|      * This method is optional; if not present, adding to a domain object's | ||||
|      * composition using this provider will be disallowed. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        which should have its composition modified | ||||
|      * @param {module:openmct.DomainObject} child the domain object to remove | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @param {Identifier} childId the domain object to remove | ||||
|      * @method remove | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.remove = function (domainObject, childId) { | ||||
|     remove(domainObject, childId) { | ||||
|         let composition = domainObject.composition.filter(function (child) { | ||||
|             return !(childId.namespace === child.namespace | ||||
|                 && childId.key === child.key); | ||||
|                       && childId.key === child.key); | ||||
|         }); | ||||
|  | ||||
|         this.publicAPI.objects.mutate(domainObject, 'composition', composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Add a domain object to another domain object's composition. | ||||
|      * | ||||
|      * This method is optional; if not present, adding to a domain object's | ||||
|      * composition using this provider will be disallowed. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} parent the domain object | ||||
|      *        which should have its composition modified | ||||
|      * @param {module:openmct.DomainObject} child the domain object to add | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @param {Identifier} childId the domain object to add | ||||
|      * @method add | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.add = function (parent, childId) { | ||||
|     add(parent, childId) { | ||||
|         if (!this.includes(parent, childId)) { | ||||
|             parent.composition.push(childId); | ||||
|             this.publicAPI.objects.mutate(parent, 'composition', parent.composition); | ||||
|         } | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * @override | ||||
|      * @param {DomainObject} parent | ||||
|      * @param {Identifier} childId | ||||
|      * @returns {boolean} | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.includes = function (parent, childId) { | ||||
|         return parent.composition.some(composee => | ||||
|             this.publicAPI.objects.areIdsEqual(composee, childId)); | ||||
|     }; | ||||
|     includes(parent, childId) { | ||||
|         return parent.composition.some(composee => this.publicAPI.objects.areIdsEqual(composee, childId)); | ||||
|     } | ||||
|  | ||||
|     DefaultCompositionProvider.prototype.reorder = function (domainObject, oldIndex, newIndex) { | ||||
|     /** | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @param {number} oldIndex | ||||
|      * @param {number} newIndex | ||||
|      * @returns | ||||
|      */ | ||||
|     reorder(domainObject, oldIndex, newIndex) { | ||||
|         let newComposition = domainObject.composition.slice(); | ||||
|         let removeId = oldIndex > newIndex ? oldIndex + 1 : oldIndex; | ||||
|         let insertPosition = oldIndex < newIndex ? newIndex + 1 : newIndex; | ||||
| @@ -241,6 +225,7 @@ define([ | ||||
|  | ||||
|         this.publicAPI.objects.mutate(domainObject, 'composition', newComposition); | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         let id = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         const listeners = this.listeningTo[id]; | ||||
|  | ||||
| @@ -257,66 +242,5 @@ define([ | ||||
|                 listener.callback(reorderPlan); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Listens on general mutation topic, using injector to fetch to avoid | ||||
|      * circular dependencies. | ||||
|      * | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.establishTopicListener = function () { | ||||
|         if (this.topicListener) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.publicAPI.objects.eventEmitter.on('mutation', this.onMutation); | ||||
|         this.topicListener = () => { | ||||
|             this.publicAPI.objects.eventEmitter.off('mutation', this.onMutation); | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handles mutation events.  If there are active listeners for the mutated | ||||
|      * object, detects changes to composition and triggers necessary events. | ||||
|      * | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.onMutation = function (oldDomainObject) { | ||||
|         const id = objectUtils.makeKeyString(oldDomainObject.identifier); | ||||
|         const listeners = this.listeningTo[id]; | ||||
|  | ||||
|         if (!listeners) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const oldComposition = listeners.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|  | ||||
|         const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); | ||||
|         const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); | ||||
|  | ||||
|         function notify(value) { | ||||
|             return function (listener) { | ||||
|                 if (listener.context) { | ||||
|                     listener.callback.call(listener.context, value); | ||||
|                 } else { | ||||
|                     listener.callback(value); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         listeners.composition = newComposition.map(objectUtils.parseKeyString); | ||||
|  | ||||
|         added.forEach(function (addedChild) { | ||||
|             listeners.add.forEach(notify(addedChild)); | ||||
|         }); | ||||
|  | ||||
|         removed.forEach(function (removedChild) { | ||||
|             listeners.remove.forEach(notify(removedChild)); | ||||
|         }); | ||||
|  | ||||
|     }; | ||||
|  | ||||
|     return DefaultCompositionProvider; | ||||
| }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -23,13 +23,11 @@ | ||||
| import FormController from './FormController'; | ||||
| import FormProperties from './components/FormProperties.vue'; | ||||
|  | ||||
| import EventEmitter from 'EventEmitter'; | ||||
| import Vue from 'vue'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class FormsAPI extends EventEmitter { | ||||
| export default class FormsAPI { | ||||
|     constructor(openmct) { | ||||
|         super(); | ||||
|  | ||||
|         this.openmct = openmct; | ||||
|         this.formController = new FormController(openmct); | ||||
|     } | ||||
| @@ -92,29 +90,75 @@ export default class FormsAPI extends EventEmitter { | ||||
|  | ||||
|     /** | ||||
|      * Show form inside an Overlay dialog with given form structure | ||||
|      * @public | ||||
|      * @param {Array<Section>} formStructure a form structure, array of section | ||||
|      * @param {Object} options | ||||
|      *      @property {function} onChange a callback function when any changes detected | ||||
|      */ | ||||
|     showForm(formStructure, { | ||||
|         onChange | ||||
|     } = {}) { | ||||
|         let overlay; | ||||
|  | ||||
|         const self = this; | ||||
|  | ||||
|         const overlayEl = document.createElement('div'); | ||||
|         overlayEl.classList.add('u-contents'); | ||||
|  | ||||
|         overlay = self.openmct.overlays.overlay({ | ||||
|             element: overlayEl, | ||||
|             size: 'dialog' | ||||
|         }); | ||||
|  | ||||
|         let formSave; | ||||
|         let formCancel; | ||||
|         const promise = new Promise((resolve, reject) => { | ||||
|             formSave = resolve; | ||||
|             formCancel = reject; | ||||
|         }); | ||||
|  | ||||
|         this.showCustomForm(formStructure, { | ||||
|             element: overlayEl, | ||||
|             onChange | ||||
|         }) | ||||
|             .then((response) => { | ||||
|                 overlay.dismiss(); | ||||
|                 formSave(response); | ||||
|             }) | ||||
|             .catch((response) => { | ||||
|                 overlay.dismiss(); | ||||
|                 formCancel(response); | ||||
|             }); | ||||
|  | ||||
|         return promise; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show form as a child of the element provided with given form structure | ||||
|      * | ||||
|      * @public | ||||
|      * @param {Array<Section>} formStructure a form structure, array of section | ||||
|      * @param {Object} options | ||||
|      *      @property {HTMLElement} element Parent Element to render a Form | ||||
|      *      @property {function} onChange a callback function when any changes detected | ||||
|      *      @property {function} onSave a callback function when form is submitted | ||||
|      *      @property {function} onDismiss a callback function when form is dismissed | ||||
|      */ | ||||
|     showForm(formStructure, { | ||||
|     showCustomForm(formStructure, { | ||||
|         element, | ||||
|         onChange | ||||
|     } = {}) { | ||||
|         const changes = {}; | ||||
|         let overlay; | ||||
|         let onDismiss; | ||||
|         let onSave; | ||||
|         if (element === undefined) { | ||||
|             throw Error('Required element parameter not provided'); | ||||
|         } | ||||
|  | ||||
|         const self = this; | ||||
|  | ||||
|         const changes = {}; | ||||
|         let formSave; | ||||
|         let formCancel; | ||||
|  | ||||
|         const promise = new Promise((resolve, reject) => { | ||||
|             onSave = onFormAction(resolve); | ||||
|             onDismiss = onFormAction(reject); | ||||
|             formSave = onFormAction(resolve); | ||||
|             formCancel = onFormAction(reject); | ||||
|         }); | ||||
|  | ||||
|         const vm = new Vue({ | ||||
| @@ -126,26 +170,17 @@ export default class FormsAPI extends EventEmitter { | ||||
|                 return { | ||||
|                     formStructure, | ||||
|                     onChange: onFormPropertyChange, | ||||
|                     onDismiss, | ||||
|                     onSave | ||||
|                     onCancel: formCancel, | ||||
|                     onSave: formSave | ||||
|                 }; | ||||
|             }, | ||||
|             template: '<FormProperties :model="formStructure" @onChange="onChange" @onDismiss="onDismiss" @onSave="onSave"></FormProperties>' | ||||
|             template: '<FormProperties :model="formStructure" @onChange="onChange" @onCancel="onCancel" @onSave="onSave"></FormProperties>' | ||||
|         }).$mount(); | ||||
|  | ||||
|         const formElement = vm.$el; | ||||
|         if (element) { | ||||
|             element.append(formElement); | ||||
|         } else { | ||||
|             overlay = self.openmct.overlays.overlay({ | ||||
|                 element: vm.$el, | ||||
|                 size: 'dialog', | ||||
|                 onDestroy: () => vm.$destroy() | ||||
|             }); | ||||
|         } | ||||
|         element.append(formElement); | ||||
|  | ||||
|         function onFormPropertyChange(data) { | ||||
|             self.emit('onFormPropertyChange', data); | ||||
|             if (onChange) { | ||||
|                 onChange(data); | ||||
|             } | ||||
| @@ -158,17 +193,14 @@ export default class FormsAPI extends EventEmitter { | ||||
|                     key = property.join('.'); | ||||
|                 } | ||||
|  | ||||
|                 changes[key] = data.value; | ||||
|                 _.set(changes, key, data.value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         function onFormAction(callback) { | ||||
|             return () => { | ||||
|                 if (element) { | ||||
|                     formElement.remove(); | ||||
|                 } else { | ||||
|                     overlay.dismiss(); | ||||
|                 } | ||||
|                 formElement.remove(); | ||||
|                 vm.$destroy(); | ||||
|  | ||||
|                 if (callback) { | ||||
|                     callback(changes); | ||||
|   | ||||
| @@ -133,7 +133,7 @@ describe('The Forms API', () => { | ||||
|         }); | ||||
|  | ||||
|         it('when container element is provided', (done) => { | ||||
|             openmct.forms.showForm(formStructure, { element }).catch(() => { | ||||
|             openmct.forms.showCustomForm(formStructure, { element }).catch(() => { | ||||
|                 done(); | ||||
|             }); | ||||
|             const titleElement = element.querySelector('.c-overlay__dialog-title'); | ||||
|   | ||||
| @@ -73,7 +73,7 @@ | ||||
|             tabindex="0" | ||||
|             class="c-button js-cancel-button" | ||||
|             aria-label="Cancel" | ||||
|             @click="onDismiss" | ||||
|             @click="onCancel" | ||||
|         > | ||||
|             {{ cancelLabel }} | ||||
|         </button> | ||||
| @@ -164,8 +164,8 @@ export default { | ||||
|  | ||||
|             this.$emit('onChange', data); | ||||
|         }, | ||||
|         onDismiss() { | ||||
|             this.$emit('onDismiss'); | ||||
|         onCancel() { | ||||
|             this.$emit('onCancel'); | ||||
|         }, | ||||
|         onSave() { | ||||
|             this.$emit('onSave'); | ||||
|   | ||||
| @@ -26,6 +26,7 @@ | ||||
|         v-model="selected" | ||||
|         required="model.required" | ||||
|         name="mctControl" | ||||
|         :aria-label="model.ariaLabel || model.name" | ||||
|         @change="onChange($event)" | ||||
|     > | ||||
|         <option | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|         :class="model.cssClass" | ||||
|     > | ||||
|         <textarea | ||||
|             :id="`${model.key}-textarea`" | ||||
|             v-model="field" | ||||
|             type="text" | ||||
|             :size="model.size" | ||||
|   | ||||
| @@ -3,39 +3,52 @@ | ||||
|     class="c-menu" | ||||
|     :class="options.menuClass" | ||||
| > | ||||
|     <ul v-if="options.actions.length && options.actions[0].length"> | ||||
|     <ul | ||||
|         v-if="options.actions.length && options.actions[0].length" | ||||
|         role="menu" | ||||
|     > | ||||
|         <template | ||||
|             v-for="(actionGroups, index) in options.actions" | ||||
|         > | ||||
|             <li | ||||
|                 v-for="action in actionGroups" | ||||
|                 :key="action.name" | ||||
|                 :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                 :title="action.description" | ||||
|                 :data-testid="action.testId || false" | ||||
|                 @click="action.onItemClicked" | ||||
|             > | ||||
|                 {{ action.name }} | ||||
|             </li> | ||||
|             <div | ||||
|                 v-if="index !== options.actions.length - 1" | ||||
|                 :key="index" | ||||
|                 class="c-menu__section-separator" | ||||
|                 role="group" | ||||
|             > | ||||
|             </div> | ||||
|             <li | ||||
|                 v-if="actionGroups.length === 0" | ||||
|                 :key="index" | ||||
|             > | ||||
|                 No actions defined. | ||||
|             </li> | ||||
|         </template> | ||||
|                 <li | ||||
|                     v-for="action in actionGroups" | ||||
|                     :key="action.name" | ||||
|                     role="menuitem" | ||||
|                     :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                     :title="action.description" | ||||
|                     :data-testid="action.testId || false" | ||||
|                     @click="action.onItemClicked" | ||||
|                 > | ||||
|                     {{ action.name }} | ||||
|                 </li> | ||||
|                 <div | ||||
|                     v-if="index !== options.actions.length - 1" | ||||
|                     :key="index" | ||||
|                     role="separator" | ||||
|                     class="c-menu__section-separator" | ||||
|                 > | ||||
|                 </div> | ||||
|                 <li | ||||
|                     v-if="actionGroups.length === 0" | ||||
|                     :key="index" | ||||
|                 > | ||||
|                     No actions defined. | ||||
|                 </li> | ||||
|             </div></template> | ||||
|     </ul> | ||||
|  | ||||
|     <ul v-else> | ||||
|     <ul | ||||
|         v-else | ||||
|         role="menu" | ||||
|     > | ||||
|         <li | ||||
|             v-for="action in options.actions" | ||||
|             :key="action.name" | ||||
|             role="menuitem" | ||||
|             :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|             :title="action.description" | ||||
|             :data-testid="action.testId || false" | ||||
|   | ||||
| @@ -5,45 +5,54 @@ | ||||
| > | ||||
|     <ul | ||||
|         v-if="options.actions.length && options.actions[0].length" | ||||
|         role="menu" | ||||
|         class="c-super-menu__menu" | ||||
|     > | ||||
|         <template | ||||
|             v-for="(actionGroups, index) in options.actions" | ||||
|         > | ||||
|             <li | ||||
|                 v-for="action in actionGroups" | ||||
|                 :key="action.name" | ||||
|                 :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                 :title="action.description" | ||||
|                 :data-testid="action.testId || false" | ||||
|                 @click="action.onItemClicked" | ||||
|                 @mouseover="toggleItemDescription(action)" | ||||
|                 @mouseleave="toggleItemDescription()" | ||||
|             > | ||||
|                 {{ action.name }} | ||||
|             </li> | ||||
|             <div | ||||
|                 v-if="index !== options.actions.length - 1" | ||||
|                 :key="index" | ||||
|                 class="c-menu__section-separator" | ||||
|                 role="group" | ||||
|             > | ||||
|             </div> | ||||
|             <li | ||||
|                 v-if="actionGroups.length === 0" | ||||
|                 :key="index" | ||||
|             > | ||||
|                 No actions defined. | ||||
|             </li> | ||||
|         </template> | ||||
|                 <li | ||||
|                     v-for="action in actionGroups" | ||||
|                     :key="action.name" | ||||
|                     role="menuitem" | ||||
|                     :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                     :title="action.description" | ||||
|                     :data-testid="action.testId || false" | ||||
|                     @click="action.onItemClicked" | ||||
|                     @mouseover="toggleItemDescription(action)" | ||||
|                     @mouseleave="toggleItemDescription()" | ||||
|                 > | ||||
|                     {{ action.name }} | ||||
|                 </li> | ||||
|                 <div | ||||
|                     v-if="index !== options.actions.length - 1" | ||||
|                     :key="index" | ||||
|                     role="separator" | ||||
|                     class="c-menu__section-separator" | ||||
|                 > | ||||
|                 </div> | ||||
|                 <li | ||||
|                     v-if="actionGroups.length === 0" | ||||
|                     :key="index" | ||||
|                 > | ||||
|                     No actions defined. | ||||
|                 </li> | ||||
|             </div></template> | ||||
|     </ul> | ||||
|  | ||||
|     <ul | ||||
|         v-else | ||||
|         class="c-super-menu__menu" | ||||
|         role="menu" | ||||
|     > | ||||
|         <li | ||||
|             v-for="action in options.actions" | ||||
|             :key="action.name" | ||||
|             role="menuitem" | ||||
|             :class="action.cssClass" | ||||
|             :title="action.description" | ||||
|             :data-testid="action.testId || false" | ||||
|   | ||||
| @@ -42,7 +42,6 @@ class InMemorySearchProvider { | ||||
|         this.openmct = openmct; | ||||
|         this.indexedIds = {}; | ||||
|         this.indexedCompositions = {}; | ||||
|         this.indexedTags = {}; | ||||
|         this.idsToIndex = []; | ||||
|         this.pendingIndex = {}; | ||||
|         this.pendingRequests = 0; | ||||
| @@ -61,7 +60,6 @@ class InMemorySearchProvider { | ||||
|         this.localSearchForObjects = this.localSearchForObjects.bind(this); | ||||
|         this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this); | ||||
|         this.localSearchForTags = this.localSearchForTags.bind(this); | ||||
|         this.localSearchForNotebookAnnotations = this.localSearchForNotebookAnnotations.bind(this); | ||||
|         this.onAnnotationCreation = this.onAnnotationCreation.bind(this); | ||||
|         this.onCompositionAdded = this.onCompositionAdded.bind(this); | ||||
|         this.onCompositionRemoved = this.onCompositionRemoved.bind(this); | ||||
| @@ -93,7 +91,7 @@ class InMemorySearchProvider { | ||||
|  | ||||
|         this.searchTypes = this.openmct.objects.SEARCH_TYPES; | ||||
|  | ||||
|         this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS]; | ||||
|         this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS]; | ||||
|  | ||||
|         this.scheduleForIndexing(rootObject.identifier); | ||||
|  | ||||
| @@ -163,8 +161,6 @@ class InMemorySearchProvider { | ||||
|             return this.localSearchForObjects(queryId, query, maxResults); | ||||
|         } else if (searchType === this.searchTypes.ANNOTATIONS) { | ||||
|             return this.localSearchForAnnotations(queryId, query, maxResults); | ||||
|         } else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) { | ||||
|             return this.localSearchForNotebookAnnotations(queryId, query, maxResults); | ||||
|         } else if (searchType === this.searchTypes.TAGS) { | ||||
|             return this.localSearchForTags(queryId, query, maxResults); | ||||
|         } else { | ||||
| @@ -281,13 +277,6 @@ class InMemorySearchProvider { | ||||
|         provider.index(domainObject); | ||||
|     } | ||||
|  | ||||
|     onTagMutation(domainObject, newTags) { | ||||
|         domainObject.tags = newTags; | ||||
|         const provider = this; | ||||
|  | ||||
|         provider.index(domainObject); | ||||
|     } | ||||
|  | ||||
|     onCompositionAdded(newDomainObjectToIndex) { | ||||
|         const provider = this; | ||||
|         // The object comes in as a mutable domain object, which has functions, | ||||
| @@ -342,14 +331,6 @@ class InMemorySearchProvider { | ||||
|                 composition.on('remove', this.onCompositionRemoved); | ||||
|                 this.indexedCompositions[keyString] = composition; | ||||
|             } | ||||
|  | ||||
|             if (domainObject.type === 'annotation') { | ||||
|                 this.indexedTags[keyString] = this.openmct.objects.observe( | ||||
|                     domainObject, | ||||
|                     'tags', | ||||
|                     this.onTagMutation.bind(this, domainObject) | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if ((keyString !== 'ROOT')) { | ||||
| @@ -581,43 +562,6 @@ class InMemorySearchProvider { | ||||
|         this.onWorkerMessage(eventToReturn); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * A local version of the same SharedWorker function | ||||
|      * if we don't have SharedWorkers available (e.g., iOS) | ||||
|      */ | ||||
|     localSearchForNotebookAnnotations(queryId, {entryId, targetKeyString}, maxResults) { | ||||
|         // This results dictionary will have domain object ID keys which | ||||
|         // point to the value the domain object's score. | ||||
|         let results = []; | ||||
|         const message = { | ||||
|             request: 'searchForNotebookAnnotations', | ||||
|             results: [], | ||||
|             total: 0, | ||||
|             queryId | ||||
|         }; | ||||
|  | ||||
|         const matchingAnnotations = this.localIndexedAnnotationsByDomainObject[targetKeyString]; | ||||
|         if (matchingAnnotations) { | ||||
|             results = matchingAnnotations.filter(matchingAnnotation => { | ||||
|                 if (!matchingAnnotation.targets) { | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 const target = matchingAnnotation.targets[targetKeyString]; | ||||
|  | ||||
|                 return (target && target.entryId && (target.entryId === entryId)); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         message.total = results.length; | ||||
|         message.results = results | ||||
|             .slice(0, maxResults); | ||||
|         const eventToReturn = { | ||||
|             data: message | ||||
|         }; | ||||
|         this.onWorkerMessage(eventToReturn); | ||||
|     } | ||||
|  | ||||
|     destroyObservers(observers) { | ||||
|         Object.entries(observers).forEach(([keyString, unobserve]) => { | ||||
|             if (typeof unobserve === 'function') { | ||||
|   | ||||
| @@ -43,8 +43,6 @@ | ||||
|                 port.postMessage(searchForAnnotations(event.data)); | ||||
|             } else if (requestType === 'TAGS') { | ||||
|                 port.postMessage(searchForTags(event.data)); | ||||
|             } else if (requestType === 'NOTEBOOK_ANNOTATIONS') { | ||||
|                 port.postMessage(searchForNotebookAnnotations(event.data)); | ||||
|             } else { | ||||
|                 throw new Error(`Unknown request ${event.data.request}`); | ||||
|             } | ||||
| @@ -204,33 +202,4 @@ | ||||
|  | ||||
|         return message; | ||||
|     } | ||||
|  | ||||
|     function searchForNotebookAnnotations(data) { | ||||
|         let results = []; | ||||
|         const message = { | ||||
|             request: 'searchForNotebookAnnotations', | ||||
|             results: {}, | ||||
|             total: 0, | ||||
|             queryId: data.queryId | ||||
|         }; | ||||
|  | ||||
|         const matchingAnnotations = indexedAnnotationsByDomainObject[data.input.targetKeyString]; | ||||
|         if (matchingAnnotations) { | ||||
|             results = matchingAnnotations.filter(matchingAnnotation => { | ||||
|                 if (!matchingAnnotation.targets) { | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 const target = matchingAnnotation.targets[data.input.targetKeyString]; | ||||
|  | ||||
|                 return (target && target.entryId && (target.entryId === data.input.entryId)); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         message.total = results.length; | ||||
|         message.results = results | ||||
|             .slice(0, data.maxResults); | ||||
|  | ||||
|         return message; | ||||
|     } | ||||
| }()); | ||||
|   | ||||
| @@ -19,6 +19,7 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| const DEFAULT_INTERCEPTOR_PRIORITY = 0; | ||||
| export default class InterceptorRegistry { | ||||
|     /** | ||||
|      * A InterceptorRegistry maintains the definitions for different interceptors that may be invoked on domain objects. | ||||
| @@ -45,7 +46,6 @@ export default class InterceptorRegistry { | ||||
|      * @memberof module:openmct.InterceptorRegistry# | ||||
|      */ | ||||
|     addInterceptor(interceptorDef) { | ||||
|         //TODO: sort by priority | ||||
|         this.interceptors.push(interceptorDef); | ||||
|     } | ||||
|  | ||||
| @@ -56,10 +56,18 @@ export default class InterceptorRegistry { | ||||
|      * @memberof module:openmct.InterceptorRegistry# | ||||
|      */ | ||||
|     getInterceptors(identifier, object) { | ||||
|  | ||||
|         function byPriority(interceptorA, interceptorB) { | ||||
|             let priorityA = interceptorA.priority ?? DEFAULT_INTERCEPTOR_PRIORITY; | ||||
|             let priorityB = interceptorB.priority ?? DEFAULT_INTERCEPTOR_PRIORITY; | ||||
|  | ||||
|             return priorityB - priorityA; | ||||
|         } | ||||
|  | ||||
|         return this.interceptors.filter(interceptor => { | ||||
|             return typeof interceptor.appliesTo === 'function' | ||||
|                 && interceptor.appliesTo(identifier, object); | ||||
|         }); | ||||
|         }).sort(byPriority); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -75,11 +75,7 @@ class MutableDomainObject { | ||||
|         return eventOff; | ||||
|     } | ||||
|     $set(path, value) { | ||||
|         _.set(this, path, value); | ||||
|  | ||||
|         if (path !== 'persisted' && path !== 'modified') { | ||||
|             _.set(this, 'modified', Date.now()); | ||||
|         } | ||||
|         MutableDomainObject.mutateObject(this, path, value); | ||||
|  | ||||
|         //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired. | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this); | ||||
| @@ -136,8 +132,11 @@ class MutableDomainObject { | ||||
|     } | ||||
|  | ||||
|     static mutateObject(object, path, value) { | ||||
|         if (path !== 'persisted') { | ||||
|             _.set(object, 'modified', Date.now()); | ||||
|         } | ||||
|  | ||||
|         _.set(object, path, value); | ||||
|         _.set(object, 'modified', Date.now()); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,7 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
| /** | ||||
|  * Uniquely identifies a domain object. | ||||
|  * | ||||
|  * @typedef Identifier | ||||
|  * @typedef {object} Identifier | ||||
|  * @property {string} namespace the namespace to/from which this domain | ||||
|  *           object should be loaded/stored. | ||||
|  * @property {string} key a unique identifier for the domain object | ||||
| @@ -50,8 +50,8 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
|  * A few common properties are defined for domain objects. Beyond these, | ||||
|  * individual types of domain objects may add more as they see fit. | ||||
|  * | ||||
|  * @typedef DomainObject | ||||
|  * @property {module:openmct.ObjectAPI~Identifier} identifier a key/namespace pair which | ||||
|  * @typedef {object} DomainObject | ||||
|  * @property {Identifier} identifier a key/namespace pair which | ||||
|  *           uniquely identifies this domain object | ||||
|  * @property {string} type the type of domain object | ||||
|  * @property {string} name the human-readable name for this domain object | ||||
| @@ -59,11 +59,20 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
|  *           object | ||||
|  * @property {number} [modified] the time, in milliseconds since the UNIX | ||||
|  *           epoch, at which this domain object was last modified | ||||
|  * @property {module:openmct.ObjectAPI~Identifier[]} [composition] if | ||||
|  * @property {Identifier[]} [composition] if | ||||
|  *           present, this will be used by the default composition provider | ||||
|  *           to load domain objects | ||||
|  * @memberof module:openmct | ||||
|  * @memberof module:openmct.ObjectAPI~ | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|   * @readonly | ||||
|   * @enum {string} SEARCH_TYPES | ||||
|   * @property {string} OBJECTS Search for objects | ||||
|   * @property {string} ANNOTATIONS Search for annotations | ||||
|   * @property {string} TAGS Search for tags | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|  * Utilities for loading, saving, and manipulating domain objects. | ||||
|  * @interface ObjectAPI | ||||
| @@ -76,7 +85,6 @@ export default class ObjectAPI { | ||||
|         this.SEARCH_TYPES = Object.freeze({ | ||||
|             OBJECTS: 'OBJECTS', | ||||
|             ANNOTATIONS: 'ANNOTATIONS', | ||||
|             NOTEBOOK_ANNOTATIONS: 'NOTEBOOK_ANNOTATIONS', | ||||
|             TAGS: 'TAGS' | ||||
|         }); | ||||
|         this.eventEmitter = new EventEmitter(); | ||||
| @@ -88,7 +96,7 @@ export default class ObjectAPI { | ||||
|         this.cache = {}; | ||||
|         this.interceptorRegistry = new InterceptorRegistry(); | ||||
|  | ||||
|         this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'plan', 'annotation']; | ||||
|         this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'restricted-notebook', 'plan', 'annotation']; | ||||
|  | ||||
|         this.errors = { | ||||
|             Conflict: ConflictError | ||||
| @@ -179,7 +187,7 @@ export default class ObjectAPI { | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Get a domain object. | ||||
|      * Force remote get for a domain object. Don't return dirty objects. | ||||
|      * | ||||
|      * @method get | ||||
|      * @memberof module:openmct.ObjectProvider# | ||||
| @@ -188,24 +196,8 @@ export default class ObjectAPI { | ||||
|      * @returns {Promise} a promise which will resolve when the domain object | ||||
|      *          has been saved, or be rejected if it cannot be saved | ||||
|      */ | ||||
|  | ||||
|     get(identifier, abortSignal) { | ||||
|         let keystring = this.makeKeyString(identifier); | ||||
|  | ||||
|         if (this.cache[keystring] !== undefined) { | ||||
|             return this.cache[keystring]; | ||||
|         } | ||||
|  | ||||
|         identifier = utils.parseKeyString(identifier); | ||||
|         let dirtyObject; | ||||
|         if (this.isTransactionActive()) { | ||||
|             dirtyObject = this.transaction.getDirtyObject(identifier); | ||||
|         } | ||||
|  | ||||
|         if (dirtyObject) { | ||||
|             return Promise.resolve(dirtyObject); | ||||
|         } | ||||
|  | ||||
|     remoteGet(identifier, abortSignal) { | ||||
|         const keystring = this.makeKeyString(identifier); | ||||
|         const provider = this.getProvider(identifier); | ||||
|  | ||||
|         if (!provider) { | ||||
| @@ -218,12 +210,11 @@ export default class ObjectAPI { | ||||
|  | ||||
|         let objectPromise = provider.get(identifier, abortSignal).then(result => { | ||||
|             delete this.cache[keystring]; | ||||
|  | ||||
|             result = this.applyGetInterceptors(identifier, result); | ||||
|             if (result.isMutable) { | ||||
|                 result.$refresh(result); | ||||
|             } else { | ||||
|                 let mutableDomainObject = this._toMutable(result); | ||||
|                 let mutableDomainObject = this.toMutable(result); | ||||
|                 mutableDomainObject.$refresh(result); | ||||
|             } | ||||
|  | ||||
| @@ -243,6 +234,36 @@ export default class ObjectAPI { | ||||
|         return objectPromise; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get a domain object. | ||||
|      * | ||||
|      * @method get | ||||
|      * @memberof module:openmct.ObjectProvider# | ||||
|      * @param {string} key the key for the domain object to load | ||||
|      * @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests | ||||
|      * @returns {Promise} a promise which will resolve when the domain object | ||||
|      *          has been saved, or be rejected if it cannot be saved | ||||
|      */ | ||||
|     get(identifier, abortSignal) { | ||||
|         const keystring = this.makeKeyString(identifier); | ||||
|  | ||||
|         if (this.cache[keystring] !== undefined) { | ||||
|             return this.cache[keystring]; | ||||
|         } | ||||
|  | ||||
|         identifier = utils.parseKeyString(identifier); | ||||
|  | ||||
|         if (this.isTransactionActive()) { | ||||
|             let dirtyObject = this.transaction.getDirtyObject(identifier); | ||||
|  | ||||
|             if (dirtyObject) { | ||||
|                 return Promise.resolve(dirtyObject); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return this.remoteGet(identifier, abortSignal); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Search for domain objects. | ||||
|      * | ||||
| @@ -300,7 +321,7 @@ export default class ObjectAPI { | ||||
|         } | ||||
|  | ||||
|         return this.get(identifier).then((object) => { | ||||
|             return this._toMutable(object); | ||||
|             return this.toMutable(object); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
| @@ -347,44 +368,67 @@ export default class ObjectAPI { | ||||
|      * @returns {Promise} a promise which will resolve when the domain object | ||||
|      *          has been saved, or be rejected if it cannot be saved | ||||
|      */ | ||||
|     save(domainObject) { | ||||
|         let provider = this.getProvider(domainObject.identifier); | ||||
|         let savedResolve; | ||||
|         let savedReject; | ||||
|     async save(domainObject) { | ||||
|         console.log('save', JSON.parse(JSON.stringify(domainObject))); | ||||
|         const provider = this.getProvider(domainObject.identifier); | ||||
|         let result; | ||||
|         let lastPersistedTime; | ||||
|  | ||||
|         if (!this.isPersistable(domainObject.identifier)) { | ||||
|             result = Promise.reject('Object provider does not support saving'); | ||||
|         } else if (this.#hasAlreadyBeenPersisted(domainObject)) { | ||||
|             result = Promise.resolve(true); | ||||
|         } else { | ||||
|             const persistedTime = Date.now(); | ||||
|             if (domainObject.persisted === undefined) { | ||||
|                 result = new Promise((resolve, reject) => { | ||||
|                     savedResolve = resolve; | ||||
|                     savedReject = reject; | ||||
|                 }); | ||||
|                 domainObject.persisted = persistedTime; | ||||
|                 const newObjectPromise = provider.create(domainObject); | ||||
|                 if (newObjectPromise) { | ||||
|                     newObjectPromise.then(response => { | ||||
|                         this.mutate(domainObject, 'persisted', persistedTime); | ||||
|                         savedResolve(response); | ||||
|                     }).catch((error) => { | ||||
|                         savedReject(error); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     result = Promise.reject(`[ObjectAPI][save] Object provider returned ${newObjectPromise} when creating new object.`); | ||||
|                 } | ||||
|             const username = await this.#getCurrentUsername(); | ||||
|             const isNewObject = domainObject.persisted === undefined; | ||||
|             let savedResolve; | ||||
|             let savedReject; | ||||
|             let savedObjectPromise; | ||||
|  | ||||
|             result = new Promise((resolve, reject) => { | ||||
|                 savedResolve = resolve; | ||||
|                 savedReject = reject; | ||||
|             }); | ||||
|  | ||||
|             this.#mutate(domainObject, 'modifiedBy', username); | ||||
|  | ||||
|             if (isNewObject) { | ||||
|                 this.#mutate(domainObject, 'createdBy', username); | ||||
|  | ||||
|                 const createdTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'created', createdTime); | ||||
|  | ||||
|                 const persistedTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'persisted', persistedTime); | ||||
|  | ||||
|                 savedObjectPromise = provider.create(domainObject); | ||||
|             } else { | ||||
|                 domainObject.persisted = persistedTime; | ||||
|                 this.mutate(domainObject, 'persisted', persistedTime); | ||||
|                 result = provider.update(domainObject); | ||||
|                 lastPersistedTime = domainObject.persisted; | ||||
|                 const persistedTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'persisted', persistedTime); | ||||
|  | ||||
|                 savedObjectPromise = provider.update(domainObject); | ||||
|             } | ||||
|  | ||||
|             if (savedObjectPromise) { | ||||
|                 savedObjectPromise.then(response => { | ||||
|                     savedResolve(response); | ||||
|                 }).catch((error) => { | ||||
|                     if (!isNewObject) { | ||||
|                         this.#mutate(domainObject, 'persisted', lastPersistedTime); | ||||
|                     } | ||||
|  | ||||
|                     savedReject(error); | ||||
|                 }); | ||||
|             } else { | ||||
|                 result = Promise.reject(`[ObjectAPI][save] Object provider returned ${savedObjectPromise} when ${isNewObject ? 'creating new' : 'updating'} object.`); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return result.catch((error) => { | ||||
|             if (error instanceof this.errors.Conflict) { | ||||
|             // suppress conflict error notifications for remotely synced items | ||||
|             // (possibly just for notebook and restricted-notebook as they have conflic resolution) | ||||
|             if (error instanceof this.errors.Conflict && !this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) { | ||||
|                 this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`); | ||||
|             } | ||||
|  | ||||
| @@ -392,8 +436,21 @@ export default class ObjectAPI { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async #getCurrentUsername() { | ||||
|         const user = await this.openmct.user.getCurrentUser(); | ||||
|         let username; | ||||
|  | ||||
|         if (user !== undefined) { | ||||
|             username = user.getName(); | ||||
|         } | ||||
|  | ||||
|         return username; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * After entering into edit mode, creates a new instance of Transaction to keep track of changes in Objects | ||||
|      * | ||||
|      * @returns {Transaction} a new Transaction that was just created | ||||
|      */ | ||||
|     startTransaction() { | ||||
|         if (this.isTransactionActive()) { | ||||
| @@ -401,6 +458,8 @@ export default class ObjectAPI { | ||||
|         } | ||||
|  | ||||
|         this.transaction = new Transaction(this); | ||||
|  | ||||
|         return this.transaction; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -473,14 +532,16 @@ export default class ObjectAPI { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Modify a domain object. | ||||
|      * Modify a domain object. Internal to ObjectAPI, won't call save after. | ||||
|      * @private | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} object the object to mutate | ||||
|      * @param {string} path the property to modify | ||||
|      * @param {*} value the new value for this property | ||||
|      * @method mutate | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|     mutate(domainObject, path, value) { | ||||
|     #mutate(domainObject, path, value) { | ||||
|         if (!this.supportsMutation(domainObject.identifier)) { | ||||
|             throw `Error: Attempted to mutate immutable object ${domainObject.name}`; | ||||
|         } | ||||
| @@ -490,7 +551,7 @@ export default class ObjectAPI { | ||||
|         } else { | ||||
|             //Creating a temporary mutable domain object allows other mutable instances of the | ||||
|             //object to be kept in sync. | ||||
|             let mutableDomainObject = this._toMutable(domainObject); | ||||
|             let mutableDomainObject = this.toMutable(domainObject); | ||||
|  | ||||
|             //Mutate original object | ||||
|             MutableDomainObject.mutateObject(domainObject, path, value); | ||||
| @@ -501,8 +562,21 @@ export default class ObjectAPI { | ||||
|             //Destroy temporary mutable object | ||||
|             this.destroyMutable(mutableDomainObject); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Modify a domain object and save. | ||||
|      * @param {module:openmct.DomainObject} object the object to mutate | ||||
|      * @param {string} path the property to modify | ||||
|      * @param {*} value the new value for this property | ||||
|      * @method mutate | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|     mutate(domainObject, path, value) { | ||||
|         this.#mutate(domainObject, path, value); | ||||
|  | ||||
|         if (this.isTransactionActive()) { | ||||
|             console.log('objectAPI: mutate', JSON.parse(JSON.stringify(domainObject)), path, value); | ||||
|             this.transaction.add(domainObject); | ||||
|         } else { | ||||
|             this.save(domainObject); | ||||
| @@ -510,15 +584,19 @@ export default class ObjectAPI { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * Create a mutable domain object from an existing domain object | ||||
|      * @param {module:openmct.DomainObject} domainObject the object to make mutable | ||||
|      * @returns {MutableDomainObject} a mutable domain object that will automatically sync | ||||
|      * @method toMutable | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|     _toMutable(object) { | ||||
|     toMutable(domainObject) { | ||||
|         let mutableObject; | ||||
|  | ||||
|         if (object.isMutable) { | ||||
|             mutableObject = object; | ||||
|         if (domainObject.isMutable) { | ||||
|             mutableObject = domainObject; | ||||
|         } else { | ||||
|             mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter); | ||||
|             mutableObject = MutableDomainObject.createMutable(domainObject, this.eventEmitter); | ||||
|  | ||||
|             // Check if provider supports realtime updates | ||||
|             let identifier = utils.parseKeyString(mutableObject.identifier); | ||||
| @@ -526,9 +604,12 @@ export default class ObjectAPI { | ||||
|  | ||||
|             if (provider !== undefined | ||||
|                 && provider.observe !== undefined | ||||
|                 && this.SYNCHRONIZED_OBJECT_TYPES.includes(object.type)) { | ||||
|                 && this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) { | ||||
|                 let unobserve = provider.observe(identifier, (updatedModel) => { | ||||
|                     if (updatedModel.persisted > mutableObject.modified) { | ||||
|                     // modified can sometimes be undefined, so make it 0 in this case | ||||
|                     const mutableObjectModification = mutableObject.modified ?? Number.MIN_SAFE_INTEGER; | ||||
|  | ||||
|                     if (updatedModel.persisted > mutableObjectModification) { | ||||
|                         //Don't replace with a stale model. This can happen on slow connections when multiple mutations happen | ||||
|                         //in rapid succession and intermediate persistence states are returned by the observe function. | ||||
|                         updatedModel = this.applyGetInterceptors(identifier, updatedModel); | ||||
| @@ -582,7 +663,7 @@ export default class ObjectAPI { | ||||
|         if (domainObject.isMutable) { | ||||
|             return domainObject.$observe(path, callback); | ||||
|         } else { | ||||
|             let mutable = this._toMutable(domainObject); | ||||
|             let mutable = this.toMutable(domainObject); | ||||
|             mutable.$observe(path, callback); | ||||
|  | ||||
|             return () => mutable.$destroy(); | ||||
| @@ -671,12 +752,14 @@ export default class ObjectAPI { | ||||
|     } | ||||
|  | ||||
|     isTransactionActive() { | ||||
|         return Boolean(this.transaction && this.openmct.editor.isEditing()); | ||||
|         return this.transaction !== undefined && this.transaction !== null; | ||||
|     } | ||||
|  | ||||
|     #hasAlreadyBeenPersisted(domainObject) { | ||||
|         // modified can sometimes be undefined, so make it 0 in this case | ||||
|         const modified = domainObject.modified ?? Number.MIN_SAFE_INTEGER; | ||||
|         const result = domainObject.persisted !== undefined | ||||
|             && domainObject.persisted >= domainObject.modified; | ||||
|             && domainObject.persisted >= modified; | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
|   | ||||
| @@ -8,13 +8,27 @@ describe("The Object API", () => { | ||||
|     let mockDomainObject; | ||||
|     const TEST_NAMESPACE = "test-namespace"; | ||||
|     const TEST_KEY = "test-key"; | ||||
|     const USERNAME = 'Joan Q Public'; | ||||
|     const FIFTEEN_MINUTES = 15 * 60 * 1000; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         typeRegistry = jasmine.createSpyObj('typeRegistry', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         const userProvider = { | ||||
|             isLoggedIn() { | ||||
|                 return true; | ||||
|             }, | ||||
|             getCurrentUser() { | ||||
|                 return Promise.resolve({ | ||||
|                     getName() { | ||||
|                         return USERNAME; | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         }; | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.user.setProvider(userProvider); | ||||
|         objectAPI = openmct.objects; | ||||
|  | ||||
|         openmct.editor = {}; | ||||
| @@ -63,19 +77,63 @@ describe("The Object API", () => { | ||||
|                 mockProvider.update.and.returnValue(Promise.resolve(true)); | ||||
|                 objectAPI.addProvider(TEST_NAMESPACE, mockProvider); | ||||
|             }); | ||||
|             it("Calls 'create' on provider if object is new", () => { | ||||
|                 objectAPI.save(mockDomainObject); | ||||
|             it("Adds a 'created' timestamp to new objects", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.created).not.toBeUndefined(); | ||||
|             }); | ||||
|             it("Calls 'create' on provider if object is new", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockProvider.create).toHaveBeenCalled(); | ||||
|                 expect(mockProvider.update).not.toHaveBeenCalled(); | ||||
|             }); | ||||
|             it("Calls 'update' on provider if object is not new", () => { | ||||
|             it("Calls 'update' on provider if object is not new", async () => { | ||||
|                 mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES; | ||||
|                 mockDomainObject.modified = Date.now(); | ||||
|  | ||||
|                 objectAPI.save(mockDomainObject); | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockProvider.create).not.toHaveBeenCalled(); | ||||
|                 expect(mockProvider.update).toHaveBeenCalled(); | ||||
|             }); | ||||
|             describe("the persisted timestamp for existing objects", () => { | ||||
|                 let persistedTimestamp; | ||||
|                 beforeEach(() => { | ||||
|                     persistedTimestamp = Date.now() - FIFTEEN_MINUTES; | ||||
|                     mockDomainObject.persisted = persistedTimestamp; | ||||
|                     mockDomainObject.modified = Date.now(); | ||||
|                 }); | ||||
|  | ||||
|                 it("is updated", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted).toBeDefined(); | ||||
|                     expect(mockDomainObject.persisted > persistedTimestamp).toBe(true); | ||||
|                 }); | ||||
|                 it("is >= modified timestamp", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true); | ||||
|                 }); | ||||
|             }); | ||||
|             describe("the persisted timestamp for new objects", () => { | ||||
|                 it("is updated", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted).toBeDefined(); | ||||
|                 }); | ||||
|                 it("is >= modified timestamp", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             it("Sets the current user for 'createdBy' on new objects", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.createdBy).toBe(USERNAME); | ||||
|             }); | ||||
|             it("Sets the current user for 'modifedBy' on existing objects", async () => { | ||||
|                 mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES; | ||||
|                 mockDomainObject.modified = Date.now(); | ||||
|  | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.modifiedBy).toBe(USERNAME); | ||||
|             }); | ||||
|  | ||||
|             it("Does not persist if the object is unchanged", () => { | ||||
|                 mockDomainObject.persisted = | ||||
| @@ -320,7 +378,7 @@ describe("The Object API", () => { | ||||
|             beforeEach(function () { | ||||
|                 // Duplicate object to guarantee we are not sharing object instance, which would invalidate test | ||||
|                 testObjectDuplicate = JSON.parse(JSON.stringify(testObject)); | ||||
|                 mutableSecondInstance = objectAPI._toMutable(testObjectDuplicate); | ||||
|                 mutableSecondInstance = objectAPI.toMutable(testObjectDuplicate); | ||||
|             }); | ||||
|  | ||||
|             afterEach(() => { | ||||
|   | ||||
| @@ -41,6 +41,7 @@ export default class Transaction { | ||||
|         const save = this.objectAPI.save.bind(this.objectAPI); | ||||
|  | ||||
|         Object.values(this.dirtyObjects).forEach(object => { | ||||
|             console.log('transaction: commit, objects', object); | ||||
|             promiseArray.push(this.createDirtyObjectPromise(object, save)); | ||||
|         }); | ||||
|  | ||||
|   | ||||
| @@ -17,6 +17,7 @@ class Overlay extends EventEmitter { | ||||
|         dismissable = true, | ||||
|         element, | ||||
|         onDestroy, | ||||
|         onDismiss, | ||||
|         size | ||||
|     } = {}) { | ||||
|         super(); | ||||
| @@ -32,7 +33,7 @@ class Overlay extends EventEmitter { | ||||
|                 OverlayComponent: OverlayComponent | ||||
|             }, | ||||
|             provide: { | ||||
|                 dismiss: this.dismiss.bind(this), | ||||
|                 dismiss: this.notifyAndDismiss.bind(this), | ||||
|                 element, | ||||
|                 buttons, | ||||
|                 dismissable: this.dismissable | ||||
| @@ -43,6 +44,10 @@ class Overlay extends EventEmitter { | ||||
|         if (onDestroy) { | ||||
|             this.once('destroy', onDestroy); | ||||
|         } | ||||
|  | ||||
|         if (onDismiss) { | ||||
|             this.once('dismiss', onDismiss); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     dismiss() { | ||||
| @@ -51,6 +56,12 @@ class Overlay extends EventEmitter { | ||||
|         this.component.$destroy(); | ||||
|     } | ||||
|  | ||||
|     //Ensures that any callers are notified that the overlay is dismissed | ||||
|     notifyAndDismiss() { | ||||
|         this.emit('dismiss'); | ||||
|         this.dismiss(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      **/ | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class OverlayAPI { | ||||
|     dismissLastOverlay() { | ||||
|         let lastOverlay = this.activeOverlays[this.activeOverlays.length - 1]; | ||||
|         if (lastOverlay && lastOverlay.dismissable) { | ||||
|             lastOverlay.dismiss(); | ||||
|             lastOverlay.notifyAndDismiss(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -27,7 +27,6 @@ import TelemetryMetadataManager from './TelemetryMetadataManager'; | ||||
| import TelemetryValueFormatter from './TelemetryValueFormatter'; | ||||
| import DefaultMetadataProvider from './DefaultMetadataProvider'; | ||||
| import objectUtils from 'objectUtils'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class TelemetryAPI { | ||||
|  | ||||
| @@ -73,7 +72,7 @@ export default class TelemetryAPI { | ||||
|      * @returns {boolean} true if the object is a telemetry object. | ||||
|      */ | ||||
|     isTelemetryObject(domainObject) { | ||||
|         return Boolean(this.findMetadataProvider(domainObject)); | ||||
|         return Boolean(this.#findMetadataProvider(domainObject)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -87,7 +86,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     canProvideTelemetry(domainObject) { | ||||
|         return Boolean(this.findSubscriptionProvider(domainObject)) | ||||
|         return Boolean(this.#findSubscriptionProvider(domainObject)) | ||||
|                 || Boolean(this.findRequestProvider(domainObject)); | ||||
|     } | ||||
|  | ||||
| @@ -120,7 +119,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findSubscriptionProvider() { | ||||
|     #findSubscriptionProvider() { | ||||
|         const args = Array.prototype.slice.apply(arguments); | ||||
|         function supportsDomainObject(provider) { | ||||
|             return provider.supportsSubscribe.apply(provider, args); | ||||
| @@ -130,9 +129,10 @@ export default class TelemetryAPI { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * Returns a telemetry request provider that supports | ||||
|      * a given domain object and options. | ||||
|      */ | ||||
|     findRequestProvider(domainObject) { | ||||
|     findRequestProvider() { | ||||
|         const args = Array.prototype.slice.apply(arguments); | ||||
|         function supportsDomainObject(provider) { | ||||
|             return provider.supportsRequest.apply(provider, args); | ||||
| @@ -144,7 +144,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findMetadataProvider(domainObject) { | ||||
|     #findMetadataProvider(domainObject) { | ||||
|         return this.metadataProviders.filter(function (p) { | ||||
|             return p.supportsMetadata(domainObject); | ||||
|         })[0]; | ||||
| @@ -153,7 +153,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findLimitEvaluator(domainObject) { | ||||
|     #findLimitEvaluator(domainObject) { | ||||
|         return this.limitProviders.filter(function (p) { | ||||
|             return p.supportsLimits(domainObject); | ||||
|         })[0]; | ||||
| @@ -161,6 +161,7 @@ export default class TelemetryAPI { | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * Though used in TelemetryCollection as well | ||||
|      */ | ||||
|     standardizeRequestOptions(options) { | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'start')) { | ||||
| @@ -174,6 +175,10 @@ export default class TelemetryAPI { | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'domain')) { | ||||
|             options.domain = this.openmct.time.timeSystem().key; | ||||
|         } | ||||
|  | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) { | ||||
|             options.timeContext = this.openmct.time; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -241,7 +246,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * Request historical telemetry for a domain object. | ||||
|      * The `options` argument allows you to specify filters | ||||
|      * (start, end, etc.), sort order, and strategies for retrieving | ||||
|      * (start, end, etc.), sort order, time context, and strategies for retrieving | ||||
|      * telemetry (aggregation, latest available, etc.). | ||||
|      * | ||||
|      * @method request | ||||
| @@ -255,7 +260,7 @@ export default class TelemetryAPI { | ||||
|      */ | ||||
|     async request(domainObject) { | ||||
|         if (this.noRequestProviderForAllObjects) { | ||||
|             return Promise.resolve([]); | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         if (arguments.length === 1) { | ||||
| @@ -273,22 +278,24 @@ export default class TelemetryAPI { | ||||
|         if (!provider) { | ||||
|             this.requestAbortControllers.delete(abortController); | ||||
|  | ||||
|             return this.handleMissingRequestProvider(domainObject); | ||||
|             return this.#handleMissingRequestProvider(domainObject); | ||||
|         } | ||||
|  | ||||
|         arguments[1] = await this.applyRequestInterceptors(domainObject, arguments[1]); | ||||
|         try { | ||||
|             const telemetry = await provider.request(...arguments); | ||||
|  | ||||
|         return provider.request.apply(provider, arguments) | ||||
|             .catch((rejected) => { | ||||
|                 if (rejected.name !== 'AbortError') { | ||||
|                     this.openmct.notifications.error('Error requesting telemetry data, see console for details'); | ||||
|                     console.error(rejected); | ||||
|                 } | ||||
|             return telemetry; | ||||
|         } catch (error) { | ||||
|             if (error.name !== 'AbortError') { | ||||
|                 this.openmct.notifications.error('Error requesting telemetry data, see console for details'); | ||||
|                 console.error(error); | ||||
|             } | ||||
|  | ||||
|                 return Promise.reject(rejected); | ||||
|             }).finally(() => { | ||||
|                 this.requestAbortControllers.delete(abortController); | ||||
|             }); | ||||
|             throw new Error(error); | ||||
|         } finally { | ||||
|             this.requestAbortControllers.delete(abortController); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -306,7 +313,7 @@ export default class TelemetryAPI { | ||||
|      *          the subscription | ||||
|      */ | ||||
|     subscribe(domainObject, callback, options) { | ||||
|         const provider = this.findSubscriptionProvider(domainObject); | ||||
|         const provider = this.#findSubscriptionProvider(domainObject); | ||||
|  | ||||
|         if (!this.subscribeCache) { | ||||
|             this.subscribeCache = {}; | ||||
| @@ -353,7 +360,7 @@ export default class TelemetryAPI { | ||||
|      */ | ||||
|     getMetadata(domainObject) { | ||||
|         if (!this.metadataCache.has(domainObject)) { | ||||
|             const metadataProvider = this.findMetadataProvider(domainObject); | ||||
|             const metadataProvider = this.#findMetadataProvider(domainObject); | ||||
|             if (!metadataProvider) { | ||||
|                 return; | ||||
|             } | ||||
| @@ -369,33 +376,6 @@ export default class TelemetryAPI { | ||||
|         return this.metadataCache.get(domainObject); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return an array of valueMetadatas that are common to all supplied | ||||
|      * telemetry objects and match the requested hints. | ||||
|      * | ||||
|      */ | ||||
|     commonValuesForHints(metadatas, hints) { | ||||
|         const options = metadatas.map(function (metadata) { | ||||
|             const values = metadata.valuesForHints(hints); | ||||
|  | ||||
|             return _.keyBy(values, 'key'); | ||||
|         }).reduce(function (a, b) { | ||||
|             const results = {}; | ||||
|             Object.keys(a).forEach(function (key) { | ||||
|                 if (Object.prototype.hasOwnProperty.call(b, key)) { | ||||
|                     results[key] = a[key]; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             return results; | ||||
|         }); | ||||
|         const sortKeys = hints.map(function (h) { | ||||
|             return 'hints.' + h; | ||||
|         }); | ||||
|  | ||||
|         return _.sortBy(options, sortKeys); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get a value formatter for a given valueMetadata. | ||||
|      * | ||||
| @@ -450,7 +430,7 @@ export default class TelemetryAPI { | ||||
|      * | ||||
|      * @returns Promise | ||||
|      */ | ||||
|     handleMissingRequestProvider(domainObject) { | ||||
|     #handleMissingRequestProvider(domainObject) { | ||||
|         this.noRequestProviderForAllObjects = this.requestProviders.every(requestProvider => { | ||||
|             const supportsRequest = requestProvider.supportsRequest.apply(requestProvider, arguments); | ||||
|             const hasRequestProvider = Object.prototype.hasOwnProperty.call(requestProvider, 'request') && typeof requestProvider.request === 'function'; | ||||
| @@ -540,7 +520,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     getLimitEvaluator(domainObject) { | ||||
|         const provider = this.findLimitEvaluator(domainObject); | ||||
|         const provider = this.#findLimitEvaluator(domainObject); | ||||
|         if (!provider) { | ||||
|             return { | ||||
|                 evaluate: function () {} | ||||
| @@ -578,7 +558,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     getLimits(domainObject) { | ||||
|         const provider = this.findLimitEvaluator(domainObject); | ||||
|         const provider = this.#findLimitEvaluator(domainObject); | ||||
|         if (!provider || !provider.getLimits) { | ||||
|             return { | ||||
|                 limits: function () { | ||||
|   | ||||
| @@ -23,11 +23,11 @@ import { createOpenMct, resetApplicationState } from 'utils/testing'; | ||||
| import TelemetryAPI from './TelemetryAPI'; | ||||
| import TelemetryCollection from './TelemetryCollection'; | ||||
|  | ||||
| describe('Telemetry API', function () { | ||||
| describe('Telemetry API', () => { | ||||
|     let openmct; | ||||
|     let telemetryAPI; | ||||
|  | ||||
|     beforeEach(function () { | ||||
|     beforeEach(() => { | ||||
|         openmct = { | ||||
|             time: jasmine.createSpyObj('timeAPI', [ | ||||
|                 'timeSystem', | ||||
| @@ -47,11 +47,11 @@ describe('Telemetry API', function () { | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     describe('telemetry providers', function () { | ||||
|     describe('telemetry providers', () => { | ||||
|         let telemetryProvider; | ||||
|         let domainObject; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             telemetryProvider = jasmine.createSpyObj('telemetryProvider', [ | ||||
|                 'supportsSubscribe', | ||||
|                 'subscribe', | ||||
| @@ -73,19 +73,16 @@ describe('Telemetry API', function () { | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         it('provides consistent results without providers', function (done) { | ||||
|         it('provides consistent results without providers', async () => { | ||||
|             const unsubscribe = telemetryAPI.subscribe(domainObject); | ||||
|  | ||||
|             expect(unsubscribe).toEqual(jasmine.any(Function)); | ||||
|  | ||||
|             telemetryAPI.request(domainObject) | ||||
|                 .then((data) => { | ||||
|                     expect(data).toEqual([]); | ||||
|                 }) | ||||
|                 .finally(done); | ||||
|             const data = await telemetryAPI.request(domainObject); | ||||
|             expect(data).toEqual([]); | ||||
|         }); | ||||
|  | ||||
|         it('skips providers that do not match', function (done) { | ||||
|         it('skips providers that do not match', async () => { | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(false); | ||||
|             telemetryProvider.supportsRequest.and.returnValue(false); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
| @@ -98,14 +95,13 @@ describe('Telemetry API', function () { | ||||
|             expect(telemetryProvider.subscribe).not.toHaveBeenCalled(); | ||||
|             expect(unsubscribe).toEqual(jasmine.any(Function)); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then((response) => { | ||||
|                 expect(telemetryProvider.supportsRequest) | ||||
|                     .toHaveBeenCalledWith(domainObject, jasmine.any(Object)); | ||||
|                 expect(telemetryProvider.request).not.toHaveBeenCalled(); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             expect(telemetryProvider.supportsRequest) | ||||
|                 .toHaveBeenCalledWith(domainObject, jasmine.any(Object)); | ||||
|             expect(telemetryProvider.request).not.toHaveBeenCalled(); | ||||
|         }); | ||||
|  | ||||
|         it('sends subscribe calls to matching providers', function () { | ||||
|         it('sends subscribe calls to matching providers', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -133,7 +129,7 @@ describe('Telemetry API', function () { | ||||
|             expect(callback).not.toHaveBeenCalledWith('otherValue'); | ||||
|         }); | ||||
|  | ||||
|         it('subscribes once per object', function () { | ||||
|         it('subscribes once per object', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -164,7 +160,7 @@ describe('Telemetry API', function () { | ||||
|             expect(callbacktwo).not.toHaveBeenCalledWith('anotherValue'); | ||||
|         }); | ||||
|  | ||||
|         it('only deletes subscription cache when there are no more subscribers', function () { | ||||
|         it('only deletes subscription cache when there are no more subscribers', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -187,7 +183,7 @@ describe('Telemetry API', function () { | ||||
|             unsubscribeThree(); | ||||
|         }); | ||||
|  | ||||
|         it('does subscribe/unsubscribe', function () { | ||||
|         it('does subscribe/unsubscribe', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -203,7 +199,7 @@ describe('Telemetry API', function () { | ||||
|             unsubscribe(); | ||||
|         }); | ||||
|  | ||||
|         it('subscribes for different object', function () { | ||||
|         it('subscribes for different object', () => { | ||||
|             const unsubFuncs = []; | ||||
|             const notifiers = []; | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -243,120 +239,120 @@ describe('Telemetry API', function () { | ||||
|             expect(unsubFuncs[1]).toHaveBeenCalled(); | ||||
|         }); | ||||
|  | ||||
|         it('sends requests to matching providers', function (done) { | ||||
|         it('sends requests to matching providers', async () => { | ||||
|             const telemPromise = Promise.resolve([]); | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(telemPromise); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then(() => { | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     jasmine.any(Object) | ||||
|                 ); | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     jasmine.any(Object) | ||||
|                 ); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 jasmine.any(Object) | ||||
|             ); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 jasmine.any(Object) | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         it('generates default request options', function (done) { | ||||
|         it('generates default request options', async () => { | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then(() => { | ||||
|                 const { signal } = new AbortController(); | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         signal, | ||||
|                         start: 0, | ||||
|                         end: 1, | ||||
|                         domain: 'system' | ||||
|                     } | ||||
|                 ); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             const { signal } = new AbortController(); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         signal, | ||||
|                         start: 0, | ||||
|                         end: 1, | ||||
|                         domain: 'system' | ||||
|                     } | ||||
|                 ); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 telemetryProvider.supportsRequest.calls.reset(); | ||||
|                 telemetryProvider.request.calls.reset(); | ||||
|             telemetryProvider.supportsRequest.calls.reset(); | ||||
|             telemetryProvider.request.calls.reset(); | ||||
|  | ||||
|                 telemetryAPI.request(domainObject, {}).then(() => { | ||||
|                     expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                         jasmine.any(Object), | ||||
|                         { | ||||
|                             signal, | ||||
|                             start: 0, | ||||
|                             end: 1, | ||||
|                             domain: 'system' | ||||
|                         } | ||||
|                     ); | ||||
|  | ||||
|                     expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                         jasmine.any(Object), | ||||
|                         { | ||||
|                             signal, | ||||
|                             start: 0, | ||||
|                             end: 1, | ||||
|                             domain: 'system' | ||||
|                         } | ||||
|                     ); | ||||
|                 }); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject, {}); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         it('do not overwrite existing request options', function (done) { | ||||
|         it('do not overwrite existing request options', async () => { | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject, { | ||||
|             await telemetryAPI.request(domainObject, { | ||||
|                 start: 20, | ||||
|                 end: 30, | ||||
|                 domain: 'someDomain' | ||||
|             }).then(() => { | ||||
|                 const { signal } = new AbortController(); | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         start: 20, | ||||
|                         end: 30, | ||||
|                         domain: 'someDomain', | ||||
|                         signal | ||||
|                     } | ||||
|                 ); | ||||
|             }); | ||||
|             const { signal } = new AbortController(); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     start: 20, | ||||
|                     end: 30, | ||||
|                     domain: 'someDomain', | ||||
|                     signal, | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         start: 20, | ||||
|                         end: 30, | ||||
|                         domain: 'someDomain', | ||||
|                         signal | ||||
|                     } | ||||
|                 ); | ||||
|  | ||||
|             }).finally(done); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     start: 20, | ||||
|                     end: 30, | ||||
|                     domain: 'someDomain', | ||||
|                     signal, | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('metadata', function () { | ||||
|     describe('metadata', () => { | ||||
|         let mockMetadata = {}; | ||||
|         let mockObjectType = { | ||||
|             definition: {} | ||||
|         }; | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             telemetryAPI.addProvider({ | ||||
|                 key: 'mockMetadataProvider', | ||||
|                 supportsMetadata() { | ||||
| @@ -369,7 +365,7 @@ describe('Telemetry API', function () { | ||||
|             openmct.types.get.and.returnValue(mockObjectType); | ||||
|         }); | ||||
|  | ||||
|         it('respects explicit priority', function () { | ||||
|         it('respects explicit priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -408,7 +404,7 @@ describe('Telemetry API', function () { | ||||
|                 expect(value.hints.priority).toBe(index + 1); | ||||
|             }); | ||||
|         }); | ||||
|         it('if no explicit priority, defaults to order defined', function () { | ||||
|         it('if no explicit priority, defaults to order defined', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -435,7 +431,7 @@ describe('Telemetry API', function () { | ||||
|                 expect(value.key).toBe(mockMetadata.values[index].key); | ||||
|             }); | ||||
|         }); | ||||
|         it('respects domain priority', function () { | ||||
|         it('respects domain priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -477,7 +473,7 @@ describe('Telemetry API', function () { | ||||
|             expect(values[0].key).toBe('timestamp-local'); | ||||
|             expect(values[1].key).toBe('timestamp-utc'); | ||||
|         }); | ||||
|         it('respects range priority', function () { | ||||
|         it('respects range priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -519,7 +515,7 @@ describe('Telemetry API', function () { | ||||
|             expect(values[0].key).toBe('cos'); | ||||
|             expect(values[1].key).toBe('sin'); | ||||
|         }); | ||||
|         it('respects priority and domain ordering', function () { | ||||
|         it('respects priority and domain ordering', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "id", | ||||
| @@ -588,7 +584,7 @@ describe('Telemetry API', function () { | ||||
|             definition: {} | ||||
|         }; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             openmct.telemetry = telemetryAPI; | ||||
|             telemetryAPI.addProvider({ | ||||
|                 key: 'mockMetadataProvider', | ||||
| @@ -644,16 +640,14 @@ describe('Telemetery', () => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     it('should not abort request without navigation', function (done) { | ||||
|     it('should not abort request without navigation', async () => { | ||||
|         telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|         telemetryAPI.request({}).finally(() => { | ||||
|             expect(watchedSignal.aborted).toBe(false); | ||||
|             done(); | ||||
|         }); | ||||
|         await telemetryAPI.request({}); | ||||
|         expect(watchedSignal.aborted).toBe(false); | ||||
|     }); | ||||
|  | ||||
|     it('should abort request on navigation', function (done) { | ||||
|     it('should abort request on navigation', (done) => { | ||||
|         telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|         telemetryAPI.request({}).finally(() => { | ||||
|   | ||||
| @@ -229,6 +229,25 @@ describe("The Time API", function () { | ||||
|             expect(api.clock()).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('Provides a default time context', () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             expect(timeContext).not.toBe(null); | ||||
|         }); | ||||
|  | ||||
|         it("Without a clock, is in fixed time mode", () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             expect(timeContext.isRealTime()).toBe(false); | ||||
|         }); | ||||
|  | ||||
|         it("Provided a clock, is in real-time mode", () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             timeContext.clock('mts', { | ||||
|                 start: 0, | ||||
|                 end: 1 | ||||
|             }); | ||||
|             expect(timeContext.isRealTime()).toBe(true); | ||||
|         }); | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     it("on tick, observes offsets, and indicates tick in bounds callback", function () { | ||||
|   | ||||
| @@ -362,6 +362,18 @@ class TimeContext extends EventEmitter { | ||||
|         this.boundsVal = newBounds; | ||||
|         this.emit('bounds', this.boundsVal, true); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if this time context is in real-time mode or not. | ||||
|      * @returns {boolean} true if this context is in real-time mode, false if not | ||||
|     */ | ||||
|     isRealTime() { | ||||
|         if (this.clock()) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default TimeContext; | ||||
|   | ||||
| @@ -114,6 +114,8 @@ export default { | ||||
|         this.formats = this.openmct.telemetry.getFormatMap(this.metadata); | ||||
|         this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|         this.timeContext = this.openmct.time.getContextForView(this.objectPath); | ||||
|  | ||||
|         this.limitEvaluator = this.openmct | ||||
|             .telemetry | ||||
|             .limitEvaluator(this.domainObject); | ||||
| @@ -134,7 +136,8 @@ export default { | ||||
|  | ||||
|         this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, { | ||||
|             size: 1, | ||||
|             strategy: 'latest' | ||||
|             strategy: 'latest', | ||||
|             timeContext: this.timeContext | ||||
|         }); | ||||
|         this.telemetryCollection.on('add', this.setLatestValues); | ||||
|         this.telemetryCollection.on('clear', this.resetValues); | ||||
|   | ||||
| @@ -112,11 +112,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         removeFromComposition(telemetryObject) { | ||||
|             let composition = this.domainObject.composition.filter(id => | ||||
|                 !this.openmct.objects.areIdsEqual(id, telemetryObject.identifier) | ||||
|             ); | ||||
|  | ||||
|             this.openmct.objects.mutate(this.domainObject, 'composition', composition); | ||||
|             this.composition.remove(telemetryObject); | ||||
|         }, | ||||
|         addTelemetryObject(telemetryObject) { | ||||
|             // grab information we need from the added telmetry object | ||||
|   | ||||
| @@ -104,10 +104,14 @@ export default { | ||||
|             this.$set(this.plotSeries, this.plotSeries.length, series); | ||||
|             this.setAxesLabels(); | ||||
|         }, | ||||
|         removeSeries(series) { | ||||
|             const index = this.plotSeries.findIndex(plotSeries => this.openmct.objects.areIdsEqual(series.identifier, plotSeries.identifier)); | ||||
|             if (index !== undefined) { | ||||
|                 this.$delete(this.plotSeries, index); | ||||
|         removeSeries(seriesKey) { | ||||
|             const seriesIndex = this.plotSeries.findIndex( | ||||
|                 plotSeries => this.openmct.objects.areIdsEqual(seriesKey, plotSeries.identifier) | ||||
|             ); | ||||
|  | ||||
|             const foundSeries = seriesIndex > -1; | ||||
|             if (foundSeries) { | ||||
|                 this.$delete(this.plotSeries, seriesIndex); | ||||
|                 this.setAxesLabels(); | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -68,6 +68,7 @@ export default function ClockPlugin(options) { | ||||
|                     ] | ||||
|                 }, | ||||
|                 { | ||||
|                     ariaLabel: "12 or 24 hour clock", | ||||
|                     control: 'select', | ||||
|                     options: [ | ||||
|                         { | ||||
|   | ||||