Compare commits
87 Commits
include-e2
...
fix-remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f26177092 | ||
|
|
3deb2e3dc2 | ||
|
|
d6e80447ab | ||
|
|
1a4bd0fb55 | ||
|
|
80f89c7609 | ||
|
|
b82649772f | ||
|
|
7f2ed27106 | ||
|
|
4d2fb9e811 | ||
|
|
ba9143dba7 | ||
|
|
57e02db6b5 | ||
|
|
d54335d21c | ||
|
|
e0ed0bb6e2 | ||
|
|
ed3fd8f965 | ||
|
|
e6d59c61d1 | ||
|
|
b74b27c464 | ||
|
|
d35e161701 | ||
|
|
653cb62f9c | ||
|
|
19b3232fa0 | ||
|
|
19892aab53 | ||
|
|
a168ce25cf | ||
|
|
189c58f952 | ||
|
|
0dfc028e1b | ||
|
|
77e93f1aee | ||
|
|
394fbbe61b | ||
|
|
40afb04f0c | ||
|
|
be73b0158a | ||
|
|
625205f24b | ||
|
|
a706a8b73e | ||
|
|
1ddf5e5137 | ||
|
|
a79646a915 | ||
|
|
d5266e7ac7 | ||
|
|
05de7ee2e0 | ||
|
|
dad88112c4 | ||
|
|
202d6d8c5d | ||
|
|
e70bcc414c | ||
|
|
7bb4a136d7 | ||
|
|
8af3b4309f | ||
|
|
bed3d83fd7 | ||
|
|
efda42cf6d | ||
|
|
e8ee5b3fc9 | ||
|
|
393cb9767f | ||
|
|
8b5daad65c | ||
|
|
fabfecdb3e | ||
|
|
a2d8b13204 | ||
|
|
4b14d2d6d2 | ||
|
|
d545124942 | ||
|
|
6abdbfdff0 | ||
|
|
500e655476 | ||
|
|
5e1f026db2 | ||
|
|
d9efae98c8 | ||
|
|
091f6406a8 | ||
|
|
42a0e503cc | ||
|
|
4697352f60 | ||
|
|
015c764ab3 | ||
|
|
8fe465d9fc | ||
|
|
9c1368885a | ||
|
|
391c0b2e7c | ||
|
|
2ae061dbcd | ||
|
|
41fc502564 | ||
|
|
b4554d2fc1 | ||
|
|
feba5f6d3b | ||
|
|
4357d35f4a | ||
|
|
5041f80e5b | ||
|
|
9e23f79bc8 | ||
|
|
bd1e869f6a | ||
|
|
e4a36532e7 | ||
|
|
2bc2316613 | ||
|
|
2fa36b2176 | ||
|
|
efa38d779e | ||
|
|
951cc6ec0d | ||
|
|
ef4b8a9934 | ||
|
|
c14b48917e | ||
|
|
26165d0a99 | ||
|
|
f7cf3f72c2 | ||
|
|
cb8e09c9f9 | ||
|
|
026eb86f5f | ||
|
|
866859a937 | ||
|
|
afc54f41f6 | ||
|
|
72c980f991 | ||
|
|
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
|
||||
|
||||
@@ -10,7 +10,7 @@ accept changes from external contributors.
|
||||
|
||||
The short version:
|
||||
|
||||
1. Write your contribution or describe your idea in the form of an [GitHub issue](https://github.com/nasa/openmct/issues/new/choose) or [Starting a GitHub Discussion](https://github.com/nasa/openmct/discussions)
|
||||
1. Write your contribution or describe your idea in the form of a [GitHub issue](https://github.com/nasa/openmct/issues/new/choose) or [start a GitHub discussion](https://github.com/nasa/openmct/discussions).
|
||||
2. Make sure your contribution meets code, test, and commit message
|
||||
standards as described below.
|
||||
3. Submit a pull request from a topic branch back to `master`. Include a check
|
||||
|
||||
16
README.md
@@ -6,10 +6,8 @@ Please visit our [Official Site](https://nasa.github.io/openmct/) and [Getting S
|
||||
|
||||
Once you've created something amazing with Open MCT, showcase your work in our GitHub Discussions [Show and Tell](https://github.com/nasa/openmct/discussions/categories/show-and-tell) section. We love seeing unique and wonderful implementations of Open MCT!
|
||||
|
||||
## See Open MCT in Action
|
||||

|
||||
|
||||
Try Open MCT now with our [live demo](https://openmct-demo.herokuapp.com/).
|
||||

|
||||
|
||||
## Building and Running Open MCT Locally
|
||||
|
||||
@@ -30,6 +28,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 +43,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 +62,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 +98,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.
|
||||
@@ -276,14 +276,36 @@ Skipping based on browser version (Rarely used): <https://github.com/microsoft/p
|
||||
- Leverage `await page.goto('./', { waitUntil: 'networkidle' });`
|
||||
- Avoid repeated setup to test to test a single assertion. Write longer tests with multiple soft assertions.
|
||||
|
||||
### How to write a great test (TODO)
|
||||
### How to write a great test (WIP)
|
||||
|
||||
- Use our [App Actions](./appActions.js) for performing common actions whenever applicable.
|
||||
- If you create an object outside of using the `createDomainObjectWithDefaults` App Action, make sure to fill in the 'Notes' section of your object with `page.testNotes`:
|
||||
|
||||
```js
|
||||
// Fill the "Notes" section with information about the
|
||||
// currently running test and its project.
|
||||
const { testNotes } = page;
|
||||
const notesInput = page.locator('form[name="mctForm"] #notes-textarea');
|
||||
await notesInput.fill(testNotes);
|
||||
```
|
||||
|
||||
#### How to write a great visual test (TODO)
|
||||
|
||||
#### How to write a great network test
|
||||
|
||||
- Where possible, it is best to mock out third-party network activity to ensure we are testing application behavior of Open MCT.
|
||||
- It is best to be as specific as possible about the expected network request/response structures in creating your mocks.
|
||||
- Make sure to only mock requests which are relevant to the specific behavior being tested.
|
||||
- Where possible, network requests and responses should be treated in an order-agnostic manner, as the order in which certain requests/responses happen is dynamic and subject to change.
|
||||
|
||||
Some examples of mocking network responses in regards to CouchDB can be found in our [couchdb.e2e.spec.js](./tests/functional/couchdb.e2e.spec.js) test file.
|
||||
|
||||
### Best Practices
|
||||
|
||||
For now, our best practices exist as self-tested, living documentation in our [exampleTemplate.e2e.spec.js](./tests/framework/exampleTemplate.e2e.spec.js) file.
|
||||
|
||||
For best practices with regards to mocking network responses, see our [couchdb.e2e.spec.js](./tests/functional/couchdb.e2e.spec.js) file.
|
||||
|
||||
### Tips & Tricks (TODO)
|
||||
|
||||
The following contains a list of tips and tricks which don't exactly fit into a FAQ or Best Practices doc.
|
||||
|
||||
@@ -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
|
||||
@@ -67,13 +72,18 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click the object specified by 'type'
|
||||
await page.click(`li:text("${type}")`);
|
||||
await page.click(`li[role='menuitem']: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);
|
||||
|
||||
if (page.testNotes) {
|
||||
// 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
|
||||
@@ -96,8 +106,8 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine
|
||||
}
|
||||
|
||||
return {
|
||||
name: name || `Unnamed ${type}`,
|
||||
uuid: uuid,
|
||||
name,
|
||||
uuid,
|
||||
url: objectUrl
|
||||
};
|
||||
}
|
||||
@@ -225,15 +235,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,9 +25,9 @@
|
||||
*
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../baseFixtures');
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
|
||||
test.describe("CouchDB Status Indicator @couchdb", () => {
|
||||
test.describe("CouchDB Status Indicator with mocked responses @couchdb", () => {
|
||||
test.use({ failOnConsoleError: false });
|
||||
//TODO BeforeAll Verify CouchDB Connectivity with APIContext
|
||||
test('Shows green if connected', async ({ page }) => {
|
||||
@@ -71,38 +71,41 @@ test.describe("CouchDB Status Indicator @couchdb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("CouchDB initialization @couchdb", () => {
|
||||
test.describe("CouchDB initialization with mocked responses @couchdb", () => {
|
||||
test.use({ failOnConsoleError: false });
|
||||
test("'My Items' folder is created if it doesn't exist", async ({ page }) => {
|
||||
// Store any relevant PUT requests that happen on the page
|
||||
const createMineFolderRequests = [];
|
||||
page.on('request', req => {
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (req.method() === 'PUT' && req.url().endsWith('openmct/mine')) {
|
||||
createMineFolderRequests.push(req);
|
||||
}
|
||||
});
|
||||
const mockedMissingObjectResponsefromCouchDB = {
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
};
|
||||
|
||||
// Override the first request to GET openmct/mine to return a 404
|
||||
await page.route('**/openmct/mine', route => {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
// Override the first request to GET openmct/mine to return a 404.
|
||||
// This simulates the case of starting Open MCT with a fresh database
|
||||
// and no "My Items" folder created yet.
|
||||
await page.route('**/mine', route => {
|
||||
route.fulfill(mockedMissingObjectResponsefromCouchDB);
|
||||
}, { times: 1 });
|
||||
|
||||
// Go to baseURL
|
||||
// Set up promise to verify that a PUT request to create "My Items"
|
||||
// folder was made.
|
||||
const putMineFolderRequest = page.waitForRequest(req =>
|
||||
req.url().endsWith('/mine')
|
||||
&& req.method() === 'PUT');
|
||||
|
||||
// Set up promise to verify that a GET request to retrieve "My Items"
|
||||
// folder was made.
|
||||
const getMineFolderRequest = page.waitForRequest(req =>
|
||||
req.url().endsWith('/mine')
|
||||
&& req.method() === 'GET');
|
||||
|
||||
// Go to baseURL.
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Verify that error banner is displayed
|
||||
const bannerMessage = await page.locator('.c-message-banner__message').innerText();
|
||||
expect(bannerMessage).toEqual('Failed to retrieve object mine');
|
||||
|
||||
// Verify that a PUT request to create "My Items" folder was made
|
||||
await expect.poll(() => createMineFolderRequests.length, {
|
||||
message: 'Verify that PUT request to create "mine" folder was made',
|
||||
timeout: 1000
|
||||
}).toBeGreaterThanOrEqual(1);
|
||||
// Wait for both requests to resolve.
|
||||
await Promise.all([
|
||||
putMineFolderRequest,
|
||||
getMineFolderRequest
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
This test suite is dedicated to tests which verify form functionality in isolation
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../baseFixtures');
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../appActions');
|
||||
const genUuid = require('uuid').v4;
|
||||
const path = require('path');
|
||||
|
||||
const TEST_FOLDER = 'test folder';
|
||||
@@ -43,7 +45,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 +54,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 +93,146 @@ 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('Can create an object after a conflict error @couchdb @2p', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5982'
|
||||
});
|
||||
|
||||
const page2 = await page.context().newPage();
|
||||
|
||||
// Both pages: Go to baseURL
|
||||
await Promise.all([
|
||||
page.goto('./', { waitUntil: 'networkidle' }),
|
||||
page2.goto('./', { waitUntil: 'networkidle' })
|
||||
]);
|
||||
|
||||
// Both pages: Click the Create button
|
||||
await Promise.all([
|
||||
page.click('button:has-text("Create")'),
|
||||
page2.click('button:has-text("Create")')
|
||||
]);
|
||||
|
||||
// Both pages: Click "Clock" in the Create menu
|
||||
await Promise.all([
|
||||
page.click(`li[role='menuitem']:text("Clock")`),
|
||||
page2.click(`li[role='menuitem']:text("Clock")`)
|
||||
]);
|
||||
|
||||
// Generate unique names for both objects
|
||||
const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]');
|
||||
const nameInput2 = page2.locator('form[name="mctForm"] .first input[type="text"]');
|
||||
|
||||
// Both pages: Fill in the 'Name' form field.
|
||||
await Promise.all([
|
||||
nameInput.fill(""),
|
||||
nameInput.fill(`Clock:${genUuid()}`),
|
||||
nameInput2.fill(""),
|
||||
nameInput2.fill(`Clock:${genUuid()}`)
|
||||
]);
|
||||
|
||||
// Both pages: Fill the "Notes" section with information about the
|
||||
// currently running test and its project.
|
||||
const testNotes = page.testNotes;
|
||||
const notesInput = page.locator('form[name="mctForm"] #notes-textarea');
|
||||
const notesInput2 = page2.locator('form[name="mctForm"] #notes-textarea');
|
||||
await Promise.all([
|
||||
notesInput.fill(testNotes),
|
||||
notesInput2.fill(testNotes)
|
||||
]);
|
||||
|
||||
// Page 2: Click "OK" to create the domain object and wait for navigation.
|
||||
// This will update the composition of the parent folder, setting the
|
||||
// conditions for a conflict error from the first page.
|
||||
await Promise.all([
|
||||
page2.waitForLoadState(),
|
||||
page2.click('[aria-label="Save"]'),
|
||||
// Wait for Save Banner to appear
|
||||
page2.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
|
||||
// Close Page 2, we're done with it.
|
||||
await page2.close();
|
||||
|
||||
// Page 1: Click "OK" to create the domain object and wait for navigation.
|
||||
// This will trigger a conflict error upon attempting to update
|
||||
// the composition of the parent folder.
|
||||
await Promise.all([
|
||||
page.waitForLoadState(),
|
||||
page.click('[aria-label="Save"]'),
|
||||
// Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
|
||||
// Page 1: Verify that the conflict has occurred and an error notification is displayed.
|
||||
await expect(page.locator('.c-message-banner__message', {
|
||||
hasText: "Conflict detected while saving mine"
|
||||
})).toBeVisible();
|
||||
|
||||
// Page 1: Start logging console errors from this point on
|
||||
let errors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Page 1: Try to create a clock with the page that received the conflict.
|
||||
const clockAfterConflict = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock'
|
||||
});
|
||||
|
||||
// Page 1: Wait for save progress dialog to appear/disappear
|
||||
await page.locator('.c-message-banner__message', {
|
||||
hasText: 'Do not navigate away from this page or close this browser tab while this message is displayed.',
|
||||
state: 'visible'
|
||||
}).waitFor({ state: 'hidden' });
|
||||
|
||||
// Page 1: Navigate to 'My Items' and verify that the second clock was created
|
||||
await page.goto('./#/browse/mine');
|
||||
await expect(page.locator(`.c-grid-item__name[title="${clockAfterConflict.name}"]`)).toBeVisible();
|
||||
|
||||
// Verify no console errors occurred
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
124
e2e/tests/functional/plugins/gauge/gauge.e2e.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/*****************************************************************************
|
||||
* 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('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
const uuid = require('uuid').v4;
|
||||
|
||||
test.describe('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' });
|
||||
});
|
||||
|
||||
test('Can add and remove telemetry sources @unstable', async ({ page }) => {
|
||||
// Create the gauge with defaults
|
||||
const gauge = await createDomainObjectWithDefaults(page, { type: 'Gauge' });
|
||||
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();
|
||||
});
|
||||
test('Can create a non-default Gauge', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5356'
|
||||
});
|
||||
//Click the Create button
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click the object specified by 'type'
|
||||
await page.click(`li[role='menuitem']:text("Gauge")`);
|
||||
// FIXME: We need better selectors for these custom form controls
|
||||
const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0');
|
||||
await displayCurrentValueSwitch.setChecked(false);
|
||||
await page.click('button[aria-label="Save"]');
|
||||
|
||||
// TODO: Verify changes in the UI
|
||||
});
|
||||
test('Can edit a single Gauge-specific property', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5985'
|
||||
});
|
||||
|
||||
// Create the gauge with defaults
|
||||
await createDomainObjectWithDefaults(page, { type: 'Gauge' });
|
||||
await page.click('button[title="More options"]');
|
||||
await page.click('li[role="menuitem"]:has-text("Edit Properties")');
|
||||
// FIXME: We need better selectors for these custom form controls
|
||||
const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0');
|
||||
await displayCurrentValueSwitch.setChecked(false);
|
||||
await page.click('button[aria-label="Save"]');
|
||||
|
||||
// TODO: Verify changes in the UI
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
]);
|
||||
|
||||
139
e2e/tests/functional/plugins/plot/plotRendering.e2e.spec.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/*****************************************************************************
|
||||
* 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);
|
||||
//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);
|
||||
});
|
||||
|
||||
test('Plot is rendered when infinity values exist', async ({ page }) => {
|
||||
// Edit Plot
|
||||
await editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject);
|
||||
|
||||
//Get pixel data from Canvas
|
||||
const plotPixelSize = await getCanvasPixelsWithData(page);
|
||||
expect(plotPixelSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This function edits a sine wave generator with the default options and enables the infinity values option.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {import('../../../../appActions').CreateObjectInfo} sineWaveGeneratorObject
|
||||
* @returns {Promise<CreatedObjectInfo>} An object containing information about the edited domain object.
|
||||
*/
|
||||
async function editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject) {
|
||||
await page.goto(sineWaveGeneratorObject.url);
|
||||
// Edit LAD table
|
||||
await page.locator('[title="More options"]').click();
|
||||
await page.locator('[title="Edit properties of this object."]').click();
|
||||
// Modify the infinity option to true
|
||||
const infinityInput = page.locator('[aria-label="Include Infinity Values"]');
|
||||
await infinityInput.click();
|
||||
|
||||
// Click OK button and wait for Navigate event
|
||||
await Promise.all([
|
||||
page.waitForLoadState(),
|
||||
page.click('[aria-label="Save"]'),
|
||||
// Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
|
||||
// FIXME: Changes to SWG properties should be reflected on save, but they're not?
|
||||
// Thus, navigate away and back to the object.
|
||||
await page.goto('./#/browse/mine');
|
||||
await page.goto(sineWaveGeneratorObject.url);
|
||||
|
||||
await page.locator('c-progress-bar c-telemetry-table__progress-bar').waitFor({
|
||||
state: 'hidden'
|
||||
});
|
||||
|
||||
// FIXME: The progress bar disappears on series data load, not on plot render,
|
||||
// so wait for a half a second before evaluating the canvas.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function getCanvasPixelsWithData(page) {
|
||||
const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve));
|
||||
|
||||
await page.evaluate(() => {
|
||||
// The document canvas is where the plot points and lines are drawn.
|
||||
// The only way to access the canvas is using document (using page.evaluate)
|
||||
let data;
|
||||
let canvas;
|
||||
let ctx;
|
||||
canvas = document.querySelector('canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const imageDataValues = Object.values(data);
|
||||
let plotPixels = [];
|
||||
// Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four.
|
||||
// The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order.
|
||||
for (let i = 0; i < imageDataValues.length;) {
|
||||
if (imageDataValues[i] > 0) {
|
||||
plotPixels.push({
|
||||
startIndex: i,
|
||||
endIndex: i + 3,
|
||||
value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})`
|
||||
});
|
||||
}
|
||||
|
||||
i = i + 4;
|
||||
|
||||
}
|
||||
|
||||
window.getCanvasValue(plotPixels.length);
|
||||
});
|
||||
|
||||
return getTelemValuePromise;
|
||||
}
|
||||
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
|
||||
|
||||
@@ -33,7 +33,8 @@ define([
|
||||
dataRateInHz: 1,
|
||||
randomness: 0,
|
||||
phase: 0,
|
||||
loadDelay: 0
|
||||
loadDelay: 0,
|
||||
infinityValues: false
|
||||
};
|
||||
|
||||
function GeneratorProvider(openmct) {
|
||||
@@ -56,7 +57,8 @@ define([
|
||||
'dataRateInHz',
|
||||
'randomness',
|
||||
'phase',
|
||||
'loadDelay'
|
||||
'loadDelay',
|
||||
'infinityValues'
|
||||
];
|
||||
|
||||
request = request || {};
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
name: data.name,
|
||||
utc: nextStep,
|
||||
yesterday: nextStep - 60 * 60 * 24 * 1000,
|
||||
sin: sin(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness),
|
||||
sin: sin(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness, data.infinityValues),
|
||||
wavelengths: wavelengths(),
|
||||
intensities: intensities(),
|
||||
cos: cos(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness)
|
||||
cos: cos(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness, data.infinityValues)
|
||||
}
|
||||
});
|
||||
nextStep += step;
|
||||
@@ -117,6 +117,7 @@
|
||||
var phase = request.phase;
|
||||
var randomness = request.randomness;
|
||||
var loadDelay = Math.max(request.loadDelay, 0);
|
||||
var infinityValues = request.infinityValues;
|
||||
|
||||
var step = 1000 / dataRateInHz;
|
||||
var nextStep = start - (start % step) + step;
|
||||
@@ -127,10 +128,10 @@
|
||||
data.push({
|
||||
utc: nextStep,
|
||||
yesterday: nextStep - 60 * 60 * 24 * 1000,
|
||||
sin: sin(nextStep, period, amplitude, offset, phase, randomness),
|
||||
sin: sin(nextStep, period, amplitude, offset, phase, randomness, infinityValues),
|
||||
wavelengths: wavelengths(),
|
||||
intensities: intensities(),
|
||||
cos: cos(nextStep, period, amplitude, offset, phase, randomness)
|
||||
cos: cos(nextStep, period, amplitude, offset, phase, randomness, infinityValues)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,12 +156,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
function cos(timestamp, period, amplitude, offset, phase, randomness) {
|
||||
function cos(timestamp, period, amplitude, offset, phase, randomness, infinityValues) {
|
||||
if (infinityValues && Math.random() > 0.5) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return amplitude
|
||||
* Math.cos(phase + (timestamp / period / 1000 * Math.PI * 2)) + (amplitude * Math.random() * randomness) + offset;
|
||||
}
|
||||
|
||||
function sin(timestamp, period, amplitude, offset, phase, randomness) {
|
||||
function sin(timestamp, period, amplitude, offset, phase, randomness, infinityValues) {
|
||||
if (infinityValues && Math.random() > 0.5) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return amplitude
|
||||
* Math.sin(phase + (timestamp / period / 1000 * Math.PI * 2)) + (amplitude * Math.random() * randomness) + offset;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,16 @@ define([
|
||||
"telemetry",
|
||||
"loadDelay"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Include Infinity Values",
|
||||
control: "toggleSwitch",
|
||||
cssClass: "l-input",
|
||||
key: "infinityValues",
|
||||
property: [
|
||||
"telemetry",
|
||||
"infinityValues"
|
||||
]
|
||||
}
|
||||
],
|
||||
initialize: function (object) {
|
||||
@@ -153,7 +163,8 @@ define([
|
||||
dataRateInHz: 1,
|
||||
phase: 0,
|
||||
randomness: 0,
|
||||
loadDelay: 0
|
||||
loadDelay: 0,
|
||||
infinityValues: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
69
package.json
@@ -1,40 +1,35 @@
|
||||
{
|
||||
"name": "openmct",
|
||||
"version": "2.1.1-SNAPSHOT",
|
||||
"version": "2.1.5-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.1",
|
||||
"@types/lodash": "4.14.191",
|
||||
"babel-loader": "9.1.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.30.0",
|
||||
"eslint-plugin-compat": "4.0.2",
|
||||
"eslint-plugin-playwright": "0.11.2",
|
||||
"eslint-plugin-vue": "9.3.0",
|
||||
"eslint-plugin-vue": "9.8.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",
|
||||
@@ -47,45 +42,46 @@
|
||||
"karma-webpack": "5.0.0",
|
||||
"location-bar": "3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"mini-css-extract-plugin": "2.6.1",
|
||||
"mini-css-extract-plugin": "2.7.2",
|
||||
"moment": "2.29.4",
|
||||
"moment-duration-format": "2.3.2",
|
||||
"moment-timezone": "0.5.37",
|
||||
"nyc":"15.1.0",
|
||||
"moment-timezone": "0.5.40",
|
||||
"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": "15.0.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"typescript": "4.9.4",
|
||||
"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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +73,10 @@ export default class Editor extends EventEmitter {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.openmct.objects.getActiveTransaction();
|
||||
if (!transaction) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
transaction.cancel()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
|
||||
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"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<ToggleSwitch
|
||||
id="switchId"
|
||||
:checked="isChecked"
|
||||
:name="model.name"
|
||||
@change="toggleCheckBox"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
@@ -188,7 +196,6 @@ 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);
|
||||
|
||||
@@ -197,13 +204,13 @@ export default class ObjectAPI {
|
||||
}
|
||||
|
||||
identifier = utils.parseKeyString(identifier);
|
||||
let dirtyObject;
|
||||
if (this.isTransactionActive()) {
|
||||
dirtyObject = this.transaction.getDirtyObject(identifier);
|
||||
}
|
||||
|
||||
if (dirtyObject) {
|
||||
return Promise.resolve(dirtyObject);
|
||||
if (this.isTransactionActive()) {
|
||||
let dirtyObject = this.transaction.getDirtyObject(identifier);
|
||||
|
||||
if (dirtyObject) {
|
||||
return Promise.resolve(dirtyObject);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = this.getProvider(identifier);
|
||||
@@ -223,7 +230,7 @@ export default class ObjectAPI {
|
||||
if (result.isMutable) {
|
||||
result.$refresh(result);
|
||||
} else {
|
||||
let mutableDomainObject = this._toMutable(result);
|
||||
let mutableDomainObject = this.toMutable(result);
|
||||
mutableDomainObject.$refresh(result);
|
||||
}
|
||||
|
||||
@@ -300,7 +307,7 @@ export default class ObjectAPI {
|
||||
}
|
||||
|
||||
return this.get(identifier).then((object) => {
|
||||
return this._toMutable(object);
|
||||
return this.toMutable(object);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,53 +354,96 @@ 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) {
|
||||
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 (lastPersistedTime !== undefined) {
|
||||
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) => {
|
||||
return result.catch(async (error) => {
|
||||
if (error instanceof this.errors.Conflict) {
|
||||
this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
|
||||
|
||||
// Synchronized objects will resolve their own conflicts, so
|
||||
// bypass the refresh here and throw the error.
|
||||
if (!this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
|
||||
if (this.isTransactionActive()) {
|
||||
this.endTransaction();
|
||||
}
|
||||
|
||||
await this.refresh(domainObject);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
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 +451,8 @@ export default class ObjectAPI {
|
||||
}
|
||||
|
||||
this.transaction = new Transaction(this);
|
||||
|
||||
return this.transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -473,14 +525,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 +544,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,6 +555,18 @@ 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()) {
|
||||
this.transaction.add(domainObject);
|
||||
@@ -510,15 +576,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 +596,11 @@ 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 +654,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 +743,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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -202,8 +202,13 @@ class IndependentTimeContext extends TimeContext {
|
||||
}
|
||||
|
||||
getUpstreamContext() {
|
||||
let timeContext = this.globalTimeContext;
|
||||
const objectKey = this.openmct.objects.makeKeyString(this.objectPath[this.objectPath.length - 1].identifier);
|
||||
const doesObjectHaveTimeContext = this.globalTimeContext.independentContexts.get(objectKey);
|
||||
if (doesObjectHaveTimeContext) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let timeContext = this.globalTimeContext;
|
||||
this.objectPath.some((item, index) => {
|
||||
const key = this.openmct.objects.makeKeyString(item.identifier);
|
||||
//last index is the view object itself
|
||||
|
||||
@@ -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;
|
||||
|
||||