Compare commits

...

38 Commits

Author SHA1 Message Date
Jamie V
e9358c0552 Merge branch 'master' into vue-3 2022-11-03 12:15:10 -07:00
Jamie V
6414a6f556 WIP 2022-11-03 12:09:43 -07:00
dependabot[bot]
42a0e503cc Bump eslint-plugin-vue from 9.6.0 to 9.7.0 (#5932)
Bumps [eslint-plugin-vue](https://github.com/vuejs/eslint-plugin-vue) from 9.6.0 to 9.7.0.
- [Release notes](https://github.com/vuejs/eslint-plugin-vue/releases)
- [Commits](https://github.com/vuejs/eslint-plugin-vue/compare/v9.6.0...v9.7.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-vue
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-01 16:41:03 +00:00
Shefali Joshi
4697352f60 Fix now marker for time system axis in timestrips and plans (#5911)
* Ensure the now marker spans the height of the plan.
* Set height for timestrip for the now marker
* Fix linting issues
* Fix failing test
2022-10-31 23:56:52 +00:00
John Hill
015c764ab3 Update dependabot.yml (#5935) 2022-10-31 21:58:12 +00:00
dependabot[bot]
8fe465d9fc Bump eslint from 8.25.0 to 8.26.0 (#5908)
Bumps [eslint](https://github.com/eslint/eslint) from 8.25.0 to 8.26.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.25.0...v8.26.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-28 21:57:31 +00:00
dependabot[bot]
9c1368885a Bump babel-loader from 8.2.5 to 9.0.0 (#5920) 2022-10-27 14:51:01 -07:00
Jesse Mazzella
391c0b2e7c Bump version to 2.1.3-SNAPSHOT (#5904) 2022-10-27 17:29:41 +00:00
dependabot[bot]
2ae061dbcd Bump sinon from 14.0.0 to 14.0.1 (#5831)
Bumps [sinon](https://github.com/sinonjs/sinon) from 14.0.0 to 14.0.1.
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v14.0.0...v14.0.1)
Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-10-25 11:42:08 -07:00
Jesse Mazzella
41fc502564 Generate type declarations for CompositionAPI and publish with OpenMCT (#5838)
* add typescript

* update tsconfig

* convert to es6 class

* Convert more stuff to es6 class

* skip checking libs, test files

* more es6 classes!

* Fix some jsdocs

* Rename file

* Improve jsdoc types

* Rename references as well

* more types

* types for CompositionAPI

* Types for CompositionCollection

* Types for CompositionProvider

* type

* types for api

* nvm

* cleanup MCT

* Fix API type definition

* Generate types before publish

* fix openmct 👀

* rename PublicAPI -> OpenMCT and document methods

* try and fix visual test ?

* Make private methods private

* more private methods!!

* import all es6 api's so we get more types for free

* convert Selection to es6 class

* remove redundant docs

* fix Branding types

* fix openmct.start() types

* Remove useless `@memberof`

* Add parameter name

* [docs] Add a section on Types

* markdownlint

* word

* Add section on limitations / contibuting types

* Let these methods be private

* make private members private, fix a type

* fix another type

* Make method private

* Update docs for `skipMutate` and related methods

* Rename file and fix references

* `DefaultCompositionProvider` extends `CompositionProvider`

* Make private members private

* Type for `AbortSignal`

* `domainObject` must be accessible for perf tests

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-10-21 17:29:52 -07:00
Jamie V
b4554d2fc1 User attribution (#5827)
* initial changes adding modified and created by fields to domain objects and updating properties inspector
* adding created date to object creation
* added a test for created timestamp
* updating remove action to hold the transaction and disregard edit state when handling transactions, also updated object api to return transaction when starting and ignore edit state when determining if transaction is active
* updating docs for object api "startTransaction"
* updating incorrect use of edit and transaction in our appActions for testing

Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: Shefali <simplyrender@gmail.com>
2022-10-21 17:05:59 -07:00
David Tsay
feba5f6d3b use full identifier instead of key (#5891) 2022-10-21 16:45:52 -07:00
tobiasbrown
4357d35f4a Centre and wrap Condition Widget label (#5886)
* [ConditionWidget] Center label text

Addresses #5799

* [ConditionWidget] Wrap label text

Addresses #5799

* [ConditionWidget] Add padding to label

Addresses #5799

* [ConditionWidget] Use interiorMargin value for padding

Addresses #5799
2022-10-21 16:35:59 -07:00
Jesse Mazzella
5041f80e5b Update version to 2.1.2-SNAPSHOT (#5897) 2022-10-21 12:16:46 -07:00
Scott Bell
9e23f79bc8 Add time context to telemetry requests (#5887)
* add time context to telemetry requests

* change to empty array

* refactor telemetry api to use time context

* removed unused function

* add tests

* add test, rename function

* make function public
2022-10-21 20:25:24 +02:00
Scott Bell
bd1e869f6a fix timing issue (#5896) 2022-10-21 20:19:41 +02:00
dependabot[bot]
e4a36532e7 Bump @types/lodash from 4.14.178 to 4.14.186 (#5892) 2022-10-19 20:40:30 +00:00
dependabot[bot]
2bc2316613 Bump @types/jasmine from 4.0.1 to 4.3.0 (#5893) 2022-10-19 13:34:44 -07:00
John Hill
2fa36b2176 Delete lighthouse.yml (#5885) 2022-10-18 16:10:09 -07:00
John Hill
efa38d779e Remove two types from package and pin others (#5883)
* Update package.json

* Update package.json

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2022-10-18 21:17:30 +00:00
dependabot[bot]
951cc6ec0d Bump eslint-plugin-vue from 9.3.0 to 9.6.0 (#5837)
Bumps [eslint-plugin-vue](https://github.com/vuejs/eslint-plugin-vue) from 9.3.0 to 9.6.0.
- [Release notes](https://github.com/vuejs/eslint-plugin-vue/releases)
- [Commits](https://github.com/vuejs/eslint-plugin-vue/compare/v9.3.0...v9.6.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-vue
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-18 16:31:01 +00:00
dependabot[bot]
ef4b8a9934 Bump @percy/cli from 1.10.3 to 1.11.0 (#5846) 2022-10-17 17:52:11 -07:00
Shefali Joshi
c14b48917e Plan documentation (#5871)
* Plan documentation
2022-10-13 20:04:51 -07:00
dependabot[bot]
26165d0a99 Bump eslint from 8.24.0 to 8.25.0 (#5861)
Bumps [eslint](https://github.com/eslint/eslint) from 8.24.0 to 8.25.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.24.0...v8.25.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2022-10-13 16:53:05 +00:00
John Hill
f7cf3f72c2 Add playwright-core to dependabot ignore list (#5863) 2022-10-10 15:35:40 -07:00
Shefali Joshi
cb8e09c9f9 Master 2.1.1 (#5858)
* Update version

* Don't delete annotations if there aren't any (#5829)

* don't delete annotations if there aren't any

* add test and align playwright-test

* align core with test

* added annotation describing test

* Add `aria-label` for time conductor history button (#5830)

* [Overlay Plot] Inspector series and legend sync fix (#5835)

* fixed overlay plots to react to series removals correctly, added alias visual to elements pool aliased items

* Keep transaction open on failed editor save (#5840)

* do not end a transaction on a failed editor save
* add unit tests for successful editor save and unsuccessful editor save

* If no matching tags, do not attempt tag search (#5839)

* do not attempt search if no matching tags

* fix timing on test

* commit again in hopes that github will run checks

* add back null tag check

* add some better documentation to tests

Co-authored-by: Andrew Henry <akhenry@gmail.com>

* Update version for  master

Co-authored-by: Scott Bell <scott@traclabs.com>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-10-08 09:04:38 -07:00
Michael Rogers
026eb86f5f 4417 fix codeql issues (#5793)
* Add release to codeql and queries to match lgtm

* Add lgtm config file

* Custom codeQL config to ignore app.js

* Custom config for lgtm

* Remove query filter for lgtm

* Updated the security test docs

* Remove lgtm.yml and delete app.js references

* Update codeql-config.yml

Co-authored-by: Alize Nguyen <alizenguyen@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-10-07 16:30:09 +00:00
Jesse Mazzella
866859a937 [CouchDB] Re-establish feed connection if EventSource is closed due to error (#5845)
* Re-establish feed connection if EventSource is closed due to error

* Use keepAlive timer

* Get rid of magic numbers and add comment
2022-10-06 21:41:38 +02:00
Jesse Mazzella
afc54f41f6 Publish example layouts json (#5842) 2022-10-05 14:17:36 -07:00
Scott Bell
72c980f991 Have webpack-dev-server write webpack assets to disk (#5836)
* create the files not webpacked and watch css

* removing trailing slash
2022-10-04 10:53:44 -07:00
dependabot[bot]
9bf39a9cd4 Bump eslint from 8.23.1 to 8.24.0 (#5807)
Bumps [eslint](https://github.com/eslint/eslint) from 8.23.1 to 8.24.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.23.1...v8.24.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-10-03 11:02:01 -07:00
Jamie V
33fd95cb2b Revert "[User Attribution] "createdBy" and "modifiedBy" fields for domainObjects (#5741)" (#5826)
This reverts commit 8c92178895.
2022-09-30 16:53:49 -07:00
Jamie V
8c92178895 [User Attribution] "createdBy" and "modifiedBy" fields for domainObjects (#5741)
* Implementation of user attribution of object changes
* Adds created date to object creation
* Updating remove action to wait for save before navigationg

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-09-30 13:47:10 -07:00
Shefali Joshi
35bbebbbc7 Fix plot resize handling (#5637)
* Imagery thumbnail regression fixes - 5327 (#5591)

* Add an active class to thumbnail to indicate current focused image

* Differentiate bg color between real-time and fixed

* scrollIntoView inline: center

* Added watcher for bounds change to trigger thumbnail scroll

* Resolve merge conflict with requestHistory change to telemetry collection

* Split thumbnail into sub component

* Monitor isFixed value to unpause playback status

Co-authored-by: Khalid Adil <khalidadil29@gmail.com>

* [e2e] Improve appActions (#5592)

* update selectors to use aria labels

* Update appActions

- Create new function `getHashUrlToDomainObject` to get the browse url to a given object given its uuid

- Create new function `getFocusedObjectUuid`... self explanatory :)

- Update `createDomainObjectWIthDefaults` to make use of the new url generation

- Update `createDomainObject...`'s arguments to be more organized, and accept a parent object

- Update some docs, still need to clarify some

* Update appActions e2e tests

- Refactor for organization

- Test our new appActions in one go

* Update existing usages of `createDomainObject...` to match the new API

* fix accidental renamed export

* Fix jsdoc return types

* refactor telemetryTable test to use appActions

* Improve selectors

* Refactor test

* improve selector

* add clock mode appActions

* lint

* Fix jsdoc

* Code review comments

* mark failing visual tests as fixme temporarily

* Update package.json (#5601)

* Fix menu style in Snow theme (#5557)

* Include the plan source map when generating the time list/plan hybrid object (#5604)

* Search should indicate in progress and no results states, filter orphaned results (#5599)

* no matching result implemented

* now filtering annotations that are orphaned

* filter object results without valid paths

* add progress bar

* added e2e tests

* removed extraneous click

* fix typos

* fix unit tests

* lint

* address pr comments

* fix tests

* fix tests, centralize logic to object api, check for root instead

* remove debug statement

* lint

* fix documentation

* lint

* fix doc

* made some optimizations after talking with akhenry

* fix test

* update docs

* fix docs

* Have in-memory search indexer use composition API (#5578)

* need to remove tags and objects on composition removal
* had to separate out emits from load as it was causing memory indexer to loop upon itself

* Add parsing for areIdsEqual util to consistently remove folders (#5589)

* Add parsing util to identifier for ID comparison

* Moved firstIdentifier to top of function

* Lint fix

Co-authored-by: Andrew Henry <akhenry@gmail.com>

* Revert "Have in-memory search indexer use composition API (#5578)" (#5609)

This reverts commit 7cf11e177c.

* [e2e] Tests for Display Layout and LAD Tables and telemetry (#5607)

* Check for circular references in originalPath - 5615 (#5619)

* check for circular references

* add test

* fix test

* address PR comments by making comments better

* fix docs...again

* Don't request data if there is a mouse click with no action
Don't request data if width gets smaller or if the change is less than the threshold of 50.

* Update version number

* Prevent cyclic references in link & move actions (#5635)

* do not create circular refs

* add negative validation test

* move to plugin

* add link test too

* fix docs

* refactored per john request

* fix path

* use appAction lib

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* [Fault Management] New Example Provider, Unit and e2e tests (#5579)

* added unit tests for fault management plugin

* modified the example fault provider to work out of the box

* updating for new e2e folder structure

* part of the e2e tests

* WIP

* Imagery thumbnail regression fixes - 5327 (#5569)

* Add an active class to thumbnail to indicate current focused image

* Differentiate bg color between real-time and fixed

* scrollIntoView inline: center

* Added watcher for bounds change to trigger thumbnail scroll

* Resolve merge conflict with requestHistory change to telemetry collection

* Split thumbnail into sub component

* Monitor isFixed value to unpause playback status

* updated search to include name, namespace and description added some more e2e tests

* added rest of e2e tests

* fixed my init script, had to disable lint for no-force because it was not working without it, saw online this may be a pw bug

* fix: removing maelstrom theme from application (#5600)

* added some tests for no faults

* visual tests

* added visual tests for fault management

* created utils file for shared functionality between function and visual tests

* updating to 2.0.8

* tryin to remove imagery changes from master

* trying to trigger a refresh

* tryin to refresh

* updated search to include name, namespace and description added some more e2e tests

* added rest of e2e tests

* fix: removing maelstrom theme from application (#5600)

* fixed my init script, had to disable lint for no-force because it was not working without it, saw online this may be a pw bug

* added some tests for no faults

* visual tests

* added visual tests for fault management

* created utils file for shared functionality between function and visual tests

* updating to 2.0.8

* no clue

* still no clue

* removing imports and chaning to requires

* updating utils file to work with require

* fixing paths

* fixing a test I had messed up when adding static exmaple faults

* ONE LAST PATH FIX... hopefully

* typo in files fix

* fix folder typo

* thought I got this one, but apparently not, well I did now! who is laughing now!?

Co-authored-by: Michael Rogers <contact@mhrogers.com>
Co-authored-by: Vitor Henckel <vitor@henckel.com.br>

* Sort tree items locally on rename (#5643)

* fix typo

* Sort the tree items locally on object rename

* Use the navigationPath as a key

- This ensures that objects AND linked objects will be sorted

* add 'tree' and 'treeitem' roles to mct-tree

* WIP tree item reordering test

* Select the first object that matches

* Test that all object links are also reordered

* Get the final uuid before queryParams as notebook sections have uuids

* Make `openObjectTreeContextMenu` more deterministic and update usage

* Add `expandPathToTreeItem` and `expandTreeItemByName` appActions

* add `#tree-pane` id for the tree view

* Add tree visual component test suite and bump percy-cli

* Remove tree appActions

* Better variable name

Co-authored-by: Scott Bell <scott@traclabs.com>

* Mct5549 fix indexer composition error (#5610)

* [Display Layout] Composition and configuration sync (#5669)

LGTM

* [e2e] Stabilize notebook tag tests (#5681)

* Use more deterministic selector

* Hover first to "slow down" e2e actions while in headless mode

* Moves condition set fix into 2.0.8 (#5673)

* Remove flag that determines if data should be reloaded on interactions.
Separate logic to clear history and reload data.

* Rename method to clarify intention

* Set Focused Image index after a imagery is selected from a timestrip - 5632 (#5664)

* Set focused image when timestamp prop is passed in

* Unused var

* Create timestrip with imagery child

* Add equality check for hovered image and view large image url

* Cleanup

* Time List 5534 for release/2.0.8 (#5678)

* Changes to Time List view. Closes #5534.
- Compacted table row spacing.
- Set all timeframes to display by default when creating a new Time List.
- Removed 'Upload plan' file button from properties.

* Changes to Time List view. Closes #5534.
- Better hint text for editing Timeframe Inspector section.

Co-authored-by: Andrew Henry <akhenry@gmail.com>

* [CI] Enable couchdb e2e testing in open source (#5655)

* Reduce threshold to 10px - Chrome resizes to about 7 pixes and Firefox to 0.

* boilerplate for coverage

* add stubs

* Update version

* Remove debugging code

* [Flexible Layout] Fix draggable status for layout items while in browse mode (#5750)

* Modify flexible layout pages to make them not draggable in browse mode and add e2e test

* Don't destroy mutable if the domain object is not ready yet (#5695)

* Check if the domain object is set (mounted is done) before trying to destroy the mutable

* Use optional chaining. Add mutable promise check to prevent memory leaks

* Don't request data if there is a mouse click with no action
Don't request data if width gets smaller or if the change is less than the threshold of 50.

* Remove flag that determines if data should be reloaded on interactions.
Separate logic to clear history and reload data.

* Rename method to clarify intention

* Reduce threshold to 10px - Chrome resizes to about 7 pixes and Firefox to 0.

* boilerplate for coverage

* add stubs

* Remove debugging code

* Remove unused import

* Request priority (#5737)

* Set priority of couch requests to high
* Set priority of image requests to low
* Add e2e test for low-priority images

* Add test for re-requests on clicking a plot

* Adding new tests for testing plot requests for historical data

* Clean up e2e test for plot requesting historical data

* Write tests to ensure resizing the plot makes requests for data appropriately

* Remove fdescribe

* Fix resizing plot tests

Co-authored-by: Michael Rogers <contact@mhrogers.com>
Co-authored-by: Khalid Adil <khalidadil29@gmail.com>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: Scott Bell <scott@traclabs.com>
Co-authored-by: Alize Nguyen <alizenguyen@gmail.com>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: Vitor Henckel <vitor@henckel.com.br>
2022-09-30 20:04:19 +00:00
Scott Bell
ce463babff 5734 synchronization for new tags on notebook entries (#5763)
* trying this again

* wip

* wip

* wip

* one annotation per tag

* fixed too many events firing

* syncing works mostly

* syncing properly across existing annotations

* search with multiple tags

* resolve conflicts between different tag editors

* resolve conflicts

* fix annotation tests

* combine search results

* modify tests

* prevent infinite loop creating annotation

* add modified and deleted

* revert index checkin

* change to standard couch deleted flag

* revert throwing of error

* resolve conflict issues

* work in progress, but load annotations once from notebook

* works to add

* attempt 1

* wip

* last changes

* listening works, though still getting conflicts

* rename to annotationLastCreated

* use local mutable again

* works with new tags syncing

* listeners wont fire if modification is null

* clean up code

* fixed local search

* cleaned up log messages

* remove on more log

* add e2e test for network traffic

* lint

* change to use good old for each

* add some local variables for clarity

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/plugins/notebook/components/Notebook.vue

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* press enter for last entry

* add test explanation of numbers

* fix spread typo

* add some nice jsdoc

* throw some errors

* use really small integer instead

* remove unneeded binding

* make method public and jsdoc it

* use mutables

* clean up tests

* clean up tests

* use aria labels for tests

* add some proper tsdoc to annotation api

* add undelete test

Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2022-09-30 10:32:11 -07:00
Mariusz Rosinski
27c30132d2 4386 - In time conductor history, show them on hover if only milliseconds have changed - tooltip (#5783)
* 4386 - In time conductor history, show them on hover if only milliseconds have changed

* [e2e] Add quick test

Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
2022-09-30 10:16:35 -07:00
Scott Bell
2bdac56505 Replace app.js with webpack-dev-server (#5797) 2022-09-30 08:17:02 -07:00
Andrew Henry
35c42ba43d Reviewer to smoke test PRs before merge (#5771)
* Reviewer to smoke test PRs before merge

* Update PULL_REQUEST_TEMPLATE.md

* Update PULL_REQUEST_TEMPLATE.md

* Update PULL_REQUEST_TEMPLATE.md

Changes based on feedback

* Update PULL_REQUEST_TEMPLATE.md

Tweaking the language slightly for clarity.

* Update PULL_REQUEST_TEMPLATE.md

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2022-09-28 17:13:41 -07:00
106 changed files with 5462 additions and 2492 deletions

View File

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

@@ -0,0 +1 @@
name: 'Custom CodeQL config'

View File

@@ -13,14 +13,14 @@ updates:
- "pr:daveit"
- "pr:platform"
ignore:
#We have to source the container which is not detected by Dependabot
- dependency-name: "@playwright/test"
#Lots of noise in these type patch releases.
- dependency-name: "@babel/eslint-parser"
- dependency-name: "@playwright/test" #We have to source the playwright container which is not detected by Dependabot
- dependency-name: "playwright-core" #We have to source the playwright container which is not detected by Dependabot
- dependency-name: "@babel/eslint-parser" #Lots of noise in these type patch releases.
update-types: ["version-update:semver-patch"]
- dependency-name: "eslint-plugin-vue" #Lots of noise in these type patch releases.
update-types: ["version-update:semver-patch"]
- dependency-name: "eslint-plugin-vue"
- dependency-name: "babel-loader"
update-types: ["version-update:semver-patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:

View File

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

View File

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

View File

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

599
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
web: node app.js --port $PORT

View File

@@ -30,6 +30,8 @@ Building and running Open MCT in your local dev environment is very easy. Be sur
Open MCT is now running, and can be accessed by pointing a web browser at [http://localhost:8080/](http://localhost:8080/)
Open MCT is built using [`npm`](http://npmjs.com/) and [`webpack`](https://webpack.js.org/).
## Documentation
Documentation is available on the [Open MCT website](https://nasa.github.io/openmct/documentation/).
@@ -43,11 +45,9 @@ our documentation.
We want Open MCT to be as easy to use, install, run, and develop for as
possible, and your feedback will help us get there! Feedback can be provided via [GitHub issues](https://github.com/nasa/openmct/issues/new/choose), [Starting a GitHub Discussion](https://github.com/nasa/openmct/discussions), or by emailing us at [arc-dl-openmct@mail.nasa.gov](mailto:arc-dl-openmct@mail.nasa.gov).
## Building Applications With Open MCT
## Developing Applications With Open MCT
Open MCT is built using [`npm`](http://npmjs.com/) and [`webpack`](https://webpack.js.org/).
See our documentation for a guide on [building Applications with Open MCT](https://github.com/nasa/openmct/blob/master/API.md#starting-an-open-mct-application).
For more on developing with Open MCT, see our documentation for a guide on [Developing Applications with Open MCT](./API.md#starting-an-open-mct-application).
## Compatibility
@@ -64,7 +64,7 @@ that is intended to be added or removed as a single unit.
As well as providing an extension mechanism, most of the core Open MCT codebase is also
written as plugins.
For information on writing plugins, please see [our API documentation](https://github.com/nasa/openmct/blob/master/API.md#plugins).
For information on writing plugins, please see [our API documentation](./API.md#plugins).
## Tests
@@ -100,7 +100,7 @@ To run the performance tests:
The test suite is configured to all tests localed in `e2e/tests/` ending in `*.e2e.spec.js`. For more about the e2e test suite, please see the [README](./e2e/README.md)
### Security Tests
Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/)
Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/). The list of CWE coverage items is avaiable in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml).
### Test Reporting and Code Coverage

92
app.js
View File

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

View File

@@ -1,3 +0,0 @@
<hr>
</body>
</html>

View File

@@ -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![Diagram ",
counter,
"](",
outputFilename,
")\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));
});
});
});
}());

View File

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

View File

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

View File

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

View File

@@ -225,15 +225,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());
}
/**

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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('../../../../baseFixtures');
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');
});
}

View File

@@ -39,7 +39,7 @@ async function createNotebookAndEntry(page, iterations = 1) {
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();
@@ -81,10 +81,8 @@ 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 +95,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 +104,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 +162,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');

View File

@@ -0,0 +1,54 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
* This test suite is dedicated to testing the rendering and interaction of plots.
*
*/
const { test, expect } = require('../../../../baseFixtures');
const { createDomainObjectWithDefaults } = require('../../../../appActions');
test.describe('Plot Integrity Testing @unstable', () => {
let sineWaveGeneratorObject;
test.beforeEach(async ({ page }) => {
//Open a browser, navigate to the main page, and wait until all networkevents to resolve
await page.goto('./', { waitUntil: 'networkidle' });
sineWaveGeneratorObject = await createDomainObjectWithDefaults(page, { type: 'Sine Wave Generator' });
});
test('Plots do not re-request data when a plot is clicked', async ({ page }) => {
//Navigate to Sine Wave Generator
await page.goto(sineWaveGeneratorObject.url);
//Capture the number of plots points and store as const name numberOfPlotPoints
//Click on the plot canvas
await page.locator('canvas').nth(1).click();
//No request was made to get historical data
const createMineFolderRequests = [];
page.on('request', req => {
// eslint-disable-next-line playwright/no-conditional-in-test
createMineFolderRequests.push(req);
});
expect(createMineFolderRequests.length).toEqual(0);
});
});

View File

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

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

View 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

View File

@@ -1,12 +0,0 @@
{
"source": {
"include": [
"src/"
],
"includePattern": "src/.+\\.js$",
"excludePattern": ".+\\Spec\\.js$|lib/.+"
},
"plugins": [
"plugins/markdown"
]
}

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,30 @@
{
"name": "openmct",
"version": "2.1.1-SNAPSHOT",
"version": "2.1.3-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",
"@percy/cli": "1.11.0",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.25.2",
"@types/eventemitter3": "^1.0.0",
"@types/jasmine": "^4.0.1",
"@types/karma": "^6.3.2",
"@types/lodash": "^4.14.178",
"@types/mocha": "^9.1.0",
"babel-loader": "8.2.5",
"@types/jasmine": "4.3.0",
"@types/lodash": "4.14.186",
"babel-loader": "9.0.0",
"babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3",
"comma-separated-values": "3.6.4",
"codecov":"3.8.3",
"copy-webpack-plugin": "11.0.0",
"cross-env": "7.0.3",
"css-loader": "6.7.1",
"d3-axis": "3.0.0",
"d3-scale": "3.3.0",
"d3-selection": "3.0.0",
"eslint": "8.23.1",
"eslint": "8.26.0",
"eslint-plugin-compat": "4.0.2",
"eslint-plugin-playwright": "0.11.2",
"eslint-plugin-vue": "9.3.0",
"eslint-plugin-vue": "9.7.0",
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
"eventemitter3": "1.2.0",
"express": "4.13.1",
"file-saver": "2.0.5",
"git-rev-sync": "3.0.2",
"html2canvas": "1.4.1",
@@ -51,41 +46,43 @@
"moment": "2.29.4",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.37",
"nyc":"15.1.0",
"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-loader": "13.0.2",
"sinon": "14.0.0",
"sinon": "14.0.1",
"style-loader": "^3.3.1",
"typescript": "4.8.4",
"uuid": "9.0.0",
"vue": "2.6.14",
"vue": "^3.1.0",
"@vue/compat": "^3.1.0",
"vue-eslint-parser": "9.1.0",
"vue-loader": "15.9.8",
"vue-template-compiler": "2.6.14",
"vue-loader": "^16.0.0",
"@vue/compiler-sfc": "^3.1.0",
"webpack": "5.74.0",
"webpack-cli": "4.10.0",
"webpack-dev-middleware": "5.3.3",
"webpack-hot-middleware": "2.25.2",
"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 +92,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 +107,6 @@
"engines": {
"node": ">=14.19.1"
},
"overrides": {
"core-js": "3.21.1"
},
"browserslist": [
"Firefox ESR",
"not IE 11",

View File

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

View File

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

View File

@@ -63,10 +63,9 @@ export default class Editor extends EventEmitter {
.then(() => {
this.editing = false;
this.emit('isEditing', false);
this.openmct.objects.endTransaction();
}).catch(error => {
throw error;
}).finally(() => {
this.openmct.objects.endTransaction();
});
}

80
src/api/EditorSpec.js Normal file
View 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();
});
});

View File

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

View File

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

View File

@@ -37,7 +37,9 @@ define([
'./types/TypeRegistry',
'./user/UserAPI',
'./annotation/AnnotationAPI'
], function (
],
function (
ActionsAPI,
CompositionAPI,
EditorAPI,

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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') {

View File

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

View File

@@ -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,10 +354,8 @@ export default class ObjectAPI {
* @returns {Promise} a promise which will resolve when the domain object
* has been saved, or be rejected if it cannot be saved
*/
save(domainObject) {
let provider = this.getProvider(domainObject.identifier);
let savedResolve;
let savedReject;
async save(domainObject) {
const provider = this.getProvider(domainObject.identifier);
let result;
if (!this.isPersistable(domainObject.identifier)) {
@@ -359,27 +364,37 @@ export default class ObjectAPI {
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, 'persisted', persistedTime);
this.#mutate(domainObject, 'modifiedBy', username);
if (isNewObject) {
this.#mutate(domainObject, 'created', persistedTime);
this.#mutate(domainObject, 'createdBy', username);
savedObjectPromise = provider.create(domainObject);
} else {
domainObject.persisted = persistedTime;
this.mutate(domainObject, 'persisted', persistedTime);
result = provider.update(domainObject);
savedObjectPromise = provider.update(domainObject);
}
if (savedObjectPromise) {
savedObjectPromise.then(response => {
savedResolve(response);
}).catch((error) => {
savedReject(error);
});
} else {
result = Promise.reject(`[ObjectAPI][save] Object provider returned ${savedObjectPromise} when ${isNewObject ? 'creating new' : 'updating'} object.`);
}
}
@@ -392,8 +407,21 @@ export default class ObjectAPI {
});
}
async #getCurrentUsername() {
const user = await this.openmct.user.getCurrentUser();
let username;
if (user !== undefined) {
username = user.getName();
}
return username;
}
/**
* After entering into edit mode, creates a new instance of Transaction to keep track of changes in Objects
*
* @returns {Transaction} a new Transaction that was just created
*/
startTransaction() {
if (this.isTransactionActive()) {
@@ -401,6 +429,8 @@ export default class ObjectAPI {
}
this.transaction = new Transaction(this);
return this.transaction;
}
/**
@@ -473,14 +503,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 +522,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 +533,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 +554,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 +574,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 +632,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 +721,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;
}

View File

@@ -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,34 @@ 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();
});
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 +349,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(() => {

View File

@@ -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 () {

View File

@@ -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(() => {

View File

@@ -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 () {

View File

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

View File

@@ -114,6 +114,8 @@ export default {
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.limitEvaluator = this.openmct
.telemetry
.limitEvaluator(this.domainObject);
@@ -134,7 +136,8 @@ export default {
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
size: 1,
strategy: 'latest'
strategy: 'latest',
timeContext: this.timeContext
});
this.telemetryCollection.on('add', this.setLatestValues);
this.telemetryCollection.on('clear', this.resetValues);

View File

@@ -33,9 +33,9 @@
<tbody>
<template
v-for="ladTable in ladTableObjects"
:key="ladTable.key"
>
<tr
:key="ladTable.key"
class="c-table__group-header js-lad-table-set__table-headers"
>
<td colspan="10">

View File

@@ -30,6 +30,12 @@
padding: $interiorMarginLg $interiorMarginLg * 2;
}
.c-condition-widget__label {
padding: $interiorMargin;
text-align: center;
white-space: normal;
}
a.c-condition-widget {
// Widget is conditionally made into a <a> when URL property has been defined
cursor: pointer !important;

View File

@@ -282,12 +282,15 @@ export default {
this.limitEvaluator = this.openmct.telemetry.limitEvaluator(this.domainObject);
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
const valueMetadata = this.metadata ? this.metadata.value(this.item.value) : {};
this.customStringformatter = this.openmct.telemetry.customStringFormatter(valueMetadata, this.item.format);
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
size: 1,
strategy: 'latest'
strategy: 'latest',
timeContext: this.timeContext
});
this.telemetryCollection.on('add', this.setLatestValues);
this.telemetryCollection.on('clear', this.refreshData);

View File

@@ -45,9 +45,9 @@
<div class="c-fl-container__frames-holder">
<template
v-for="(frame, i) in frames"
:key="frame.id"
>
<frame-component
:key="frame.id"
class="c-fl-container__frame"
:frame="frame"
:index="i"
@@ -57,7 +57,6 @@
/>
<drop-hint
:key="'hint-' + i"
class="c-fl-frame__drop-hint"
:index="i"
:allow-drop="allowDrop"
@@ -66,7 +65,6 @@
<resize-handle
v-if="(i !== frames.length - 1)"
:key="'handle-' + i"
:index="i"
:orientation="rowsLayout ? 'horizontal' : 'vertical'"
:is-editing="isEditing"

View File

@@ -40,10 +40,12 @@
'c-fl--rows': rowsLayout === true
}"
>
<template v-for="(container, index) in containers">
<template
v-for="(container, index) in containers"
:key="`component-${container.id}`"
>
<drop-hint
v-if="index === 0 && containers.length > 1"
:key="`hint-top-${container.id}`"
class="c-fl-frame__drop-hint"
:index="-1"
:allow-drop="allowContainerDrop"
@@ -51,7 +53,6 @@
/>
<container-component
:key="`component-${container.id}`"
class="c-fl__container"
:index="index"
:container="container"
@@ -77,7 +78,6 @@
<drop-hint
v-if="containers.length > 1"
:key="`hint-bottom-${container.id}`"
class="c-fl-frame__drop-hint"
:index="index"
:allow-drop="allowContainerDrop"

View File

@@ -107,7 +107,7 @@ export default class CreateAction extends PropertiesAction {
}
const url = '#/browse/' + objectPath
.map(object => object && this.openmct.objects.makeKeyString(object.identifier.key))
.map(object => object && this.openmct.objects.makeKeyString(object.identifier))
.reverse()
.join('/');

View File

@@ -256,7 +256,7 @@ export default {
isNested: true
};
},
template: `<swim-lane :is-nested="isNested" :hide-label="true"><template slot="object"><div class="c-imagery-tsv-container"></div></template></swim-lane>`
template: `<swim-lane :is-nested="isNested" :hide-label="true"><slot name="object"><div class="c-imagery-tsv-container"></div></slot></swim-lane>`
});
this.$refs.imageryHolder.appendChild(component.$mount().$el);

View File

@@ -174,7 +174,7 @@
:active="focusedImageIndex === index"
:selected="focusedImageIndex === index && isPaused"
:real-time="!isFixed"
@click.native="thumbnailClicked(index)"
@click="thumbnailClicked(index)"
/>
</div>

View File

@@ -151,6 +151,7 @@
:key="entry.id"
:entry="entry"
:domain-object="domainObject"
:notebook-annotations="notebookAnnotations[entry.id]"
:selected-page="selectedPage"
:selected-section="selectedSection"
:read-only="false"
@@ -219,10 +220,12 @@ export default {
isRestricted: this.domainObject.type === RESTRICTED_NOTEBOOK_TYPE,
search: '',
searchResults: [],
lastLocalAnnotationCreation: 0,
showTime: this.domainObject.configuration.showTime || 0,
showNav: false,
sidebarCoversEntries: false,
filteredAndSortedEntries: []
filteredAndSortedEntries: [],
notebookAnnotations: {}
};
},
computed: {
@@ -289,7 +292,8 @@ export default {
this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
},
mounted() {
async mounted() {
await this.loadAnnotations();
this.formatSidebar();
this.setSectionAndPageFromUrl();
@@ -307,6 +311,13 @@ export default {
this.unobserveEntries();
}
Object.keys(this.notebookAnnotations).forEach(entryID => {
const notebookAnnotationsForEntry = this.notebookAnnotations[entryID];
notebookAnnotationsForEntry.forEach(notebookAnnotation => {
this.openmct.objects.destroyMutable(notebookAnnotation);
});
});
window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
},
@@ -338,6 +349,32 @@ export default {
}
});
},
async loadAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) {
return;
}
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const query = this.openmct.objects.makeKeyString(this.domainObject.identifier);
const foundAnnotations = await this.openmct.annotation.getAnnotations(query);
foundAnnotations.forEach((foundAnnotation) => {
const targetId = Object.keys(foundAnnotation.targets)[0];
const entryId = foundAnnotation.targets[targetId].entryId;
if (!this.notebookAnnotations[entryId]) {
this.$set(this.notebookAnnotations, entryId, []);
}
const annotationExtant = this.notebookAnnotations[entryId].some((existingAnnotation) => {
return this.openmct.objects.areIdsEqual(existingAnnotation.identifier, foundAnnotation.identifier);
});
if (!annotationExtant) {
const annotationArray = this.notebookAnnotations[entryId];
const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation);
annotationArray.push(mutableAnnotation);
}
});
},
filterAndSortEntries() {
const filterTime = Date.now();
const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
@@ -350,6 +387,10 @@ export default {
this.filteredAndSortedEntries = this.defaultSort === 'oldest'
? filteredPageEntriesByTime
: [...filteredPageEntriesByTime].reverse();
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
this.loadAnnotations();
}
},
changeSelectedSection({ sectionId, pageId }) {
const sections = this.sections.map(s => {
@@ -473,14 +514,10 @@ export default {
]
});
},
async removeAnnotations(entryId) {
const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
const query = {
targetKeyString,
entryId
};
const existingAnnotation = await this.openmct.annotation.getAnnotation(query, this.openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
this.openmct.annotation.removeAnnotationTags(existingAnnotation);
removeAnnotations(entryId) {
if (this.notebookAnnotations[entryId]) {
this.openmct.annotation.deleteAnnotations(this.notebookAnnotations[entryId]);
}
},
checkEntryPos(entry) {
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);

View File

@@ -84,9 +84,8 @@
<TagEditor
:domain-object="domainObject"
:annotation-query="annotationQuery"
:annotations="notebookAnnotations"
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
:annotation-search-type="openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS"
:target-specific-details="{entryId: entry.id}"
@tags-updated="timestampAndUpdate"
/>
@@ -163,6 +162,12 @@ export default {
return {};
}
},
notebookAnnotations: {
type: Array,
default() {
return [];
}
},
entry: {
type: Object,
default() {
@@ -204,15 +209,6 @@ export default {
createdOnDate() {
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
},
annotationQuery() {
const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
return {
targetKeyString,
entryId: this.entry.id,
modified: this.entry.modified
};
},
createdOnTime() {
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
},

View File

@@ -6,6 +6,7 @@
<span class="c-sidebar__header-label">{{ sectionTitle }}</span>
<button
class="c-icon-button c-icon-button--major icon-plus"
aria-label="Add Section"
@click="addSection"
>
<span class="c-list-button__label">Add</span>
@@ -33,6 +34,7 @@
<button
class="c-icon-button c-icon-button--major icon-plus"
aria-label="Add Page"
@click="addPage"
>
<span class="c-icon-button__label">Add</span>

View File

@@ -1,22 +1,23 @@
import { isNotebookType } from './notebook-constants';
import { isAnnotationType, isNotebookType, isNotebookOrAnnotationType } from './notebook-constants';
import _ from 'lodash';
export default function (openmct) {
const apiSave = openmct.objects.save.bind(openmct.objects);
openmct.objects.save = async (domainObject) => {
if (!isNotebookType(domainObject)) {
if (!isNotebookOrAnnotationType(domainObject)) {
return apiSave(domainObject);
}
const isNewMutable = !domainObject.isMutable;
const localMutable = openmct.objects._toMutable(domainObject);
const localMutable = openmct.objects.toMutable(domainObject);
let result;
try {
result = await apiSave(localMutable);
} catch (error) {
if (error instanceof openmct.objects.errors.Conflict) {
result = resolveConflicts(localMutable, openmct);
result = await resolveConflicts(domainObject, localMutable, openmct);
} else {
result = Promise.reject(error);
}
@@ -30,16 +31,56 @@ export default function (openmct) {
};
}
function resolveConflicts(localMutable, openmct) {
const localEntries = JSON.parse(JSON.stringify(localMutable.configuration.entries));
function resolveConflicts(domainObject, localMutable, openmct) {
if (isNotebookType(domainObject)) {
return resolveNotebookEntryConflicts(localMutable, openmct);
} else if (isAnnotationType(domainObject)) {
return resolveNotebookTagConflicts(localMutable, openmct);
}
}
return openmct.objects.getMutable(localMutable.identifier).then((remoteMutable) => {
applyLocalEntries(remoteMutable, localEntries, openmct);
async function resolveNotebookTagConflicts(localAnnotation, openmct) {
const localClonedAnnotation = structuredClone(localAnnotation);
const remoteMutable = await openmct.objects.getMutable(localClonedAnnotation.identifier);
openmct.objects.destroyMutable(remoteMutable);
// should only be one annotation per targetID, entryID, and tag; so for sanity, ensure we have the
// same targetID, entryID, and tags for this conflict
if (!(_.isEqual(remoteMutable.tags, localClonedAnnotation.tags))) {
throw new Error('Conflict on annotation\'s tag has different tags than remote');
}
return true;
Object.keys(localClonedAnnotation.targets).forEach(targetKey => {
if (!remoteMutable.targets[targetKey]) {
throw new Error(`Conflict on annotation's target is missing ${targetKey}`);
}
const remoteMutableTarget = remoteMutable.targets[targetKey];
const localMutableTarget = localClonedAnnotation.targets[targetKey];
if (remoteMutableTarget.entryId !== localMutableTarget.entryId) {
throw new Error(`Conflict on annotation's entryID ${remoteMutableTarget.entryId} has a different entry Id ${localMutableTarget.entryId}`);
}
});
if (remoteMutable._deleted && (remoteMutable._deleted !== localClonedAnnotation._deleted)) {
// not deleting wins 😘
openmct.objects.mutate(remoteMutable, '_deleted', false);
}
openmct.objects.destroyMutable(remoteMutable);
return true;
}
async function resolveNotebookEntryConflicts(localMutable, openmct) {
if (localMutable.configuration.entries) {
const localEntries = structuredClone(localMutable.configuration.entries);
const remoteMutable = await openmct.objects.getMutable(localMutable.identifier);
applyLocalEntries(remoteMutable, localEntries, openmct);
openmct.objects.destroyMutable(remoteMutable);
}
return true;
}
function applyLocalEntries(mutable, entries, openmct) {

View File

@@ -1,5 +1,6 @@
export const NOTEBOOK_TYPE = 'notebook';
export const RESTRICTED_NOTEBOOK_TYPE = 'restricted-notebook';
export const ANNOTATION_TYPE = 'annotation';
export const EVENT_SNAPSHOTS_UPDATED = 'SNAPSHOTS_UPDATED';
export const NOTEBOOK_DEFAULT = 'DEFAULT';
export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT';
@@ -9,10 +10,18 @@ export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED';
export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED';
// these only deals with constants, figured this could skip going into a utils file
export function isNotebookOrAnnotationType(domainObject) {
return (isNotebookType(domainObject) || isAnnotationType(domainObject));
}
export function isNotebookType(domainObject) {
return [NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE].includes(domainObject.type);
}
export function isAnnotationType(domainObject) {
return [ANNOTATION_TYPE].includes(domainObject.type);
}
export function isNotebookViewType(view) {
return [NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_VIEW_TYPE].includes(view);
}

View File

@@ -27,7 +27,7 @@ export default class AbstractStatusIndicator {
#configuration;
/**
* @param {*} openmct the Open MCT API (proper jsdoc to come)
* @param {*} openmct the Open MCT API (proper typescript doc to come)
* @param {import('@/api/user/UserAPI').UserAPIConfiguration} configuration Per-deployment status styling. See the type definition in UserAPI
*/
constructor(openmct, configuration) {

View File

@@ -57,8 +57,9 @@ describe('the plugin', () => {
it('calculates an fps value', async () => {
await loopForABit();
// eslint-disable-next-line
expect(parseInt(performanceIndicator.text().split(' fps')[0])).toBeGreaterThan(0);
// eslint-disable-next-line radix
const fps = parseInt(performanceIndicator.text().split(' fps')[0]);
expect(fps).toBeGreaterThan(0);
});
function loopForABit() {
@@ -66,7 +67,7 @@ describe('the plugin', () => {
return new Promise(resolve => {
requestAnimationFrame(function loop() {
if (++frames === 240) {
if (++frames > 90) {
resolve();
} else {
requestAnimationFrame(loop);

View File

@@ -2,6 +2,9 @@
const connections = [];
let connected = false;
let couchEventSource;
let changesFeedUrl;
const keepAliveTime = 20 * 1000;
let keepAliveTimer;
const controller = new AbortController();
self.onconnect = function (e) {
@@ -35,7 +38,8 @@
return;
}
self.listenForChanges(event.data.url);
changesFeedUrl = event.data.url;
self.listenForChanges();
}
};
@@ -63,17 +67,28 @@
});
};
self.listenForChanges = function (url) {
console.debug('⇿ Opening CouchDB change feed connection ⇿');
self.listenForChanges = function () {
if (keepAliveTimer) {
clearTimeout(keepAliveTimer);
}
couchEventSource = new EventSource(url);
couchEventSource.onerror = self.onerror;
couchEventSource.onopen = self.onopen;
/**
* Once the connection has been opened, poll every 20 seconds to see if the EventSource has closed unexpectedly.
* If it has, attempt to reconnect.
*/
keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime);
// start listening for events
couchEventSource.addEventListener('message', self.onCouchMessage);
connected = true;
console.debug('⇿ Opened connection ⇿');
if (!couchEventSource || couchEventSource.readyState === EventSource.CLOSED) {
console.debug('⇿ Opening CouchDB change feed connection ⇿');
couchEventSource = new EventSource(changesFeedUrl);
couchEventSource.onerror = self.onerror;
couchEventSource.onopen = self.onopen;
// start listening for events
couchEventSource.addEventListener('message', self.onCouchMessage);
connected = true;
console.debug('⇿ Opened connection ⇿');
}
};
self.updateCouchStateIndicator = function () {

View File

@@ -23,7 +23,7 @@
import CouchDocument from "./CouchDocument";
import CouchObjectQueue from "./CouchObjectQueue";
import { PENDING, CONNECTED, DISCONNECTED, UNKNOWN } from "./CouchStatusIndicator";
import { isNotebookType } from '../../notebook/notebook-constants.js';
import { isNotebookOrAnnotationType } from '../../notebook/notebook-constants.js';
const REV = "_rev";
const ID = "_id";
@@ -71,7 +71,7 @@ class CouchObjectProvider {
}
onSharedWorkerMessageError(event) {
console.log('Error', event);
console.error('Error', event);
}
isSynchronizedObject(object) {
@@ -290,7 +290,7 @@ class CouchObjectProvider {
this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]);
}
if (isNotebookType(object) || object.type === 'annotation') {
if (isNotebookOrAnnotationType(object)) {
//Temporary measure until object sync is supported for all object types
//Always update notebook revision number because we have realtime sync, so always assume it's the latest.
this.objectQueue[key].updateRevision(response[REV]);

View File

@@ -31,7 +31,7 @@ class CouchSearchProvider {
constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.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];
}
supportsSearchType(searchType) {
@@ -43,8 +43,6 @@ class CouchSearchProvider {
return this.searchForObjects(query, abortSignal);
} else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.searchForAnnotations(query, abortSignal);
} else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
return this.searchForNotebookAnnotations(query, abortSignal);
} else if (searchType === this.searchTypes.TAGS) {
return this.searchForTags(query, abortSignal);
} else {
@@ -91,46 +89,19 @@ class CouchSearchProvider {
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
searchForNotebookAnnotations({targetKeyString, entryId}, abortSignal) {
const filter = {
"selector": {
"$and": [
{
"model.type": {
"$eq": "annotation"
}
},
{
"model.annotationType": {
"$eq": "NOTEBOOK"
}
},
{
"model": {
"targets": {
}
}
}
]
}
};
filter.selector.$and[2].model.targets[targetKeyString] = {
"entryId": {
"$eq": entryId
}
};
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
searchForTags(tagsArray, abortSignal) {
if (!tagsArray || !tagsArray.length) {
return [];
}
const filter = {
"selector": {
"$and": [
{
"model.tags": {
"$elemMatch": {
"$eq": `${tagsArray[0]}`
"$or": [
]
}
}
},
@@ -142,6 +113,11 @@ class CouchSearchProvider {
]
}
};
tagsArray.forEach(tag => {
filter.selector.$and[0]["model.tags"].$elemMatch.$or.push({
"$eq": `${tag}`
});
});
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}

View File

@@ -27,7 +27,7 @@
>
<template v-if="viewBounds && !options.compact">
<swim-lane>
<template slot="label">{{ timeSystem.name }}</template>
<slot name="label">{{ timeSystem.name }}</slot>
<timeline-axis
slot="object"
:bounds="viewBounds"
@@ -129,11 +129,13 @@ export default {
this.timeContext.on("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.on("bounds", this.updateViewBounds);
this.timeContext.on("clock", this.updateBounds);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.off("bounds", this.updateViewBounds);
this.timeContext.off("clock", this.updateBounds);
}
},
observeForChanges(mutatedObject) {
@@ -142,10 +144,15 @@ export default {
},
resize() {
let clientWidth = this.getClientWidth();
let clientHeight = this.getClientHeight();
if (clientWidth !== this.width) {
this.setDimensions();
this.updateViewBounds();
}
if (clientHeight !== this.height) {
this.setDimensions();
}
},
getClientWidth() {
let clientWidth = this.$refs.plan.clientWidth;
@@ -160,9 +167,27 @@ export default {
return clientWidth - 200;
},
getClientHeight() {
let clientHeight = this.$refs.plan.clientHeight;
if (!clientHeight) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientHeight = parent.getBoundingClientRect().height;
}
}
return clientHeight;
},
getPlanData(domainObject) {
this.planData = getValidatedData(domainObject);
},
updateBounds(clock) {
if (clock === undefined) {
this.viewBounds = Object.create(this.timeContext.bounds());
}
},
updateViewBounds(bounds) {
if (bounds) {
this.viewBounds = Object.create(bounds);
@@ -191,10 +216,8 @@ export default {
activities.forEach(activity => activity.remove());
},
setDimensions() {
const planHolder = this.$refs.plan;
this.width = this.getClientWidth();
this.height = Math.round(planHolder.getBoundingClientRect().height);
this.height = this.getClientHeight();
},
setScale(timeSystem) {
if (!this.width) {
@@ -395,7 +418,7 @@ export default {
width: svgWidth
};
},
template: `<swim-lane :is-nested="isNested" :status="status"><template slot="label">{{heading}}</template><template slot="object"><svg :height="height" :width="width"></svg></template></swim-lane>`
template: `<swim-lane :is-nested="isNested" :status="status"><slot name="label">{{heading}}</template><template slot="object"><svg :height="height" :width="width"></svg></slot></swim-lane>`
});
this.$refs.planHolder.appendChild(component.$mount().$el);

117
src/plugins/plan/README.md Normal file
View File

@@ -0,0 +1,117 @@
# Plan view and domain objects
Plans provide a view for a list of activities grouped by categories.
## Plan category and activity JSON format
The JSON format for a plan consists of categories/groups and a list of activities for each category.
Activity properties include:
* name: Name of the activity
* start: Timestamps in milliseconds
* end: Timestamps in milliseconds
* type: Matches the name of the category it is in
* color: Background color for the activity
* textColor: Color of the name text for the activity
* The format of the json file is as follows:
```json
{
"TEST_GROUP": [{
"name": "Event 1 with a really long name",
"start": 1665323197000,
"end": 1665344921000,
"type": "TEST_GROUP",
"color": "orange",
"textColor": "white"
}],
"GROUP_2": [{
"name": "Event 2",
"start": 1665409597000,
"end": 1665456252000,
"type": "GROUP_2",
"color": "red",
"textColor": "white"
}]
}
```
## Plans using JSON file uploads
Plan domain objects can be created by uploading a JSON file with the format above to render categories and activities.
## Using Domain Objects directly
If uploading a JSON is not desired, it is possible to visualize domain objects of type 'plan'.
The standard model is as follows:
```javascript
{
identifier: {
namespace: ""
key: "test-plan"
}
name:"A plan object",
type:"plan",
location:"ROOT",
selectFile: {
body: {
SOME_CATEGORY: [{
name: "An activity",
start: 1665323197000,
end: 1665323197100,
type: "SOME_CATEGORY"
}
],
ANOTHER_CATEGORY: [{
name: "An activity",
start: 1665323197000,
end: 1665323197100,
type: "ANOTHER_CATEGORY"
}
]
}
}
}
```
If your data has non-standard keys for `start, end, type and activities` properties, use the `sourceMap` property mapping.
```javascript
{
identifier: {
namespace: ""
key: "another-test-plan"
}
name:"Another plan object",
type:"plan",
location:"ROOT",
sourceMap: {
start: 'start_time',
end: 'end_time',
activities: 'items',
groupId: 'category'
},
selectFile: {
body: {
items: [
{
name: "An activity",
start_time: 1665323197000,
end_time: 1665323197100,
category: "SOME_CATEGORY"
},
{
name: "Another activity",
start_time: 1665323198000,
end_time: 1665323198100,
category: "ANOTHER_CATEGORY"
}
]
}
}
}
```
## Rendering categories and activities:
The algorithm to render categories and activities on a canvas is as follows:
* Each category gets a swimlane.
* Activities within a category are rendered within it's swimlane.
* Render each activity on a given row if it's duration+label do not overlap (start/end times) with an existing activity on that row.
* Move to the next available row within a swimlane if there is overlap
* Labels for a given activity will be rendered within it's duration slot if it fits in that rectangular space.
* Labels that do not fit within an activity's duration slot are rendered outside, to the right of the duration slot.

View File

@@ -264,7 +264,7 @@ describe('the plugin', function () {
it('provides an inspector view with the version information if available', () => {
componentObject = component.$root.$children[0];
const propertiesEls = componentObject.$el.querySelectorAll('.c-inspect-properties__row');
expect(propertiesEls.length).toEqual(4);
expect(propertiesEls.length).toEqual(6);
const found = Array.from(propertiesEls).some((propertyEl) => {
return (propertyEl.children[0].innerHTML.trim() === 'Version'
&& propertyEl.children[1].innerHTML.trim() === 'v1');

View File

@@ -122,7 +122,7 @@
<button
class="c-button icon-reset"
title="Reset pan/zoom"
@click="clear()"
@click="resumeRealtimeData()"
>
</button>
</div>
@@ -141,7 +141,7 @@
v-if="isFrozen"
class="c-button icon-arrow-right pause-play is-paused"
title="Resume displaying real-time data"
@click="play()"
@click="resumeRealtimeData()"
>
</button>
</div>
@@ -213,6 +213,8 @@ import XAxis from "./axis/XAxis.vue";
import YAxis from "./axis/YAxis.vue";
import _ from "lodash";
const OFFSET_THRESHOLD = 10;
export default {
components: {
XAxis,
@@ -329,6 +331,8 @@ export default {
}
},
mounted() {
this.offsetWidth = 0;
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
eventHelpers.extend(this);
@@ -438,7 +442,8 @@ export default {
});
},
removeSeries(plotSeries) {
removeSeries(plotSeries, index) {
this.seriesModels.splice(index, 1);
this.checkSameRangeValue();
this.stopListening(plotSeries);
},
@@ -576,9 +581,8 @@ export default {
};
this.config.xAxis.set('range', newRange);
if (!isTick) {
this.skipReloadOnInteraction = true;
this.clear();
this.skipReloadOnInteraction = false;
this.clearPanZoomHistory();
this.synchronizeIfBoundsMatch();
this.loadMoreData(newRange, true);
} else {
// If we're not panning or zooming (time conductor and plot x-axis times are not out of sync)
@@ -601,16 +605,20 @@ export default {
/**
* Handle end of user viewport change: load more data for current display
* bounds, and mark view as synchronized if bounds match configured bounds.
* bounds, and mark view as synchronized if necessary.
*/
userViewportChangeEnd() {
this.synchronizeIfBoundsMatch();
const xDisplayRange = this.config.xAxis.get('displayRange');
this.loadMoreData(xDisplayRange);
},
/**
* mark view as synchronized if bounds match configured bounds.
*/
synchronizeIfBoundsMatch() {
const xDisplayRange = this.config.xAxis.get('displayRange');
const xRange = this.config.xAxis.get('range');
if (!this.skipReloadOnInteraction) {
this.loadMoreData(xDisplayRange);
}
this.synchronized(xRange.min === xDisplayRange.min
&& xRange.max === xDisplayRange.max);
},
@@ -839,7 +847,8 @@ export default {
// needs to follow endMarquee so that plotHistory is pruned
const isAction = Boolean(this.plotHistory.length);
if (!isAction && !this.isFrozenOnMouseDown) {
return this.play();
this.clearPanZoomHistory();
this.synchronizeIfBoundsMatch();
}
},
@@ -1076,18 +1085,22 @@ export default {
this.setStatus();
},
clear() {
resumeRealtimeData() {
this.clearPanZoomHistory();
this.userViewportChangeEnd();
},
clearPanZoomHistory() {
this.config.yAxis.set('frozen', false);
this.config.xAxis.set('frozen', false);
this.setStatus();
this.plotHistory = [];
this.userViewportChangeEnd();
},
back() {
const previousAxisRanges = this.plotHistory.pop();
if (this.plotHistory.length === 0) {
this.clear();
this.resumeRealtimeData();
return;
}
@@ -1105,10 +1118,6 @@ export default {
this.freeze();
},
play() {
this.clear();
},
showSynchronizeDialog() {
const isLocalClock = this.timeContext.clock();
if (isLocalClock !== undefined) {
@@ -1172,7 +1181,9 @@ export default {
this.removeStatusListener();
}
this.plotContainerResizeObserver.disconnect();
if (this.plotContainerResizeObserver) {
this.plotContainerResizeObserver.disconnect();
}
this.stopFollowingTimeContext();
this.openmct.objectViews.off('clearData', this.clearData);
@@ -1181,9 +1192,12 @@ export default {
this.$emit('statusUpdated', status);
},
handleWindowResize() {
const newOffsetWidth = this.$parent.$refs.plotWrapper.offsetWidth;
//we ignore when width gets smaller
const offsetChange = newOffsetWidth - this.offsetWidth;
if (this.$parent.$refs.plotWrapper
&& (this.offsetWidth !== this.$parent.$refs.plotWrapper.offsetWidth)) {
this.offsetWidth = this.$parent.$refs.plotWrapper.offsetWidth;
&& offsetChange > OFFSET_THRESHOLD) {
this.offsetWidth = newOffsetWidth;
this.config.series.models.forEach(this.loadSeriesData, this);
}
},

View File

@@ -93,6 +93,9 @@ export default function PlotViewProvider(openmct) {
destroy: function () {
component.$destroy();
component = undefined;
},
getComponent() {
return component;
}
};
}

View File

@@ -382,7 +382,7 @@ export default {
makeLimitLines(series) {
this.clearLimitLines(series);
if (!series.get('limitLines')) {
if (!series || !series.get('limitLines')) {
return;
}

View File

@@ -102,8 +102,8 @@ export default class Collection extends Model {
throw new Error('model not found in collection.');
}
this.emit('remove', model, index);
this.models.splice(index, 1);
this.emit('remove', model, index);
}
destroy(model) {

View File

@@ -151,16 +151,6 @@ export default class PlotSeries extends Model {
this.limitEvaluator = this.openmct.telemetry.limitEvaluator(options.domainObject);
this.limitDefinition = this.openmct.telemetry.limitDefinition(options.domainObject);
this.limits = [];
this.limitDefinition.limits().then(response => {
this.limits = [];
if (response) {
this.limits = response;
}
this.emit('limits', this);
});
this.openmct.time.on('bounds', this.updateLimits);
this.removeMutationListener = this.openmct.objects.observe(
this.domainObject,
@@ -176,15 +166,6 @@ export default class PlotSeries extends Model {
this.emit('limitBounds', bounds);
}
locateOldObject(oldStyleParent) {
return oldStyleParent.useCapability('composition')
.then(function (children) {
this.oldObject = children
.filter(function (child) {
return child.getId() === this.keyString;
}, this)[0];
}.bind(this));
}
/**
* Fetch historical data and establish a realtime subscription. Returns
* a promise that is resolved when all connections have been successfully
@@ -192,7 +173,7 @@ export default class PlotSeries extends Model {
*
* @returns {Promise}
*/
fetch(options) {
async fetch(options) {
let strategy;
if (this.model.interpolate !== 'none') {
@@ -217,23 +198,19 @@ export default class PlotSeries extends Model {
);
}
/* eslint-disable you-dont-need-lodash-underscore/concat */
return this.openmct
.telemetry
.request(this.domainObject, options)
.then((points) => {
const data = this.getSeriesData();
const newPoints = _(data)
.concat(points)
.sortBy(this.getXVal)
.uniq(true, point => [this.getXVal(point), this.getYVal(point)].join())
.value();
this.reset(newPoints);
})
.catch((error) => {
console.warn('Error fetching data', error);
});
/* eslint-enable you-dont-need-lodash-underscore/concat */
try {
const points = await this.openmct.telemetry.request(this.domainObject, options);
const data = this.getSeriesData();
// eslint-disable-next-line you-dont-need-lodash-underscore/concat
const newPoints = _(data)
.concat(points)
.sortBy(this.getXVal)
.uniq(true, point => [this.getXVal(point), this.getYVal(point)].join())
.value();
this.reset(newPoints);
} catch (error) {
console.warn('Error fetching data', error);
}
}
updateName(name) {
@@ -334,16 +311,19 @@ export default class PlotSeries extends Model {
* Override this to implement plot series loading functionality. Must return
* a promise that is resolved when loading is completed.
*
* @private
* @returns {Promise}
*/
load(options) {
return this.fetch(options)
.then(function (res) {
this.emit('load');
async load(options) {
await this.fetch(options);
this.emit('load');
const limitsResponse = await this.limitDefinition.limits();
this.limits = [];
if (limitsResponse) {
this.limits = limitsResponse;
}
return res;
}.bind(this));
this.emit('limits', this);
this.emit('change:limitLines', this);
}
/**

View File

@@ -144,12 +144,6 @@ describe("the plugin", function () {
element.appendChild(child);
document.body.appendChild(element);
spyOn(window, 'ResizeObserver').and.returnValue({
observe() {},
unobserve() {},
disconnect() {}
});
openmct.types.addType("test-object", {
creatable: true
});
@@ -166,7 +160,7 @@ describe("the plugin", function () {
afterEach((done) => {
openmct.time.timeSystem('utc', {
start: 0,
end: 1
end: 2
});
configStore.deleteAll();
@@ -506,6 +500,23 @@ describe("the plugin", function () {
expect(playElAfterChartClick.length).toBe(1);
});
it("clicking the plot does not request historical data", async () => {
expect(openmct.telemetry.request).toHaveBeenCalledTimes(2);
// simulate an errant mouse click
// the second item is the canvas we need to use
const canvas = element.querySelectorAll("canvas")[1];
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
canvas.dispatchEvent(mouseDownEvent);
// mouseup event is bound to the window
window.dispatchEvent(mouseUpEvent);
await Vue.nextTick();
expect(openmct.telemetry.request).toHaveBeenCalledTimes(2);
});
});
describe('controls in time strip view', () => {
@@ -528,6 +539,94 @@ describe("the plugin", function () {
});
});
describe('resizing the plot', () => {
let plotContainerResizeObserver;
let resizePromiseResolve;
let testTelemetryObject;
let applicableViews;
let plotViewProvider;
let plotView;
let resizePromise;
beforeEach(() => {
testTelemetryObject = {
identifier: {
namespace: "",
key: "test-object"
},
type: "test-object",
name: "Test Object",
telemetry: {
values: [{
key: "utc",
format: "utc",
name: "Time",
hints: {
domain: 1
}
}, {
key: "some-key",
name: "Some attribute",
hints: {
range: 1
}
}, {
key: "some-other-key",
name: "Another attribute",
hints: {
range: 2
}
}]
}
};
openmct.router.path = [testTelemetryObject];
applicableViews = openmct.objectViews.get(testTelemetryObject, mockObjectPath);
plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === "plot-single");
plotView = plotViewProvider.view(testTelemetryObject, []);
plotView.show(child, true);
resizePromise = new Promise((resolve) => {
resizePromiseResolve = resolve;
});
const handlePlotResize = _.debounce(() => {
resizePromiseResolve(true);
}, 600);
plotContainerResizeObserver = new ResizeObserver(handlePlotResize);
plotContainerResizeObserver.observe(plotView.getComponent().$children[0].$children[1].$parent.$refs.plotWrapper);
return Vue.nextTick(() => {
plotView.getComponent().$children[0].$children[1].stopFollowingTimeContext();
spyOn(plotView.getComponent().$children[0].$children[1], 'loadSeriesData').and.callThrough();
});
});
afterEach(() => {
plotContainerResizeObserver.disconnect();
openmct.router.path = null;
});
it("requests historical data when over the threshold", (done) => {
element.style.width = '680px';
resizePromise.then(() => {
expect(plotView.getComponent().$children[0].$children[1].loadSeriesData).toHaveBeenCalledTimes(1);
done();
});
});
it("does not request historical data when under the threshold", (done) => {
element.style.width = '644px';
resizePromise.then(() => {
expect(plotView.getComponent().$children[0].$children[1].loadSeriesData).not.toHaveBeenCalled();
done();
});
});
});
describe('the inspector view', () => {
let component;
let viewComponentObject;

View File

@@ -19,8 +19,12 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
export default class RemoveAction {
#transaction;
constructor(openmct) {
this.name = 'Remove';
this.key = 'remove';
this.description = 'Remove this object from its containing object.';
@@ -29,17 +33,25 @@ export default class RemoveAction {
this.priority = 1;
this.openmct = openmct;
this.removeFromComposition = this.removeFromComposition.bind(this); // for access to private transaction variable
}
invoke(objectPath) {
async invoke(objectPath) {
let object = objectPath[0];
let parent = objectPath[1];
this.showConfirmDialog(object).then(() => {
this.removeFromComposition(parent, object);
if (this.inNavigationPath(object)) {
this.navigateTo(objectPath.slice(1));
}
}).catch(() => {});
try {
await this.showConfirmDialog(object);
} catch (error) {
return; // form canceled, exit invoke
}
await this.removeFromComposition(parent, object);
if (this.inNavigationPath(object)) {
this.navigateTo(objectPath.slice(1));
}
}
showConfirmDialog(object) {
@@ -81,20 +93,21 @@ export default class RemoveAction {
this.openmct.router.navigate('#/browse/' + urlPath);
}
removeFromComposition(parent, child) {
let composition = parent.composition.filter(id =>
!this.openmct.objects.areIdsEqual(id, child.identifier)
);
async removeFromComposition(parent, child) {
this.startTransaction();
this.openmct.objects.mutate(parent, 'composition', composition);
const composition = this.openmct.composition.get(parent);
composition.remove(child);
if (!this.isAlias(child, parent)) {
this.openmct.objects.mutate(child, 'location', null);
}
if (this.inNavigationPath(child) && this.openmct.editor.isEditing()) {
this.openmct.editor.save();
}
if (!this.isAlias(child, parent)) {
this.openmct.objects.mutate(child, 'location', null);
}
await this.saveTransaction();
}
isAlias(child, parent) {
@@ -132,4 +145,23 @@ export default class RemoveAction {
&& parentType.definition.creatable
&& Array.isArray(parent.composition);
}
startTransaction() {
if (!this.openmct.objects.isTransactionActive()) {
this.#transaction = this.openmct.objects.startTransaction();
}
}
saveTransaction() {
if (!this.#transaction) {
return;
}
return this.#transaction.commit()
.catch(error => {
throw error;
}).finally(() => {
this.openmct.objects.endTransaction();
});
}
}

View File

@@ -257,11 +257,11 @@
@change-height="setRowHeight"
/>
<tr>
<template v-for="(title, key) in headers">
<th
:key="key"
:style="{ width: configuredColumnWidths[key] + 'px', 'max-width': configuredColumnWidths[key] + 'px'}"
>
<template
v-for="(title, key) in headers"
:key="key"
>
<th :style="{ width: configuredColumnWidths[key] + 'px', 'max-width': configuredColumnWidths[key] + 'px'}">
{{ title }}
</th>
</template>

View File

@@ -26,6 +26,7 @@
>
<div class="c-menu-button c-ctrl-wrapper c-ctrl-wrapper--menus-left">
<button
aria-label="Time Conductor History"
class="c-button--menu c-history-button icon-history"
@click.prevent.stop="showHistoryMenu"
>
@@ -144,10 +145,11 @@ export default {
this.initializeHistoryIfNoHistory();
},
getHistoryMenuItems() {
const descriptionDateFormat = 'YYYY-MM-DD HH:mm:ss.SSS';
const history = this.historyForCurrentTimeSystem.map(timespan => {
let name;
let startTime = this.formatTime(timespan.start);
let description = `${startTime} - ${this.formatTime(timespan.end)}`;
const startTime = this.formatTime(timespan.start);
const description = `${this.formatTime(timespan.start, descriptionDateFormat)} - ${this.formatTime(timespan.end, descriptionDateFormat)}`;
if (this.timeSystem.isUTCBased && !this.openmct.time.clock()) {
name = `${startTime} ${millisecondsToDHMS(timespan.end - timespan.start)}`;
@@ -253,7 +255,7 @@ export default {
return maxRecordsLength;
},
formatTime(time) {
formatTime(time, utcDateFormat) {
let format = this.timeSystem.timeFormat;
let isNegativeOffset = false;
@@ -274,7 +276,8 @@ export default {
let formattedDate;
if (formatter instanceof UTCTimeFormat) {
formattedDate = formatter.format(time, formatter.DATE_FORMATS.PRECISION_SECONDS);
const formatString = formatter.isValidFormatString(utcDateFormat) ? utcDateFormat : formatter.DATE_FORMATS.PRECISION_SECONDS;
formattedDate = formatter.format(time, formatString);
} else {
formattedDate = formatter.format(time);
}

View File

@@ -14,7 +14,7 @@
:bottom="keyString !== undefined"
:type="'start'"
:offset="offsets.start"
@focus.native="$event.target.select()"
@focus="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>
@@ -54,7 +54,7 @@
:bottom="keyString !== undefined"
:type="'end'"
:offset="offsets.end"
@focus.native="$event.target.select()"
@focus="$event.target.select()"
@hide="hideAllTimePopups"
@update="timePopUpdate"
/>

View File

@@ -124,12 +124,10 @@ export default {
};
this.items.push(item);
this.updateContentHeight();
},
removeItem(identifier) {
let index = this.items.findIndex(item => this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier));
this.items.splice(index, 1);
this.updateContentHeight();
},
reorder(reorderPlan) {
let oldItems = this.items.slice();
@@ -138,7 +136,23 @@ export default {
});
},
updateContentHeight() {
this.height = Math.round(this.$refs.contentHolder.getBoundingClientRect().height);
const clientHeight = this.getClientHeight();
if (this.height !== clientHeight) {
this.height = clientHeight;
}
},
getClientHeight() {
let clientHeight = this.$refs.contentHolder.getBoundingClientRect().height;
if (!clientHeight) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientHeight = parent.getBoundingClientRect().height;
}
}
return clientHeight;
},
getTimeSystems() {
const timeSystems = this.openmct.time.getAllTimeSystems();
@@ -155,7 +169,9 @@ export default {
//TODO: Some kind of translation via an offset? of current bounds to target timeSystem
return currentBounds;
},
updateViewBounds(bounds) {
updateViewBounds() {
const bounds = this.timeContext.bounds();
this.updateContentHeight();
let currentTimeSystem = this.timeSystems.find(item => item.timeSystem.key === this.openmct.time.timeSystem().key);
if (currentTimeSystem) {
currentTimeSystem.bounds = bounds;
@@ -166,12 +182,14 @@ export default {
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.getTimeSystems();
this.updateViewBounds(this.timeContext.bounds());
this.updateViewBounds();
this.timeContext.on('bounds', this.updateViewBounds);
this.timeContext.on('clock', this.updateViewBounds);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off('bounds', this.updateViewBounds);
this.timeContext.off('clock', this.updateViewBounds);
}
}
}

View File

@@ -27,6 +27,7 @@ import EventEmitter from "EventEmitter";
describe('the plugin', function () {
let objectDef;
let appHolder;
let element;
let child;
let openmct;
@@ -92,6 +93,10 @@ describe('the plugin', function () {
};
beforeEach((done) => {
appHolder = document.createElement('div');
appHolder.style.width = '640px';
appHolder.style.height = '480px';
mockObjectPath = [
{
name: 'mock folder',
@@ -133,7 +138,7 @@ describe('the plugin', function () {
element.appendChild(child);
openmct.on('start', done);
openmct.startHeadless();
openmct.start(appHolder);
});
afterEach(() => {
@@ -167,7 +172,7 @@ describe('the plugin', function () {
const applicableViews = openmct.objectViews.get(testViewObject, mockObjectPath);
timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject, element);
let view = timelineView.view(testViewObject, mockObjectPath);
view.show(child, true);
return Vue.nextTick();
@@ -245,7 +250,7 @@ describe('the plugin', function () {
beforeEach(done => {
const applicableViews = openmct.objectViews.get(testViewObject, mockObjectPath);
timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject, element);
let view = timelineView.view(testViewObject, mockObjectPath);
view.show(child, true);
Vue.nextTick(done);
@@ -281,7 +286,7 @@ describe('the plugin', function () {
beforeEach((done) => {
const applicableViews = openmct.objectViews.get(testViewObject2, mockObjectPath);
timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject2, element);
let view = timelineView.view(testViewObject2, mockObjectPath);
view.show(child, true);
Vue.nextTick(done);

View File

@@ -20,250 +20,225 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
define(
[
'EventEmitter',
'lodash'
],
function (
EventEmitter,
_
) {
/**
* Manages selection state for Open MCT
* @private
*/
function Selection(openmct) {
EventEmitter.call(this);
import EventEmitter from 'EventEmitter';
import _ from 'lodash';
this.openmct = openmct;
this.selected = [];
/**
* Manages selection state for Open MCT
* @private
*/
export default class Selection extends EventEmitter {
constructor(openmct) {
super();
this.openmct = openmct;
this.selected = [];
}
/**
* Gets the selected object.
* @public
*/
get() {
return this.selected;
}
/**
* Selects the selectable object and emits the 'change' event.
*
* @param {object} selectable an object with element and context properties
* @param {Boolean} isMultiSelectEvent flag indication shift key is pressed or not
* @private
*/
select(selectable, isMultiSelectEvent) {
if (!Array.isArray(selectable)) {
selectable = [selectable];
}
Selection.prototype = Object.create(EventEmitter.prototype);
let multiSelect = isMultiSelectEvent
&& this.parentSupportsMultiSelect(selectable)
&& this.isPeer(selectable)
&& !this.selectionContainsParent(selectable);
/**
* Gets the selected object.
* @public
*/
Selection.prototype.get = function () {
return this.selected;
};
if (multiSelect) {
this.handleMultiSelect(selectable);
} else {
this.handleSingleSelect(selectable);
}
}
/**
* @private
*/
handleMultiSelect(selectable) {
if (this.elementSelected(selectable)) {
this.remove(selectable);
} else {
this.addSelectionAttributes(selectable);
this.selected.push(selectable);
}
/**
* Selects the selectable object and emits the 'change' event.
*
* @param {object} selectable an object with element and context properties
* @param {Boolean} isMultiSelectEvent flag indication shift key is pressed or not
* @private
*/
Selection.prototype.select = function (selectable, isMultiSelectEvent) {
if (!Array.isArray(selectable)) {
selectable = [selectable];
}
let multiSelect = isMultiSelectEvent
&& this.parentSupportsMultiSelect(selectable)
&& this.isPeer(selectable)
&& !this.selectionContainsParent(selectable);
if (multiSelect) {
this.handleMultiSelect(selectable);
} else {
this.handleSingleSelect(selectable);
}
};
/**
* @private
*/
Selection.prototype.handleMultiSelect = function (selectable) {
if (this.elementSelected(selectable)) {
this.remove(selectable);
} else {
this.addSelectionAttributes(selectable);
this.selected.push(selectable);
}
this.emit('change', this.selected);
}
/**
* @private
*/
handleSingleSelect(selectable) {
if (!_.isEqual([selectable], this.selected)) {
this.setSelectionStyles(selectable);
this.selected = [selectable];
this.emit('change', this.selected);
}
}
/**
* @private
*/
elementSelected(selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath, selectable));
}
/**
* @private
*/
remove(selectable) {
this.selected = this.selected.filter(selectionPath => !_.isEqual(selectionPath, selectable));
if (this.selected.length === 0) {
this.removeSelectionAttributes(selectable);
selectable[1].element.click(); // Select the parent if there is no selection.
} else {
this.removeSelectionAttributes(selectable, true);
}
}
/**
* @private
*/
setSelectionStyles(selectable) {
this.selected.forEach(selectionPath => this.removeSelectionAttributes(selectionPath));
this.addSelectionAttributes(selectable);
}
removeSelectionAttributes(selectionPath, keepParentStyle) {
if (selectionPath[0] && selectionPath[0].element) {
selectionPath[0].element.removeAttribute('s-selected');
}
if (selectionPath[1] && selectionPath[1].element && !keepParentStyle) {
selectionPath[1].element.removeAttribute('s-selected-parent');
}
}
/**
* Adds selection attributes to the selected element and its parent.
* @private
*/
addSelectionAttributes(selectable) {
if (selectable[0] && selectable[0].element) {
selectable[0].element.setAttribute('s-selected', "");
}
if (selectable[1] && selectable[1].element) {
selectable[1].element.setAttribute('s-selected-parent', "");
}
}
/**
* @private
*/
parentSupportsMultiSelect(selectable) {
return selectable[1] && selectable[1].context.supportsMultiSelect;
}
/**
* @private
*/
selectionContainsParent(selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath[0], selectable[1]));
}
/**
* @private
*/
isPeer(selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath[1], selectable[1]));
}
/**
* @private
*/
isSelectable(element) {
if (!element) {
return false;
}
return Boolean(element.closest('[data-selectable]'));
}
/**
* @private
*/
capture(selectable) {
let capturingContainsSelectable = this.capturing && this.capturing.includes(selectable);
if (!this.capturing || capturingContainsSelectable) {
this.capturing = [];
}
this.capturing.push(selectable);
}
/**
* @private
*/
selectCapture(selectable, event) {
if (!this.capturing) {
return;
}
let reversedCapturing = this.capturing.reverse();
delete this.capturing;
this.select(reversedCapturing, event.shiftKey);
}
/**
* Attaches the click handlers to the element.
*
* @param element an html element
* @param context object which defines item or other arbitrary properties.
* e.g. {
* item: domainObject,
* elementProxy: element,
* controller: fixedController
* }
* @param select a flag to select the element if true
* @returns a function that removes the click handlers from the element
* @public
*/
selectable(element, context, select) {
if (!this.isSelectable(element)) {
return () => { };
}
let selectable = {
context: context,
element: element
};
/**
* @private
*/
Selection.prototype.handleSingleSelect = function (selectable) {
if (!_.isEqual([selectable], this.selected)) {
this.setSelectionStyles(selectable);
this.selected = [selectable];
const capture = this.capture.bind(this, selectable);
const selectCapture = this.selectCapture.bind(this, selectable);
let removeMutable = false;
this.emit('change', this.selected);
element.addEventListener('click', capture, true);
element.addEventListener('click', selectCapture);
if (context.item && context.item.isMutable !== true) {
removeMutable = true;
context.item = this.openmct.objects.toMutable(context.item);
}
if (select) {
if (typeof select === 'object') {
element.dispatchEvent(select);
} else if (typeof select === 'boolean') {
element.click();
}
};
}
/**
* @private
*/
Selection.prototype.elementSelected = function (selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath, selectable));
};
return (function () {
element.removeEventListener('click', capture, true);
element.removeEventListener('click', selectCapture);
/**
* @private
*/
Selection.prototype.remove = function (selectable) {
this.selected = this.selected.filter(selectionPath => !_.isEqual(selectionPath, selectable));
if (this.selected.length === 0) {
this.removeSelectionAttributes(selectable);
selectable[1].element.click(); // Select the parent if there is no selection.
} else {
this.removeSelectionAttributes(selectable, true);
if (context.item !== undefined && context.item.isMutable && removeMutable === true) {
this.openmct.objects.destroyMutable(context.item);
}
};
/**
* @private
*/
Selection.prototype.setSelectionStyles = function (selectable) {
this.selected.forEach(selectionPath => this.removeSelectionAttributes(selectionPath));
this.addSelectionAttributes(selectable);
};
Selection.prototype.removeSelectionAttributes = function (selectionPath, keepParentStyle) {
if (selectionPath[0] && selectionPath[0].element) {
selectionPath[0].element.removeAttribute('s-selected');
}
if (selectionPath[1] && selectionPath[1].element && !keepParentStyle) {
selectionPath[1].element.removeAttribute('s-selected-parent');
}
};
/*
* Adds selection attributes to the selected element and its parent.
* @private
*/
Selection.prototype.addSelectionAttributes = function (selectable) {
if (selectable[0] && selectable[0].element) {
selectable[0].element.setAttribute('s-selected', "");
}
if (selectable[1] && selectable[1].element) {
selectable[1].element.setAttribute('s-selected-parent', "");
}
};
/**
* @private
*/
Selection.prototype.parentSupportsMultiSelect = function (selectable) {
return selectable[1] && selectable[1].context.supportsMultiSelect;
};
/**
* @private
*/
Selection.prototype.selectionContainsParent = function (selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath[0], selectable[1]));
};
/**
* @private
*/
Selection.prototype.isPeer = function (selectable) {
return this.selected.some(selectionPath => _.isEqual(selectionPath[1], selectable[1]));
};
/**
* @private
*/
Selection.prototype.isSelectable = function (element) {
if (!element) {
return false;
}
return Boolean(element.closest('[data-selectable]'));
};
/**
* @private
*/
Selection.prototype.capture = function (selectable) {
let capturingContainsSelectable = this.capturing && this.capturing.includes(selectable);
if (!this.capturing || capturingContainsSelectable) {
this.capturing = [];
}
this.capturing.push(selectable);
};
/**
* @private
*/
Selection.prototype.selectCapture = function (selectable, event) {
if (!this.capturing) {
return;
}
let reversedCapturing = this.capturing.reverse();
delete this.capturing;
this.select(reversedCapturing, event.shiftKey);
};
/**
* Attaches the click handlers to the element.
*
* @param element an html element
* @param context object which defines item or other arbitrary properties.
* e.g. {
* item: domainObject,
* elementProxy: element,
* controller: fixedController
* }
* @param select a flag to select the element if true
* @returns a function that removes the click handlers from the element
* @public
*/
Selection.prototype.selectable = function (element, context, select) {
if (!this.isSelectable(element)) {
return () => {};
}
let selectable = {
context: context,
element: element
};
const capture = this.capture.bind(this, selectable);
const selectCapture = this.selectCapture.bind(this, selectable);
let removeMutable = false;
element.addEventListener('click', capture, true);
element.addEventListener('click', selectCapture);
if (context.item && context.item.isMutable !== true) {
removeMutable = true;
context.item = this.openmct.objects._toMutable(context.item);
}
if (select) {
if (typeof select === 'object') {
element.dispatchEvent(select);
} else if (typeof select === 'boolean') {
element.click();
}
}
return (function () {
element.removeEventListener('click', capture, true);
element.removeEventListener('click', selectCapture);
if (context.item !== undefined && context.item.isMutable && removeMutable === true) {
this.openmct.objects.destroyMutable(context.item);
}
}).bind(this);
};
return Selection;
});
}).bind(this);
}
}

View File

@@ -94,15 +94,13 @@ export default {
if (this.openmct.time.clock() === undefined) {
let nowMarker = document.querySelector('.nowMarker');
if (nowMarker) {
nowMarker.parentNode.removeChild(nowMarker);
nowMarker.classList.add('hidden');
}
} else {
let nowMarker = document.querySelector('.nowMarker');
if (nowMarker) {
const svgEl = d3Selection.select(this.svgElement).node();
let height = svgEl.style('height').replace('px', '');
height = Number(height) + this.contentHeight;
nowMarker.style.height = height + 'px';
nowMarker.classList.remove('hidden');
nowMarker.style.height = this.contentHeight + 'px';
const now = this.xScale(Date.now());
nowMarker.style.left = now + this.offset + 'px';
}

View File

@@ -5,10 +5,10 @@
>
<input
class="c-search__input"
v-bind="$attrs"
aria-label="Search Input"
tabindex="10000"
type="search"
v-bind="$attrs"
:value="value"
v-on="inputListeners"
>

View File

@@ -51,18 +51,14 @@ export default {
},
inject: ['openmct'],
props: {
annotationQuery: {
type: Object,
annotations: {
type: Array,
required: true
},
annotationType: {
type: String,
required: true
},
annotationSearchType: {
type: String,
required: true
},
targetSpecificDetails: {
type: Object,
required: true
@@ -76,7 +72,6 @@ export default {
},
data() {
return {
annontation: null,
addedTags: [],
userAddingTag: false
};
@@ -92,57 +87,50 @@ export default {
}
},
watch: {
annotation: {
annotations: {
handler() {
this.tagsChanged(this.annotation.tags);
},
deep: true
},
annotationQuery: {
handler() {
this.unloadAnnotation();
this.loadAnnotation();
this.annotationsChanged();
},
deep: true
}
},
mounted() {
this.loadAnnotation();
},
destroyed() {
if (this.removeTagsListener) {
this.removeTagsListener();
}
this.annotationsChanged();
},
methods: {
addAnnotationListener(annotation) {
if (annotation && !this.removeTagsListener) {
this.removeTagsListener = this.openmct.objects.observe(annotation, '*', (newAnnotation) => {
this.tagsChanged(newAnnotation.tags);
this.annotation = newAnnotation;
});
annotationsChanged() {
if (this.annotations && this.annotations.length) {
this.tagsChanged();
}
},
async loadAnnotation() {
this.annotation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType);
this.addAnnotationListener(this.annotation);
if (this.annotation && this.annotation.tags) {
this.tagsChanged(this.annotation.tags);
annotationDeletionListener(changedAnnotation) {
const matchingAnnotation = this.annotations.find((possibleMatchingAnnotation) => {
return this.openmct.objects.areIdsEqual(possibleMatchingAnnotation.identifier, changedAnnotation.identifier);
});
if (matchingAnnotation) {
matchingAnnotation._deleted = changedAnnotation._deleted;
this.userAddingTag = false;
this.tagsChanged();
}
},
unloadAnnotation() {
if (this.removeTagsListener) {
this.removeTagsListener();
this.removeTagsListener = undefined;
}
},
tagsChanged(newTags) {
if (newTags.length < this.addedTags.length) {
this.addedTags = this.addedTags.slice(0, newTags.length);
tagsChanged() {
// gather tags from annotations
const tagsFromAnnotations = this.annotations.flatMap((annotation) => {
if (annotation._deleted) {
return [];
} else {
return annotation.tags;
}
}).filter((tag, index, array) => {
return array.indexOf(tag) === index;
});
if (tagsFromAnnotations.length !== this.addedTags.length) {
this.addedTags = this.addedTags.slice(0, tagsFromAnnotations.length);
}
for (let index = 0; index < newTags.length; index += 1) {
this.$set(this.addedTags, index, newTags[index]);
for (let index = 0; index < tagsFromAnnotations.length; index += 1) {
this.$set(this.addedTags, index, tagsFromAnnotations[index]);
}
},
addTag() {
@@ -153,23 +141,27 @@ export default {
this.userAddingTag = true;
},
async tagRemoved(tagToRemove) {
const result = await this.openmct.annotation.removeAnnotationTag(this.annotation, tagToRemove);
this.$emit('tags-updated');
return result;
// Soft delete annotations that match tag instead
const annotationsToDelete = this.annotations.filter((annotation) => {
return annotation.tags.includes(tagToRemove);
});
if (annotationsToDelete) {
await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
this.$emit('tags-updated', annotationsToDelete);
}
},
async tagAdded(newTag) {
const annotationWasCreated = this.annotation === null || this.annotation === undefined;
this.annotation = await this.openmct.annotation.addAnnotationTag(this.annotation,
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
if (annotationWasCreated) {
this.addAnnotationListener(this.annotation);
}
// Either undelete an annotation, or create one (1) new annotation
const existingAnnotation = this.annotations.find((annotation) => {
return annotation.tags.includes(newTag);
});
const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation,
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
this.tagsChanged(this.annotation.tags);
this.userAddingTag = false;
this.$emit('tags-updated');
this.$emit('tags-updated', createdAnnotation);
}
}
};

View File

@@ -37,7 +37,10 @@
class="c-tag"
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
>
<div class="c-tag__label">{{ selectedTagLabel }} </div>
<div
class="c-tag__label"
aria-label="Tag"
>{{ selectedTagLabel }} </div>
<button
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
@click="removeTag"

View File

@@ -32,6 +32,10 @@
z-index: 10;
background: gray;
&.hidden {
display: none;
}
& .icon-arrow-down {
font-size: large;
position: absolute;

View File

@@ -33,7 +33,8 @@
class="c-tree__item c-elements-pool__item"
:class="{
'is-context-clicked': contextClickActive,
'hover': hover
'hover': hover,
'is-alias': isAlias
}"
>
<span
@@ -55,6 +56,7 @@ export default {
components: {
ObjectLabel
},
inject: ['openmct'],
props: {
index: {
type: Number,
@@ -82,9 +84,12 @@ export default {
}
},
data() {
const isAlias = this.elementObject.location !== this.openmct.objects.makeKeyString(this.parentObject.identifier);
return {
contextClickActive: false,
hover: false
hover: false,
isAlias
};
},
methods: {

View File

@@ -38,6 +38,8 @@ describe('the inspector', () => {
folderItem = {
name: 'folder',
type: 'folder',
createdBy: 'John Q',
modifiedBy: 'Public',
id: 'mock-folder-key',
identifier: {
namespace: '',
@@ -74,6 +76,8 @@ describe('the inspector', () => {
const [
title,
type,
createdBy,
modifiedBy,
notes,
timestamp
] = details;
@@ -87,6 +91,14 @@ describe('the inspector', () => {
.toEqual('Type');
expect(type.value.toLowerCase())
.toEqual(folderItem.type);
expect(createdBy.name)
.toEqual('Created By');
expect(createdBy.value)
.toEqual(folderItem.createdBy);
expect(modifiedBy.name)
.toEqual('Modified By');
expect(modifiedBy.value)
.toEqual(folderItem.modifiedBy);
expect(notes.value)
.toEqual('This object should have some notes');

View File

@@ -90,10 +90,13 @@ export default {
return;
}
const UNKNOWN_USER = 'Unknown';
const title = this.domainObject.name;
const typeName = this.type ? this.type.definition.name : `Unknown: ${this.domainObject.type}`;
const timestampLabel = this.domainObject.modified ? 'Modified' : 'Created';
const timestamp = this.domainObject.modified ? this.domainObject.modified : this.domainObject.created;
const createdTimestamp = this.domainObject.created;
const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
const modifiedTimestamp = this.domainObject.modified ? this.domainObject.modified : this.domainObject.created;
const notes = this.domainObject.notes;
const version = this.domainObject.version;
@@ -105,6 +108,14 @@ export default {
{
name: 'Type',
value: typeName
},
{
name: 'Created By',
value: createdBy
},
{
name: 'Modified By',
value: modifiedBy
}
];
@@ -115,15 +126,28 @@ export default {
});
}
if (timestamp !== undefined) {
const formattedTimestamp = Moment.utc(timestamp)
if (createdTimestamp !== undefined) {
const formattedCreatedTimestamp = Moment.utc(createdTimestamp)
.format('YYYY-MM-DD[\n]HH:mm:ss')
+ ' UTC';
details.push(
{
name: timestampLabel,
value: formattedTimestamp
name: 'Created',
value: formattedCreatedTimestamp
}
);
}
if (modifiedTimestamp !== undefined) {
const formattedModifiedTimestamp = Moment.utc(modifiedTimestamp)
.format('YYYY-MM-DD[\n]HH:mm:ss')
+ ' UTC';
details.push(
{
name: 'Modified',
value: formattedModifiedTimestamp
}
);
}

View File

@@ -8,6 +8,15 @@
margin-top: $interiorMargin;
}
&__item {
&.is-alias {
// Object is an alias to an original.
[class*='__type-icon'] {
@include isAlias();
}
}
}
&__search {
flex: 0 0 auto;
}

View File

@@ -232,6 +232,8 @@ describe("GrandSearch", () => {
it("should render an object search result if new object added", async () => {
const composition = openmct.composition.get(mockFolderObject);
composition.add(mockNewObject);
// after adding, need to wait a beat for the folder to be indexed
await Vue.nextTick();
await grandSearchComponent.$children[0].searchEverything('apple');
await Vue.nextTick();
const searchResults = document.querySelectorAll('[aria-label="New Apple Test Folder folder result"]');
@@ -271,6 +273,13 @@ describe("GrandSearch", () => {
expect(annotationResults[1].innerText).toContain('Driving');
});
it("should render no annotation search results if no match", async () => {
await grandSearchComponent.$children[0].searchEverything('Qbert');
await Vue.nextTick();
const annotationResults = document.querySelectorAll('[aria-label="Search Result"]');
expect(annotationResults.length).toBe(0);
});
it("should preview object search results in edit mode if object clicked", async () => {
await grandSearchComponent.$children[0].searchEverything('Folder');
grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout];

View File

@@ -41,7 +41,7 @@
:key="openmct.objects.makeKeyString(objectResult.identifier)"
:result="objectResult"
@preview-changed="previewChanged"
@click.native="selectedResult"
@click="selectedResult"
/>
</div>
<div
@@ -53,7 +53,7 @@
v-for="(annotationResult) in annotationResults"
:key="openmct.objects.makeKeyString(annotationResult.identifier)"
:result="annotationResult"
@click.native="selectedResult"
@click="selectedResult"
/>
</div>
<div

View File

@@ -7,10 +7,10 @@
<div class="c-labeled-input__label">{{ options.label }}</div>
</label>
<input
v-bind="options.attrs"
:id="uid"
:type="options.type"
:value="options.value"
v-bind="options.attrs"
>
</div>
</template>

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