Compare commits

...

46 Commits

Author SHA1 Message Date
Shefali Joshi
2301d43b45 Merge branch 'master' into pouchdb-proto-sync 2021-03-16 14:37:01 -07:00
Jamie V
b042d9098d doing correct comparison to find page to delete (#3754) 2021-03-12 15:49:58 -08:00
Shefali Joshi
325f2c4860 Pinch to zoom and wheel zoom work the same way (#3752) 2021-03-12 12:22:22 -08:00
Jamie V
74a516aa9e [Date Picker] Highlight Selected Date (#3678)
* WIP: added color, was not sure correct sass variable, charles... take it from here!

* adding charles name to the comment for posterity

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-03-11 10:25:30 -08:00
Shefali Joshi
28e26461cc Change master to 1.6.3 SNAPSHOT (#3749) 2021-03-10 10:40:30 -08:00
Deep Tailor
50016db052 fix create issue 2021-03-08 16:00:30 -08:00
David Tsay
cfaaf6b1fe [Imagery] Update to be compatible with VIPER (#3744)
* heading, sun heading, and camera pan  are all absolute directions
* removing roll and pitch keys as they will not be necessary
* proofing against empty historical or realtime ids from config
* adding checks in imagerylayout for missing properties

Co-authored-by: Jamie Vigliotta <jamie.j.vigliotta@nasa.gov>
2021-03-08 15:27:35 -08:00
Deep Tailor
629ec8950b rename file 2021-03-08 14:16:56 -08:00
Deep Tailor
bca4eaf9c2 delete unused files 2021-03-08 14:16:24 -08:00
Deep Tailor
15ddd43cf0 update with new PouchProvider 2021-03-08 14:11:29 -08:00
Deep Tailor
0c90b46d6e working proto, still bugs to fix 2021-03-08 11:54:33 -08:00
Shefali Joshi
bffe79ecbd Pass object path when getting views during object creation (#3736) 2021-03-04 10:17:14 -08:00
Shefali Joshi
94d9852339 Force Vue to look for the reactive property and recompute views (#3733)
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-03-03 15:42:55 -08:00
Jamie V
905e397d3f making sure ALL telemetry for a given indicator is fresh (#3734) 2021-03-03 15:37:49 -08:00
Shefali Joshi
e70a636073 Accept plans from a file OR from JSON object (couchDB) (#3729)
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-03-03 14:50:48 -08:00
Jamie V
03abb5e5de [Abort Search] Completely Abort Search (#3728)
* added new spacecraftPosition keys and changed old spacecraft keys to spacecraftOriention keys, updated how freshness is determined

* due to tests, added some checks for missing related telemetry before acting on it

* lint fixes

* pr comments, change simple if to Boolean

* checking if search aborted before aggregating search results, which calls getOriginalPath, which calls more gets

* removing erroneous abort signal check
2021-03-03 14:05:17 -08:00
Shefali Joshi
ac20c01233 Fix method name in Plan.vue (#3726) 2021-03-03 13:34:07 -08:00
Shefali Joshi
b8ded0a16e Hide vue plots from showing up in the view-switcher and in the preview window (#3725)
* Hide vue plots from showing up in the view-switcher and in the preview window.
2021-03-03 11:33:31 -08:00
Deep Tailor
b68f79f427 [Elements Pool][Flexible Layout] Improvements (#3694)
* fix flexible-layout bug

* abstract element item from elements pool

* improve dragging of elements pool

* better dragover handling in elements pool

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2021-03-03 09:50:48 -08:00
Jamie V
221d10d3e6 [Imagery Freshness] Update Telemetry for Imagery Freshness (#3723)
* added new spacecraftPosition keys and changed old spacecraft keys to spacecraftOriention keys, updated how freshness is determined

* due to tests, added some checks for missing related telemetry before acting on it

* lint fixes

* pr comments, change simple if to Boolean
2021-03-02 16:55:27 -08:00
Charles Hacskaylo
22d32eed1d Tweak plan view (#3724)
* Plan view final sanding for Build 4

- Removed unused selector;
- Changed class pointer for "no-activites" labels, refined text;

* Plan view final sanding for Build 4

- Removed unused selector;
- Changed class pointer for "no-activites" labels, refined text;
2021-03-02 15:36:23 -08:00
Jamie V
5d656f0963 [Abort Search] Ability to Cancel Search Requests (#3716)
* adding first triggers for aborting search

* adding abort capabilities to the path a search request takes through the code

* switching empty args from null to undefined

* adding abortSignal to couchdb provider request function

* minor syntax tweak

* fixing accidental change of code

* simplifying the assignment of fetch options

* add finally to search promises to delete abort controller just in case it is still there

* passing signal in to provider.get not getProvider

* moving the couchdb doc creation out of the argument for request

* removing console log for aborted search error

* lint fix

* adding interceptors to objects.search

* removing the options object and replacing with abort signal

* removing unused variable leftover

* had accidentally removed stringifying the body of the request if present... added back in

* created an applyGetInterceptors function for search and get to use

* created an applyGetInterceptors function for search and get to use

* fixed bug that our TESTS FOUND!!!!
2021-03-02 15:21:34 -08:00
Charles Hacskaylo
201d622b85 Style Timestrip and Plan views (#3715)
* Time Strip styling WIP

- Plan activity rects now don't use corner radius if smaller than the
radius, and use a minimum width of 1;
- Visual refinements to time axis and swimlanes;
- Refactored CSS-related class names and file `swim-lane` to `swimlane`;

* Compute row span dynamically

* Remove activities only in the current plan when refreshing

* Time Strip styling WIP

- Refinement and consolidation of CSS between c-plan and c-timeline;
- CSS cleanups;

* Time Strip styling WIP

- Added calculated activity text coloring based on background fill
color;

* Fix timestrip time bounds syncing

* Unlisten for bounds

* Update name of css file

* Adds test for time system axis in timestrip

Co-authored-by: Joshi <simplyrender@gmail.com>
2021-03-01 10:15:53 -08:00
Shefali Joshi
3571004f5c Use mutable object for plans (#3712)
* Use mutable object for plans so that they can sync even in time strip views
Allow the name of couch search folder to be configurable

* Move observing of couchdb changes to the _toMutable function

* Fix unit tests

Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
2021-02-25 15:14:31 -08:00
David Tsay
16249c3790 do not show sun into compass rose if sun heading is undefined (#3713) 2021-02-25 13:22:59 -08:00
Jamie V
5377f0d0b3 moved image info from telemetry datum to separate object with already formatted or calculated properties (#3703) 2021-02-24 19:16:19 -08:00
Shefali Joshi
15778b00a0 Handled chunked response from CouchDB for search results (#3701) 2021-02-24 15:59:41 -08:00
Shefali Joshi
169eec0a51 Time strip view to show different components within a given timeline (#3654)
* Initial commit of plot refactor for vuejs

* Use es6 classes instead of using extend

* Use classList api to add and remove classes

* Remove angular specific event mechanisms

* Refactor plot legend into smaller components

* Refactor moving config into MctPlot component. Fix Legend issues.

* Refactor XAxis and YAxis into their own components

* Remove commented out code

* Remove empty initialize method

* Fix grid lines and initialize function revert.

* Check that plots views are available only to domainObjects that have range and domain

* Make css class a computed property

* Remove unnecessary legacyObject conversion

* Remove comments and commented out code

* Remove use of private for vue methods

* Remove console logs

* Fixes Y-axis ticks display

* Add plots and plans to the time strip view

* Adds stacked plots and overlay plots

* Fix css for stacked plots

* Disable Vue plots

* Rename Stacked plot item component

* Make the time axis a component
Ensure plans and timelines use the time axis component and it is displayed correctly
ensure plots don't display specific controls when in compact mode

* Add missing file

* Revert change to state generator metadata

* Address Review comment: Remove unnecessary event emitted

* Address review comments: Add a note about why nextTick is needed

* Display time systems in time strip view
Update look and feel (css)

* Fix bug with legend when multiple plots are being displayed

* Don't show action buttons for stacked plots

* Changes to plan view to render as a css grid

* Change LinearScale to a class

* Remove duplicated comment

* Adds missing copyright info

* Revert change to stackedplotItem

* Styling for Timestrip view WIP

- Significant mods to markup and CSS to use CSS grid;
- CSS class names changed;

* Styling for Timestrip view WIP

- Temp mods to illustrate design desires for the appearance of the time
axis;

* Layout changes for plan in timestrip view

* Increase style height to match number of stacked plot items

* Fix ticks

* Fix removal of activities

* Remove event listeners on destroy

* Styling for Timestrip view WIP

- VERY WIP trying to make the plan component work properly when dropped
into a Timestrip view, lots of badness that needs to be fixed;
- Refined classes in acivity bars to differentiate between the rect and
its text;

* Show Vue plots only in timestrip view.
Reorder and Remove now works for timestrip objects

* Make swim lanes a component to be reused by time strip and plan views
Rewrite svg rendering to use javascript rather than d3.
Write a prototype of foreign object for svg to render text

* Don't show left and right edges when start or end is out of bounds

* Descriptive name for Plan views

* Adds plan icon and name

* Fixes linting issues

* Adds basic tests

* Fixes broken test.

* Adds new test

* Fix linting errors. Adds tests

* Adds tests

* Adds tests for stacked plots

* Adds more tests

* Removes fdescribe

* Adds tests for y-axis ticks

* Tests for addition of series to plots

* Adds more tests

* Adds cursor guides test

* Adds tests for interceptors

* Adds more plots tests for x and y scale

* Use config store

* Adding goToOriginalAction tests

* Fix tests for plan and time strip views

* Fixes height of SVG

* Fixes broken tests

* Address review comments: remove view options API change.

* Remove commented out code

* Fix tests

* Use the clientWidth of the plan if it's available

* Account for the width of labels in the client width

* Remove unnecessary test code

Co-authored-by: charlesh88 <charlesh88@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-02-24 09:40:57 -08:00
Deep Tailor
f789775b1c Fixes isMutable error when domainobject is undefined (#3690)
* fix isMutable error when domainobject is undefined

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-02-23 16:51:50 -08:00
Jamie V
fc59a4dce4 [Imagery Plugin] Enhancements - Compass, HUD, Freshness (#3675)
* Adds a compass rose component showing spacecraft and camera pointing direction in images, as well as sun location.
* Adds a "heads up display" component that shows heading at the top of images, as well as sun direction
* Adds freshness indicators for spacecraft and camera position

Co-authored-by: Jamie Vigliotta <jamie.j.vigliotta@nasa.gov>

Co-authored-by: David Tsay <david.e.tsay@nasa.gov>
Co-authored-by: charlesh88 <charlesh88@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: charlesh88 <charles.f.hacskaylo@nasa.gov>
Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
2021-02-23 11:46:09 -08:00
Shefali Joshi
29128a891d Couchdb object synchronization (#3674)
* Implements ObjectAPI changes to refresh objects when an update is received from the database.
* Populates a virtual folder of plans from CouchDB
* Fixes bug with supportsMutation API call parameters
2021-02-22 18:35:11 -08:00
Deep Tailor
dd3d4c8c3a remove localStorage from mct-tree (#3696)
* Removed local storage of navigated path in tree
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>
2021-02-22 10:33:09 -08:00
josephml-nasa
4047c888be Mct3528 (#3682)
* MCT-3528: Add Condition Manager changes

its been smoke tested

* Fix bounds getter and amend unit test

* Change fdescribe back to describe

* [Object API] add object provider search (#3610)

* add search method to object api
* use object api search
* do not index objects that have a provided search capability
* provide indexed search for objects without a search provider

* Upgrades to eslint-plugin-vue 7.5.0 (#3685)

* Preparing for sprint 1.6.2 (#3663)

* [NonEditable Folder Plugin] Default Install, Browse Bar Update, StyleGuide Use (#3676)

* default noneditable folder plugin, change styleguide folders to uneditable folder types, browse bar object name no longer input box if not createable

* moved plugin to mct.js instead of index.html

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>

* Fixes [Flexible Layout] bug with composition (#3680)

* fix delete and composition load
* remove unused remove action
* remove star listener and use computed property

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>

* Notebook saved link (#2998)

* https://github.com/nasa/openmct/issues/2859

* create and store link to default notebook in storage and pass it to notification.

* [Notebook] Add link to notebook inside 'Saved to Notebook' notification #2860

* Added custom autoDismissTimeout for into notifications.

* Backwards compatibility fix for old notebook model without link in metadata.

* lint fixes

* added JS Doc description for API changes + changed property names to appropriate function.

* fixed bug due to merging.

* fixed url update loop

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

Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>
Co-authored-by: Nikhil <nikhil.k.mandlik@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-02-22 09:54:01 -08:00
Shefali Joshi
1499286bee Plots refactor to use Vue and es6 instead of angular (#3586)
* Initial commit of plot refactor for vuejs

* Use es6 classes instead of using extend

* Use classList api to add and remove classes

* Remove angular specific event mechanisms

* Refactor plot legend into smaller components

* Refactor moving config into MctPlot component. Fix Legend issues.

* Refactor XAxis and YAxis into their own components

* Remove commented out code

* Remove empty initialize method

* Fix grid lines and initialize function revert.

* Check that plots views are available only to domainObjects that have range and domain

* Make css class a computed property

* Remove unnecessary legacyObject conversion

* Remove comments and commented out code

* Remove use of private for vue methods

* Remove console logs

* Fixes Y-axis ticks display

* Adds stacked plots and overlay plots

* Fix css for stacked plots

* Disable Vue plots

* Rename Stacked plot item component

* Address Review comment: Remove unnecessary event emitted

* Address review comments: Add a note about why nextTick is needed

* Fix bug with legend when multiple plots are being displayed

* Change LinearScale to a class

* Remove duplicated comment

* Adds missing copyright info

* Revert change to stackedplotItem

* Adds basic tests

* Fixes broken test.

* Adds new test

* Fix linting errors. Adds tests

* Adds tests

* Adds tests for stacked plots

* Adds more tests

* Removes fdescribe

* Adds tests for y-axis ticks

* Tests for addition of series to plots

* Adds more tests

* Adds cursor guides test

* Adds tests for interceptors

* Adds more plots tests for x and y scale

* Use config store

* Adding goToOriginalAction tests

* Modified tests to increase coverage, and added teardown for application router

* Fixed linting issues

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-02-19 06:48:17 -08:00
Jamie V
6226763c37 [Navigation Tree] Move "nav up" arrow down one item (#3581)
* moved nav up arrow down one tree item and updated icon
* cleaned up css to be more targeted for up arrow

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
Co-authored-by: charlesh88 <charlesh88@gmail.com>
2021-02-18 09:55:54 -08:00
Nikhil
7623a0648f [Notebook] Press Enter to save notebook entries (#3580)
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-02-18 09:31:45 -08:00
Nikhil
b7085f7f62 Notebook saved link (#2998)
* https://github.com/nasa/openmct/issues/2859

* create and store link to default notebook in storage and pass it to notification.

* [Notebook] Add link to notebook inside 'Saved to Notebook' notification #2860

* Added custom autoDismissTimeout for into notifications.

* Backwards compatibility fix for old notebook model without link in metadata.

* lint fixes

* added JS Doc description for API changes + changed property names to appropriate function.

* fixed bug due to merging.

* fixed url update loop

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-02-17 11:16:45 -08:00
Deep Tailor
55c851873c Fixes [Flexible Layout] bug with composition (#3680)
* fix delete and composition load
* remove unused remove action
* remove star listener and use computed property

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.com>
2021-02-16 14:05:39 -08:00
Jamie V
2b143dfc0f [NonEditable Folder Plugin] Default Install, Browse Bar Update, StyleGuide Use (#3676)
* default noneditable folder plugin, change styleguide folders to uneditable folder types, browse bar object name no longer input box if not createable

* moved plugin to mct.js instead of index.html

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-02-16 11:51:44 -08:00
Shefali Joshi
9405272f3b Preparing for sprint 1.6.2 (#3663) 2021-02-12 13:58:26 -08:00
Shefali Joshi
a9be9f1827 Upgrades to eslint-plugin-vue 7.5.0 (#3685) 2021-02-12 13:46:53 -08:00
David Tsay
abb1a5c75b [Object API] add object provider search (#3610)
* add search method to object api
* use object api search
* do not index objects that have a provided search capability
* provide indexed search for objects without a search provider
2021-02-12 12:48:34 -08:00
Deep Tailor
5e2fe7dc42 improve tab loading logic and fix delete tab issue (#3671) 2021-02-09 11:02:11 -08:00
Deep Tailor
e4d6e90c35 fix preview and navigate on click (#3668) 2021-02-04 17:36:30 -08:00
David Tsay
84d9a525a9 [Maintenance] remove dead code (#3640)
Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-02-03 21:18:11 -08:00
Jamie V
0aca0ce6a6 [Non-Editable Folder Plugin] Created new non-editable folder plugin (#3617)
* new non-edtiable folder plugin as well as updates for folder views to accept this type

* tests for new plugin

* remove fdescribe

* correcting a test expectation

* added a constnats file for folder view types

Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov>
2021-02-03 10:00:34 -08:00
194 changed files with 11499 additions and 1310 deletions

View File

@@ -54,7 +54,7 @@ module.exports = {
{
"anonymous": "always",
"asyncArrow": "always",
"named": "never",
"named": "never"
}
],
"array-bracket-spacing": "error",
@@ -178,7 +178,10 @@ module.exports = {
//https://eslint.org/docs/rules/no-whitespace-before-property
"no-whitespace-before-property": "error",
// https://eslint.org/docs/rules/object-curly-newline
"object-curly-newline": ["error", {"consistent": true, "multiline": true}],
"object-curly-newline": ["error", {
"consistent": true,
"multiline": true
}],
// https://eslint.org/docs/rules/object-property-newline
"object-property-newline": "error",
// https://eslint.org/docs/rules/brace-style
@@ -188,7 +191,7 @@ module.exports = {
// https://eslint.org/docs/rules/operator-linebreak
"operator-linebreak": ["error", "before", {"overrides": {"=": "after"}}],
// https://eslint.org/docs/rules/padding-line-between-statements
"padding-line-between-statements":["error", {
"padding-line-between-statements": ["error", {
"blankLine": "always",
"prev": "multiline-block-like",
"next": "*"
@@ -200,11 +203,17 @@ module.exports = {
// https://eslint.org/docs/rules/space-infix-ops
"space-infix-ops": "error",
// https://eslint.org/docs/rules/space-unary-ops
"space-unary-ops": ["error", {"words": true, "nonwords": false}],
"space-unary-ops": ["error", {
"words": true,
"nonwords": false
}],
// https://eslint.org/docs/rules/arrow-spacing
"arrow-spacing": "error",
// https://eslint.org/docs/rules/semi-spacing
"semi-spacing": ["error", {"before": false, "after": true}],
"semi-spacing": ["error", {
"before": false,
"after": true
}],
"vue/html-indent": [
"error",
@@ -237,6 +246,7 @@ module.exports = {
}],
"vue/multiline-html-element-content-newline": "off",
"vue/singleline-html-element-content-newline": "off",
"vue/no-mutating-props": "off"
},
"overrides": [

View File

@@ -138,7 +138,7 @@ define([
"id": "styleguide:home",
"priority": "preferred",
"model": {
"type": "folder",
"type": "noneditable.folder",
"name": "Style Guide Home",
"location": "ROOT",
"composition": [
@@ -155,7 +155,7 @@ define([
"id": "styleguide:ui-elements",
"priority": "preferred",
"model": {
"type": "folder",
"type": "noneditable.folder",
"name": "UI Elements",
"location": "styleguide:home",
"composition": [

View File

@@ -81,12 +81,15 @@
openmct.legacyRegistry.enable.bind(openmct.legacyRegistry)
);
openmct.install(openmct.plugins.LocalStorage());
// openmct.install(openmct.plugins.LocalStorage());
openmct.install(openmct.plugins.PouchDB());
openmct.install(openmct.plugins.Espresso());
openmct.install(openmct.plugins.MyItems());
openmct.install(openmct.plugins.Generator());
openmct.install(openmct.plugins.ExampleImagery());
openmct.install(openmct.plugins.PlanLayout());
openmct.install(openmct.plugins.Timeline());
openmct.install(openmct.plugins.PlotVue());
openmct.install(openmct.plugins.UTCTimeSystem());
openmct.install(openmct.plugins.AutoflowView({
type: "telemetry.panel"

View File

@@ -1,6 +1,6 @@
{
"name": "openmct",
"version": "1.6.1-SNAPSHOT",
"version": "1.6.3-SNAPSHOT",
"description": "The Open MCT core platform",
"dependencies": {},
"devDependencies": {
@@ -23,7 +23,7 @@
"d3-time": "1.0.x",
"d3-time-format": "2.1.x",
"eslint": "7.0.0",
"eslint-plugin-vue": "^6.0.0",
"eslint-plugin-vue": "^7.5.0",
"eslint-plugin-you-dont-need-lodash-underscore": "^6.10.0",
"eventemitter3": "^1.2.0",
"exports-loader": "^0.7.0",
@@ -62,6 +62,7 @@
"node-sass": "^4.14.1",
"painterro": "^1.0.35",
"printj": "^1.2.1",
"pouchdb": "^7.2.2",
"raw-loader": "^0.5.1",
"request": "^2.69.0",
"split": "^1.0.0",

View File

@@ -71,10 +71,10 @@ define(
openmct.editor.cancel();
}
function isFirstViewEditable(domainObject) {
let firstView = openmct.objectViews.get(domainObject)[0];
function isFirstViewEditable(domainObject, objectPath) {
let firstView = openmct.objectViews.get(domainObject, objectPath)[0];
return firstView && firstView.canEdit && firstView.canEdit(domainObject);
return firstView && firstView.canEdit && firstView.canEdit(domainObject, objectPath);
}
function navigateAndEdit(object) {
@@ -88,7 +88,7 @@ define(
window.location.href = url;
if (isFirstViewEditable(object.useCapability('adapter'))) {
if (isFirstViewEditable(object.useCapability('adapter'), objectPath)) {
openmct.editor.edit();
}
}

View File

@@ -37,7 +37,7 @@ define(
this.$q = $q;
}
LocatingObjectDecorator.prototype.getObjects = function (ids) {
LocatingObjectDecorator.prototype.getObjects = function (ids, abortSignal) {
var $q = this.$q,
$log = this.$log,
objectService = this.objectService,
@@ -79,7 +79,7 @@ define(
});
}
return objectService.getObjects([id]).then(attachContext);
return objectService.getObjects([id], abortSignal).then(attachContext);
}
ids.forEach(function (id) {

View File

@@ -146,10 +146,15 @@ define([
* @param {String} id to be indexed.
*/
GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
this.indexedIds[id] = true;
this.pendingIndex[id] = true;
this.idsToIndex.push(id);
const identifier = objectUtils.parseKeyString(id);
const objectProvider = this.openmct.objects.getProvider(identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
this.indexedIds[id] = true;
this.pendingIndex[id] = true;
this.idsToIndex.push(id);
}
}
this.keepIndexing();

View File

@@ -80,12 +80,15 @@ define([
* @param {Function} [filter] if provided, will be called for every
* potential modelResult. If it returns false, the model result will be
* excluded from the search results.
* @param {AbortController.signal} abortSignal (optional) can pass in an abortSignal to cancel any
* downstream fetch requests.
* @returns {Promise} A Promise for a search result object.
*/
SearchAggregator.prototype.query = function (
inputText,
maxResults,
filter
filter,
abortSignal
) {
var aggregator = this,
@@ -120,7 +123,7 @@ define([
modelResults = aggregator.applyFilter(modelResults, filter);
modelResults = aggregator.removeDuplicates(modelResults);
return aggregator.asObjectResults(modelResults);
return aggregator.asObjectResults(modelResults, abortSignal);
});
};
@@ -193,16 +196,19 @@ define([
* Convert modelResults to objectResults by fetching them from the object
* service.
*
* @param {Object} modelResults an object containing the results from the search
* @param {AbortController.signal} abortSignal (optional) abort signal to cancel any
* downstream fetch requests
* @returns {Promise} for an objectResults object.
*/
SearchAggregator.prototype.asObjectResults = function (modelResults) {
SearchAggregator.prototype.asObjectResults = function (modelResults, abortSignal) {
var objectIds = modelResults.hits.map(function (modelResult) {
return modelResult.id;
});
return this
.objectService
.getObjects(objectIds)
.getObjects(objectIds, abortSignal)
.then(function (objects) {
var objectResults = {

View File

@@ -219,7 +219,7 @@ define([
* @memberof module:openmct.MCT#
* @name objects
*/
this.objects = new api.ObjectAPI.default(this.types);
this.objects = new api.ObjectAPI.default(this.types, this);
/**
* An interface for retrieving and interpreting telemetry data associated
@@ -283,6 +283,7 @@ define([
this.install(this.plugins.NewFolderAction());
this.install(this.plugins.ViewDatumAction());
this.install(this.plugins.ObjectInterceptors());
this.install(this.plugins.NonEditableFolder());
}
MCT.prototype = Object.create(EventEmitter.prototype);

View File

@@ -37,7 +37,7 @@ define([
context.domainObject.getModel(),
objectUtils.parseKeyString(context.domainObject.getId())
);
const providers = mct.propertyEditors.get(domainObject);
const providers = mct.propertyEditors.get(domainObject, mct.router.path);
if (providers.length > 0) {
action.dialogService = Object.create(action.dialogService);

View File

@@ -32,7 +32,7 @@ define([], function () {
if (Object.prototype.hasOwnProperty.call(view, 'provider')) {
const domainObject = legacyObject.useCapability('adapter');
return view.provider.canView(domainObject);
return view.provider.canView(domainObject, this.openmct.router.path);
}
return true;

View File

@@ -139,18 +139,26 @@ define([
});
};
ObjectServiceProvider.prototype.superSecretFallbackSearch = function (query, abortSignal) {
const searchService = this.$injector.get('searchService');
// need to pass the abortSignal down, so need to
// pass in undefined for maxResults and filter on query
return searchService.query(query, undefined, undefined, abortSignal);
};
// Injects new object API as a decorator so that it hijacks all requests.
// Object providers implemented on new API should just work, old API should just work, many things may break.
function LegacyObjectAPIInterceptor(openmct, ROOTS, instantiate, topic, objectService) {
const eventEmitter = openmct.objects.eventEmitter;
this.getObjects = function (keys) {
this.getObjects = function (keys, abortSignal) {
const results = {};
const promises = keys.map(function (keyString) {
const key = utils.parseKeyString(keyString);
return openmct.objects.get(key)
return openmct.objects.get(key, abortSignal)
.then(function (object) {
object = utils.toOldFormat(object);
results[keyString] = instantiate(object, keyString);

View File

@@ -29,9 +29,22 @@ describe('The ActionCollection', () => {
let mockApplicableActions;
let mockObjectPath;
let mockView;
let mockIdentifierService;
beforeEach(() => {
openmct = createOpenMct();
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
['parse']
);
mockIdentifierService.parse.and.returnValue({
getSpace: () => {
return '';
}
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
mockObjectPath = [
{
name: 'mock folder',

View File

@@ -196,7 +196,7 @@ define([
this.provider.add(this.domainObject, child.identifier);
} else {
if (this.returnMutables && this.publicAPI.objects.supportsMutation(child)) {
if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) {
let keyString = this.publicAPI.objects.makeKeyString(child.identifier);
child = this.publicAPI.objects._toMutable(child);

View File

@@ -30,12 +30,12 @@ class Menu extends EventEmitter {
this.options = options;
this.component = new Vue({
provide: {
actions: options.actions
},
components: {
MenuComponent
},
provide: {
actions: options.actions
},
template: '<menu-component />'
});

View File

@@ -75,13 +75,20 @@ export default class NotificationAPI extends EventEmitter {
* Info notifications are low priority informational messages for the user. They will be auto-destroy after a brief
* period of time.
* @param {string} message The message to display to the user
* @param {Object} [options] object with following properties
* autoDismissTimeout: {number} in miliseconds to automatically dismisses notification
* link: {Object} Add a link to notifications for navigation
* onClick: callback function
* cssClass: css class name to add style on link
* text: text to display for link
* @returns {InfoNotification}
*/
info(message) {
info(message, options = {}) {
let notificationModel = {
message: message,
autoDismiss: true,
severity: "info"
severity: "info",
options
};
return this._notify(notificationModel);
@@ -90,12 +97,19 @@ export default class NotificationAPI extends EventEmitter {
/**
* Present an alert to the user.
* @param {string} message The message to display to the user.
* @param {Object} [options] object with following properties
* autoDismissTimeout: {number} in miliseconds to automatically dismisses notification
* link: {Object} Add a link to notifications for navigation
* onClick: callback function
* cssClass: css class name to add style on link
* text: text to display for link
* @returns {Notification}
*/
alert(message) {
alert(message, options = {}) {
let notificationModel = {
message: message,
severity: "alert"
severity: "alert",
options
};
return this._notify(notificationModel);
@@ -104,12 +118,19 @@ export default class NotificationAPI extends EventEmitter {
/**
* Present an error message to the user
* @param {string} message
* @param {Object} [options] object with following properties
* autoDismissTimeout: {number} in miliseconds to automatically dismisses notification
* link: {Object} Add a link to notifications for navigation
* onClick: callback function
* cssClass: css class name to add style on link
* text: text to display for link
* @returns {Notification}
*/
error(message) {
error(message, options = {}) {
let notificationModel = {
message: message,
severity: "error"
severity: "error",
options
};
return this._notify(notificationModel);
@@ -325,9 +346,11 @@ export default class NotificationAPI extends EventEmitter {
this.emit('notification', notification);
if (notification.model.autoDismiss || this._selectNextNotification()) {
const autoDismissTimeout = notification.model.options.autoDismissTimeout
|| DEFAULT_AUTO_DISMISS_TIMEOUT;
this.activeTimeout = setTimeout(() => {
this._dismissOrMinimize(notification);
}, DEFAULT_AUTO_DISMISS_TIMEOUT);
}, autoDismissTimeout);
} else {
delete this.activeTimeout;
}

View File

@@ -96,6 +96,16 @@ class MutableDomainObject {
//TODO: Emit events for listeners of child properties when parent changes.
// Do it at observer time - also register observers for parent attribute path.
}
$refresh(model) {
//TODO: Currently we are updating the entire object.
// In the future we could update a specific property of the object using the 'path' parameter.
this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), model);
//Emit wildcard event, with path so that callback knows what changed
this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this);
}
$on(event, callback) {
this._instanceEventEmitter.on(event, callback);

View File

@@ -33,11 +33,15 @@ import InterceptorRegistry from './InterceptorRegistry';
* @memberof module:openmct
*/
function ObjectAPI(typeRegistry) {
function ObjectAPI(typeRegistry, openmct) {
this.typeRegistry = typeRegistry;
this.eventEmitter = new EventEmitter();
this.providers = {};
this.rootRegistry = new RootRegistry();
this.injectIdentifierService = function () {
this.identifierService = openmct.$injector.get("identifierService");
};
this.rootProvider = new RootObjectProvider(this.rootRegistry);
this.cache = {};
this.interceptorRegistry = new InterceptorRegistry();
@@ -51,16 +55,33 @@ ObjectAPI.prototype.supersecretSetFallbackProvider = function (p) {
this.fallbackProvider = p;
};
/**
* @private
*/
ObjectAPI.prototype.getIdentifierService = function () {
// Lazily acquire identifier service
if (!this.identifierService) {
this.injectIdentifierService();
}
return this.identifierService;
};
/**
* Retrieve the provider for a given identifier.
* @private
*/
ObjectAPI.prototype.getProvider = function (identifier) {
//handles the '' vs 'mct' namespace issue
const keyString = utils.makeKeyString(identifier);
const identifierService = this.getIdentifierService();
const namespace = identifierService.parse(keyString).getSpace();
if (identifier.key === 'ROOT') {
return this.rootProvider;
}
return this.providers[identifier.namespace] || this.fallbackProvider;
return this.providers[namespace] || this.fallbackProvider;
};
/**
@@ -133,11 +154,12 @@ ObjectAPI.prototype.addProvider = function (namespace, provider) {
* @method get
* @memberof module:openmct.ObjectProvider#
* @param {string} key the key for the domain object to load
* @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests
* @returns {Promise} a promise which will resolve when the domain object
* has been saved, or be rejected if it cannot be saved
*/
ObjectAPI.prototype.get = function (identifier) {
ObjectAPI.prototype.get = function (identifier, abortSignal) {
let keystring = this.makeKeyString(identifier);
if (this.cache[keystring] !== undefined) {
return this.cache[keystring];
@@ -154,20 +176,50 @@ ObjectAPI.prototype.get = function (identifier) {
throw new Error('Provider does not support get!');
}
let objectPromise = provider.get(identifier);
let objectPromise = provider.get(identifier, abortSignal);
this.cache[keystring] = objectPromise;
return objectPromise.then(result => {
delete this.cache[keystring];
const interceptors = this.listGetInterceptors(identifier, result);
interceptors.forEach(interceptor => {
result = interceptor.invoke(identifier, result);
});
result = this.applyGetInterceptors(identifier, result);
return result;
});
};
/**
* Search for domain objects.
*
* Object providersSearches and combines results of each object provider search.
* Objects without search provided will have been indexed
* and will be searched using the fallback indexed search.
* Search results are asynchronous and resolve in parallel.
*
* @method search
* @memberof module:openmct.ObjectAPI#
* @param {string} query the term to search for
* @param {AbortController.signal} abortSignal (optional) signal to cancel downstream fetch requests
* @returns {Array.<Promise.<module:openmct.DomainObject>>}
* an array of promises returned from each object provider's search function
* each resolving to domain objects matching provided search query and options.
*/
ObjectAPI.prototype.search = function (query, abortSignal) {
const searchPromises = Object.values(this.providers)
.filter(provider => provider.search !== undefined)
.map(provider => provider.search(query, abortSignal));
searchPromises.push(this.fallbackProvider.superSecretFallbackSearch(query, abortSignal)
.then(results => results.hits
.map(hit => {
let domainObject = utils.toNewFormat(hit.object.getModel(), hit.object.getId());
domainObject = this.applyGetInterceptors(domainObject.identifier, domainObject);
return domainObject;
})));
return searchPromises;
};
/**
* Will fetch object for the given identifier, returning a version of the object that will automatically keep
* itself updated as it is mutated. Before using this function, you should ask yourself whether you really need it.
@@ -289,6 +341,19 @@ ObjectAPI.prototype.listGetInterceptors = function (identifier, object) {
return this.interceptorRegistry.getInterceptors(identifier, object);
};
/**
* Inovke interceptors if applicable for a given domain object.
* @private
*/
ObjectAPI.prototype.applyGetInterceptors = function (identifier, domainObject) {
const interceptors = this.listGetInterceptors(identifier, domainObject);
interceptors.forEach(interceptor => {
domainObject = interceptor.invoke(identifier, domainObject);
});
return domainObject;
};
/**
* Modify a domain object.
* @param {module:openmct.DomainObject} object the object to mutate
@@ -324,11 +389,29 @@ ObjectAPI.prototype.mutate = function (domainObject, path, value) {
* @private
*/
ObjectAPI.prototype._toMutable = function (object) {
let mutableObject;
if (object.isMutable) {
return object;
mutableObject = object;
} else {
return MutableDomainObject.createMutable(object, this.eventEmitter);
mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter);
}
// Check if provider supports realtime updates
let identifier = utils.parseKeyString(mutableObject.identifier);
let provider = this.getProvider(identifier);
if (provider !== undefined
&& provider.observe !== undefined) {
let unobserve = provider.observe(identifier, (updatedModel) => {
mutableObject.$refresh(updatedModel);
});
mutableObject.$on('$destroy', () => {
unobserve();
});
}
return mutableObject;
};
/**

View File

@@ -0,0 +1,119 @@
import ObjectAPI from './ObjectAPI.js';
describe("The Object API Search Function", () => {
const MOCK_PROVIDER_KEY = 'mockProvider';
const ANOTHER_MOCK_PROVIDER_KEY = 'anotherMockProvider';
const MOCK_PROVIDER_SEARCH_DELAY = 15000;
const ANOTHER_MOCK_PROVIDER_SEARCH_DELAY = 20000;
const TOTAL_TIME_ELAPSED = 21000;
const BASE_TIME = new Date(2021, 0, 1);
let objectAPI;
let mockObjectProvider;
let anotherMockObjectProvider;
let mockFallbackProvider;
let fallbackProviderSearchResults;
let resultsPromises;
beforeEach(() => {
jasmine.clock().install();
jasmine.clock().mockDate(BASE_TIME);
resultsPromises = [];
fallbackProviderSearchResults = {
hits: []
};
objectAPI = new ObjectAPI();
mockObjectProvider = jasmine.createSpyObj("mock object provider", [
"search"
]);
anotherMockObjectProvider = jasmine.createSpyObj("another mock object provider", [
"search"
]);
mockFallbackProvider = jasmine.createSpyObj("super secret fallback provider", [
"superSecretFallbackSearch"
]);
objectAPI.addProvider('objects', mockObjectProvider);
objectAPI.addProvider('other-objects', anotherMockObjectProvider);
objectAPI.supersecretSetFallbackProvider(mockFallbackProvider);
mockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const mockProviderSearch = {
name: MOCK_PROVIDER_KEY,
start: new Date()
};
setTimeout(() => {
mockProviderSearch.end = new Date();
return resolve(mockProviderSearch);
}, MOCK_PROVIDER_SEARCH_DELAY);
});
});
anotherMockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const anotherMockProviderSearch = {
name: ANOTHER_MOCK_PROVIDER_KEY,
start: new Date()
};
setTimeout(() => {
anotherMockProviderSearch.end = new Date();
return resolve(anotherMockProviderSearch);
}, ANOTHER_MOCK_PROVIDER_SEARCH_DELAY);
});
});
mockFallbackProvider.superSecretFallbackSearch.and.callFake(
() => new Promise(
resolve => setTimeout(
() => resolve(fallbackProviderSearchResults),
50
)
)
);
resultsPromises = objectAPI.search('foo');
jasmine.clock().tick(TOTAL_TIME_ELAPSED);
});
afterEach(() => {
jasmine.clock().uninstall();
});
it("uses each objects given provider's search function", () => {
expect(mockObjectProvider.search).toHaveBeenCalled();
expect(anotherMockObjectProvider.search).toHaveBeenCalled();
});
it("uses the fallback indexed search for objects without a search function provided", () => {
expect(mockFallbackProvider.superSecretFallbackSearch).toHaveBeenCalled();
});
it("provides each providers results as promises that resolve in parallel", async () => {
const results = await Promise.all(resultsPromises);
const mockProviderResults = results.find(
result => result.name === MOCK_PROVIDER_KEY
);
const anotherMockProviderResults = results.find(
result => result.name === ANOTHER_MOCK_PROVIDER_KEY
);
const mockProviderStart = mockProviderResults.start.getTime();
const mockProviderEnd = mockProviderResults.end.getTime();
const anotherMockProviderStart = anotherMockProviderResults.start.getTime();
const anotherMockProviderEnd = anotherMockProviderResults.end.getTime();
const searchElapsedTime = Math.max(mockProviderEnd, anotherMockProviderEnd)
- Math.min(mockProviderEnd, anotherMockProviderEnd);
expect(mockProviderStart).toBeLessThan(anotherMockProviderEnd);
expect(anotherMockProviderStart).toBeLessThan(mockProviderEnd);
expect(searchElapsedTime).toBeLessThan(
MOCK_PROVIDER_SEARCH_DELAY
+ ANOTHER_MOCK_PROVIDER_SEARCH_DELAY
);
});
});

View File

@@ -3,6 +3,8 @@ import ObjectAPI from './ObjectAPI.js';
describe("The Object API", () => {
let objectAPI;
let typeRegistry;
let openmct = {};
let mockIdentifierService;
let mockDomainObject;
const TEST_NAMESPACE = "test-namespace";
const FIFTEEN_MINUTES = 15 * 60 * 1000;
@@ -11,7 +13,19 @@ describe("The Object API", () => {
typeRegistry = jasmine.createSpyObj('typeRegistry', [
'get'
]);
objectAPI = new ObjectAPI(typeRegistry);
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
['parse']
);
mockIdentifierService.parse.and.returnValue({
getSpace: () => {
return TEST_NAMESPACE;
}
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
objectAPI = new ObjectAPI(typeRegistry, openmct);
mockDomainObject = {
identifier: {
namespace: TEST_NAMESPACE,
@@ -136,11 +150,13 @@ describe("The Object API", () => {
describe("the mutation API", () => {
let testObject;
let updatedTestObject;
let mutable;
let mockProvider;
let callbacks = [];
beforeEach(function () {
objectAPI = new ObjectAPI(typeRegistry);
objectAPI = new ObjectAPI(typeRegistry, openmct);
testObject = {
identifier: {
namespace: TEST_NAMESPACE,
@@ -154,12 +170,27 @@ describe("The Object API", () => {
}
}
};
updatedTestObject = Object.assign({otherAttribute: 'changed-attribute-value'}, testObject);
mockProvider = jasmine.createSpyObj("mock provider", [
"get",
"create",
"update"
"update",
"observe",
"observeObjectChanges"
]);
mockProvider.get.and.returnValue(Promise.resolve(testObject));
mockProvider.observeObjectChanges.and.callFake(() => {
callbacks[0](updatedTestObject);
callbacks.splice(0, 1);
});
mockProvider.observe.and.callFake((id, callback) => {
if (callbacks.length === 0) {
callbacks.push(callback);
} else {
callbacks[0] = callback;
}
});
objectAPI.addProvider(TEST_NAMESPACE, mockProvider);
return objectAPI.getMutable(testObject.identifier)
@@ -191,6 +222,13 @@ describe("The Object API", () => {
it('that is identical to original object when serialized', function () {
expect(JSON.stringify(mutable)).toEqual(JSON.stringify(testObject));
});
it('that observes for object changes', function () {
let mockListener = jasmine.createSpy('mockListener');
objectAPI.observe(testObject, '*', mockListener);
mockProvider.observeObjectChanges();
expect(mockListener).toHaveBeenCalled();
});
});
describe('uses events', function () {

View File

@@ -48,7 +48,7 @@ define([
this.providers.push(function () {
return key;
});
} else if (_.isFunction(key)) {
} else if (typeof key === "function") {
this.providers.push(key);
}
};

View File

@@ -6,6 +6,9 @@ class Dialog extends Overlay {
constructor({iconClass, message, title, hint, timestamp, ...options}) {
let component = new Vue({
components: {
DialogComponent: DialogComponent
},
provide: {
iconClass,
message,
@@ -13,9 +16,6 @@ class Dialog extends Overlay {
hint,
timestamp
},
components: {
DialogComponent: DialogComponent
},
template: '<dialog-component></dialog-component>'
}).$mount();

View File

@@ -7,6 +7,9 @@ let component;
class ProgressDialog extends Overlay {
constructor({progressPerc, progressText, iconClass, message, title, hint, timestamp, ...options}) {
component = new Vue({
components: {
ProgressDialogComponent: ProgressDialogComponent
},
provide: {
iconClass,
message,
@@ -14,9 +17,6 @@ class ProgressDialog extends Overlay {
hint,
timestamp
},
components: {
ProgressDialogComponent: ProgressDialogComponent
},
data() {
return {
model: {

View File

@@ -38,12 +38,12 @@
<script>
export default {
inject: ['dismiss', 'element', 'buttons', 'dismissable'],
data: function () {
return {
focusIndex: -1
};
},
inject: ['dismiss', 'element', 'buttons', 'dismissable'],
mounted() {
const element = this.$refs.element;
element.appendChild(this.element);

View File

@@ -0,0 +1,37 @@
export default function (folderName, couchPlugin, searchFilter) {
return function install(openmct) {
const couchProvider = couchPlugin.couchProvider;
openmct.objects.addRoot({
namespace: 'couch-search',
key: 'couch-search'
});
openmct.objects.addProvider('couch-search', {
get(identifier) {
if (identifier.key !== 'couch-search') {
return undefined;
} else {
return Promise.resolve({
identifier,
type: 'folder',
name: folderName || "CouchDB Documents"
});
}
}
});
openmct.composition.addProvider({
appliesTo(domainObject) {
return domainObject.identifier.namespace === 'couch-search'
&& domainObject.identifier.key === 'couch-search';
},
load() {
return couchProvider.getObjectsByFilter(searchFilter).then(objects => {
return objects.map(object => object.identifier);
});
}
});
};
}

View File

@@ -0,0 +1,91 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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";
import CouchDBSearchFolderPlugin from './plugin';
describe('the plugin', function () {
let identifier = {
namespace: 'couch-search',
key: "couch-search"
};
let testPath = '/test/db';
let openmct;
let composition;
beforeEach((done) => {
openmct = createOpenMct();
let couchPlugin = openmct.plugins.CouchDB(testPath);
openmct.install(couchPlugin);
openmct.install(new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, {
"selector": {
"model": {
"type": "plan"
}
}
}));
openmct.on('start', done);
openmct.startHeadless();
composition = openmct.composition.get({identifier});
spyOn(couchPlugin.couchProvider, 'getObjectsByFilter').and.returnValue(Promise.resolve([
{
identifier: {
key: "1",
namespace: "mct"
}
},
{
identifier: {
key: "2",
namespace: "mct"
}
}
]));
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('provides a folder to hold plans', () => {
openmct.objects.get(identifier).then((object) => {
expect(object).toEqual({
identifier,
type: 'folder',
name: "CouchDB Documents"
});
});
});
it('provides composition for couch search folders', () => {
composition.load().then((objects) => {
expect(objects.length).toEqual(2);
});
});
});

View File

@@ -43,15 +43,15 @@ export default function LADTableViewProvider(openmct) {
components: {
LadTableComponent: LadTable
},
provide: {
openmct
},
data: () => {
return {
domainObject,
objectPath
};
},
provide: {
openmct
},
template: '<lad-table-component :domain-object="domainObject" :object-path="objectPath"></lad-table-component>'
});
},

View File

@@ -48,10 +48,10 @@
import LadRow from './LADRow.vue';
export default {
inject: ['openmct'],
components: {
LadRow
},
inject: ['openmct'],
props: {
domainObject: {
type: Object,

View File

@@ -57,10 +57,10 @@
import LadRow from './LADRow.vue';
export default {
inject: ['openmct', 'domainObject'],
components: {
LadRow
},
inject: ['openmct', 'domainObject'],
data() {
return {
ladTableObjects: [],

View File

@@ -98,7 +98,7 @@ describe("The LAD Table", () => {
});
it("should provide a table view only for lad table objects", () => {
let applicableViews = openmct.objectViews.get(mockObj.ladTable);
let applicableViews = openmct.objectViews.get(mockObj.ladTable, []);
let ladTableView = applicableViews.find(
(viewProvider) => viewProvider.key === ladTableKey
@@ -185,7 +185,7 @@ describe("The LAD Table", () => {
end: bounds.end
});
applicableViews = openmct.objectViews.get(mockObj.ladTable);
applicableViews = openmct.objectViews.get(mockObj.ladTable, []);
ladTableViewProvider = applicableViews.find((viewProvider) => viewProvider.key === ladTableKey);
ladTableView = ladTableViewProvider.view(mockObj.ladTable, [mockObj.ladTable]);
ladTableView.show(child, true);
@@ -296,7 +296,7 @@ describe("The LAD Table Set", () => {
});
it("should provide a lad table set view only for lad table set objects", () => {
let applicableViews = openmct.objectViews.get(mockObj.ladTableSet);
let applicableViews = openmct.objectViews.get(mockObj.ladTableSet, []);
let ladTableSetView = applicableViews.find(
(viewProvider) => viewProvider.key === ladTableSetKey
@@ -391,7 +391,7 @@ describe("The LAD Table Set", () => {
end: bounds.end
});
applicableViews = openmct.objectViews.get(mockObj.ladTableSet);
applicableViews = openmct.objectViews.get(mockObj.ladTableSet, []);
ladTableSetViewProvider = applicableViews.find((viewProvider) => viewProvider.key === ladTableSetKey);
ladTableSetView = ladTableSetViewProvider.view(mockObj.ladTableSet, [mockObj.ladTableSet]);
ladTableSetView.show(child, true);

View File

@@ -67,11 +67,11 @@ describe("AutoflowTabularPlugin", () => {
});
it("applies its view to the type from options", () => {
expect(provider.canView(testObject)).toBe(true);
expect(provider.canView(testObject, [])).toBe(true);
});
it("does not apply to other types", () => {
expect(provider.canView({ type: 'foo' })).toBe(false);
expect(provider.canView({ type: 'foo' }, [])).toBe(false);
});
describe("provides a view which", () => {

View File

@@ -37,12 +37,12 @@ define([
return function install(openmct) {
if (installIndicator) {
let component = new Vue ({
provide: {
openmct
},
components: {
GlobalClearIndicator: GlobaClearIndicator.default
},
provide: {
openmct
},
template: '<GlobalClearIndicator></GlobalClearIndicator>'
});

View File

@@ -34,6 +34,9 @@ export default class ConditionManager extends EventEmitter {
this.composition = this.openmct.composition.get(conditionSetDomainObject);
this.composition.on('add', this.subscribeToTelemetry, this);
this.composition.on('remove', this.unsubscribeFromTelemetry, this);
this.shouldEvaluateNewTelemetry = this.shouldEvaluateNewTelemetry.bind(this);
this.compositionLoad = this.composition.load();
this.subscriptions = {};
this.telemetryObjects = {};
@@ -337,6 +340,10 @@ export default class ConditionManager extends EventEmitter {
return false;
}
shouldEvaluateNewTelemetry(currentTimestamp) {
return this.openmct.time.bounds().end >= currentTimestamp;
}
telemetryReceived(endpoint, datum) {
if (!this.isTelemetryUsed(endpoint)) {
return;
@@ -345,10 +352,12 @@ export default class ConditionManager extends EventEmitter {
const normalizedDatum = this.createNormalizedDatum(datum, endpoint);
const timeSystemKey = this.openmct.time.timeSystem().key;
let timestamp = {};
timestamp[timeSystemKey] = normalizedDatum[timeSystemKey];
this.updateConditionResults(normalizedDatum);
this.updateCurrentCondition(timestamp);
const currentTimestamp = normalizedDatum[timeSystemKey];
timestamp[timeSystemKey] = currentTimestamp;
if (this.shouldEvaluateNewTelemetry(currentTimestamp)) {
this.updateConditionResults(normalizedDatum);
this.updateCurrentCondition(timestamp);
}
}
updateConditionResults(normalizedDatum) {

View File

@@ -195,11 +195,11 @@ import { TRIGGER, TRIGGER_LABEL } from "@/plugins/condition/utils/constants";
import uuid from 'uuid';
export default {
inject: ['openmct'],
components: {
Criterion,
ConditionDescription
},
inject: ['openmct'],
props: {
currentConditionId: {
type: String,

View File

@@ -81,10 +81,10 @@ import Condition from './Condition.vue';
import ConditionManager from '../ConditionManager';
export default {
inject: ['openmct', 'domainObject'],
components: {
Condition
},
inject: ['openmct', 'domainObject'],
props: {
isEditing: Boolean,
testData: {

View File

@@ -58,11 +58,11 @@ import TestData from './TestData.vue';
import ConditionCollection from './ConditionCollection.vue';
export default {
inject: ["openmct", "domainObject"],
components: {
TestData,
ConditionCollection
},
inject: ["openmct", "domainObject"],
props: {
isEditing: Boolean
},

View File

@@ -41,7 +41,7 @@
</div>
</div>
<ul
v-if="expanded"
v-if="expanded && !isLoading"
class="c-tree"
>
<li
@@ -68,10 +68,10 @@ import viewControl from '@/ui/components/viewControl.vue';
export default {
name: 'ConditionSetDialogTreeItem',
inject: ['openmct'],
components: {
viewControl
},
inject: ['openmct'],
props: {
node: {
type: Object,

View File

@@ -41,7 +41,7 @@
></div>
<!-- end loading -->
<div v-if="(allTreeItems.length === 0) || (searchValue && filteredTreeItems.length === 0)"
<div v-if="shouldDisplayNoResultsText"
class="c-tree-and-search__no-results"
>
No results found
@@ -63,7 +63,7 @@
<!-- end main tree -->
<!-- search tree -->
<ul v-if="searchValue"
<ul v-if="searchValue && !isLoading"
class="c-tree-and-search__tree c-tree"
>
<condition-set-dialog-tree-item
@@ -80,16 +80,17 @@
</template>
<script>
import debounce from 'lodash/debounce';
import search from '@/ui/components/search.vue';
import ConditionSetDialogTreeItem from './ConditionSetDialogTreeItem.vue';
export default {
inject: ['openmct'],
name: 'ConditionSetSelectorDialog',
components: {
search,
ConditionSetDialogTreeItem
},
inject: ['openmct'],
data() {
return {
expanded: false,
@@ -100,8 +101,20 @@ export default {
selectedItem: undefined
};
},
computed: {
shouldDisplayNoResultsText() {
if (this.isLoading) {
return false;
}
return this.allTreeItems.length === 0
|| (this.searchValue && this.filteredTreeItems.length === 0);
}
},
created() {
this.getDebouncedFilteredChildren = debounce(this.getFilteredChildren, 400);
},
mounted() {
this.searchService = this.openmct.$injector.get('searchService');
this.getAllChildren();
},
methods: {
@@ -124,37 +137,44 @@ export default {
});
},
getFilteredChildren() {
this.searchService.query(this.searchValue).then(children => {
this.filteredTreeItems = children.hits.map(child => {
// clear any previous search results
this.filteredTreeItems = [];
let context = child.object.getCapability('context');
let object = child.object.useCapability('adapter');
let objectPath = [];
let navigateToParent;
const promises = this.openmct.objects.search(this.searchValue)
.map(promise => promise
.then(results => this.aggregateFilteredChildren(results)));
if (context) {
objectPath = context.getPath().slice(1)
.map(oldObject => oldObject.useCapability('adapter'))
.reverse();
navigateToParent = '/browse/' + objectPath.slice(1)
.map((parent) => this.openmct.objects.makeKeyString(parent.identifier))
.join('/');
}
return {
id: this.openmct.objects.makeKeyString(object.identifier),
object,
objectPath,
navigateToParent
};
});
Promise.all(promises).then(() => {
this.isLoading = false;
});
},
async aggregateFilteredChildren(results) {
for (const object of results) {
const objectPath = await this.openmct.objects.getOriginalPath(object.identifier);
const navigateToParent = '/browse/'
+ objectPath.slice(1)
.map(parent => this.openmct.objects.makeKeyString(parent.identifier))
.join('/');
const filteredChild = {
id: this.openmct.objects.makeKeyString(object.identifier),
object,
objectPath,
navigateToParent
};
this.filteredTreeItems.push(filteredChild);
}
},
searchTree(value) {
this.searchValue = value;
this.isLoading = true;
if (this.searchValue !== '') {
this.getFilteredChildren();
this.getDebouncedFilteredChildren();
} else {
this.isLoading = false;
}
},
handleItemSelection(item, node) {

View File

@@ -136,7 +136,7 @@ describe('the plugin', function () {
}
};
const applicableViews = openmct.objectViews.get(testViewObject);
const applicableViews = openmct.objectViews.get(testViewObject, []);
let conditionSetView = applicableViews.find((viewProvider) => viewProvider.key === 'conditionSet.view');
expect(conditionSetView).toBeDefined();
});
@@ -401,15 +401,15 @@ describe('the plugin', function () {
let viewContainer = document.createElement('div');
child.append(viewContainer);
component = new Vue({
el: viewContainer,
components: {
StylesView
},
provide: {
openmct: openmct,
selection: selection,
stylesManager
},
el: viewContainer,
components: {
StylesView
},
template: '<styles-view/>'
});
@@ -543,7 +543,6 @@ describe('the plugin', function () {
});
it('should evaluate as stale when telemetry is not received in the allotted time', (done) => {
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
conditionMgr.on('conditionSetResultUpdated', mockListener);
conditionMgr.telemetryObjects = {
@@ -565,7 +564,7 @@ describe('the plugin', function () {
});
it('should not evaluate as stale when telemetry is received in the allotted time', (done) => {
const date = Date.now();
const date = 1;
conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = ["0.4"];
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
conditionMgr.on('conditionSetResultUpdated', mockListener);

View File

@@ -56,14 +56,14 @@ define([
return {
show: function (element) {
component = new Vue({
provide: {
openmct,
objectPath
},
el: element,
components: {
AlphanumericFormatView: AlphanumericFormatView.default
},
provide: {
openmct,
objectPath
},
template: '<alphanumeric-format-view ref="alphanumericFormatView"></alphanumeric-format-view>'
});
},

View File

@@ -51,11 +51,11 @@ export default {
height: 5
};
},
inject: ['openmct'],
components: {
LayoutFrame
},
mixins: [conditionalStylesMixin],
inject: ['openmct'],
props: {
item: {
type: Object,

View File

@@ -51,11 +51,11 @@ export default {
url: element.url
};
},
inject: ['openmct'],
components: {
LayoutFrame
},
mixins: [conditionalStylesMixin],
inject: ['openmct'],
props: {
item: {
type: Object,

View File

@@ -99,8 +99,8 @@ export default {
stroke: '#717171'
};
},
inject: ['openmct'],
mixins: [conditionalStylesMixin],
inject: ['openmct'],
props: {
item: {
type: Object,

View File

@@ -80,11 +80,11 @@ export default {
viewKey
};
},
inject: ['openmct', 'objectPath'],
components: {
ObjectFrame,
LayoutFrame
},
inject: ['openmct', 'objectPath'],
props: {
item: {
type: Object,
@@ -109,7 +109,8 @@ export default {
data() {
return {
domainObject: undefined,
currentObjectPath: []
currentObjectPath: [],
mutablePromise: undefined
};
},
watch: {
@@ -130,7 +131,7 @@ export default {
},
mounted() {
if (this.openmct.objects.supportsMutation(this.item.identifier)) {
this.openmct.objects.getMutable(this.item.identifier)
this.mutablePromise = this.openmct.objects.getMutable(this.item.identifier)
.then(this.setObject);
} else {
this.openmct.objects.get(this.item.identifier)
@@ -142,13 +143,18 @@ export default {
this.removeSelectable();
}
if (this.domainObject.isMutable) {
if (this.mutablePromise) {
this.mutablePromise.then(() => {
this.openmct.objects.destroyMutable(this.domainObject);
});
} else {
this.openmct.objects.destroyMutable(this.domainObject);
}
},
methods: {
setObject(domainObject) {
this.domainObject = domainObject;
this.mutablePromise = undefined;
this.currentObjectPath = [this.domainObject].concat(this.objectPath.slice());
this.$nextTick(() => {
let reference = this.$refs.objectFrame;

View File

@@ -98,11 +98,11 @@ export default {
font: 'default'
};
},
inject: ['openmct', 'objectPath'],
components: {
LayoutFrame
},
mixins: [conditionalStylesMixin],
inject: ['openmct', 'objectPath'],
props: {
item: {
type: Object,
@@ -131,7 +131,8 @@ export default {
domainObject: undefined,
formats: undefined,
viewKey: `alphanumeric-format-${Math.random()}`,
status: ''
status: '',
mutablePromise: undefined
};
},
computed: {
@@ -213,7 +214,7 @@ export default {
},
mounted() {
if (this.openmct.objects.supportsMutation(this.item.identifier)) {
this.openmct.objects.getMutable(this.item.identifier)
this.mutablePromise = this.openmct.objects.getMutable(this.item.identifier)
.then(this.setObject);
} else {
this.openmct.objects.get(this.item.identifier)
@@ -235,7 +236,11 @@ export default {
this.openmct.time.off("bounds", this.refreshData);
if (this.domainObject.isMutable) {
if (this.mutablePromise) {
this.mutablePromise.then(() => {
this.openmct.objects.destroyMutable(this.domainObject);
});
} else {
this.openmct.objects.destroyMutable(this.domainObject);
}
},
@@ -296,6 +301,7 @@ export default {
},
setObject(domainObject) {
this.domainObject = domainObject;
this.mutablePromise = undefined;
this.keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.limitEvaluator = this.openmct.telemetry.limitEvaluator(this.domainObject);

View File

@@ -59,11 +59,11 @@ export default {
font: 'default'
};
},
inject: ['openmct'],
components: {
LayoutFrame
},
mixins: [conditionalStylesMixin],
inject: ['openmct'],
props: {
item: {
type: Object,

View File

@@ -83,7 +83,7 @@ describe('the plugin', function () {
}
};
const applicableViews = openmct.objectViews.get(testViewObject);
const applicableViews = openmct.objectViews.get(testViewObject, []);
let displayLayoutViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'layout.view');
expect(displayLayoutViewProvider).toBeDefined();
});

View File

@@ -47,13 +47,13 @@ define([
return {
show: function (element) {
component = new Vue({
provide: {
openmct
},
el: element,
components: {
FiltersView: FiltersView.default
},
provide: {
openmct
},
template: '<filters-view></filters-view>'
});
},

View File

@@ -65,11 +65,11 @@ import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue';
import isEmpty from 'lodash/isEmpty';
export default {
inject: ['openmct'],
components: {
FilterField,
ToggleSwitch
},
inject: ['openmct'],
props: {
filterObject: {
type: Object,

View File

@@ -41,10 +41,10 @@
import FilterField from './FilterField.vue';
export default {
inject: ['openmct'],
components: {
FilterField
},
inject: ['openmct'],
props: {
globalMetadata: {
type: Object,

View File

@@ -87,12 +87,12 @@ import DropHint from './dropHint.vue';
const MIN_FRAME_SIZE = 5;
export default {
inject: ['openmct'],
components: {
FrameComponent,
ResizeHandle,
DropHint
},
inject: ['openmct'],
props: {
container: {
type: Object,

View File

@@ -28,7 +28,7 @@
></div>
<div
v-if="areAllContainersEmpty()"
v-if="allContainersAreEmpty"
class="c-fl__empty"
>
<span class="c-fl__empty-message">This Flexible Layout is currently empty</span>
@@ -94,7 +94,6 @@ import Container from '../utils/container';
import Frame from '../utils/frame';
import ResizeHandle from './resizeHandle.vue';
import DropHint from './dropHint.vue';
import RemoveAction from '../../remove/RemoveAction.js';
const MIN_CONTAINER_SIZE = 5;
@@ -140,19 +139,20 @@ function sizeToFill(items) {
}
export default {
inject: ['openmct', 'objectPath', 'layoutObject'],
components: {
ContainerComponent,
ResizeHandle,
DropHint
},
inject: ['openmct', 'objectPath', 'layoutObject'],
props: {
isEditing: Boolean
},
data() {
return {
domainObject: this.layoutObject,
newFrameLocation: []
newFrameLocation: [],
identifierMap: {}
};
},
computed: {
@@ -168,26 +168,30 @@ export default {
},
rowsLayout() {
return this.domainObject.configuration.rowsLayout;
},
allContainersAreEmpty() {
return this.containers.every(container => container.frames.length === 0);
}
},
mounted() {
this.buildIdentifierMap();
this.composition = this.openmct.composition.get(this.domainObject);
this.composition.on('remove', this.removeChildObject);
this.composition.on('add', this.addFrame);
this.RemoveAction = new RemoveAction(this.openmct);
this.unobserve = this.openmct.objects.observe(this.domainObject, '*', this.updateDomainObject);
this.composition.load();
},
beforeDestroy() {
this.composition.off('remove', this.removeChildObject);
this.composition.off('add', this.addFrame);
this.unobserve();
},
methods: {
areAllContainersEmpty() {
return !this.containers.filter(container => container.frames.length).length;
buildIdentifierMap() {
this.containers.forEach(container => {
container.frames.forEach(frame => {
let keystring = this.openmct.objects.makeKeyString(frame.domainObjectIdentifier);
this.identifierMap[keystring] = true;
});
});
},
addContainer() {
let container = new Container();
@@ -236,16 +240,21 @@ export default {
this.newFrameLocation = [containerIndex, insertFrameIndex];
},
addFrame(domainObject) {
let containerIndex = this.newFrameLocation.length ? this.newFrameLocation[0] : 0;
let container = this.containers[containerIndex];
let frameIndex = this.newFrameLocation.length ? this.newFrameLocation[1] : container.frames.length;
let frame = new Frame(domainObject.identifier);
let keystring = this.openmct.objects.makeKeyString(domainObject.identifier);
container.frames.splice(frameIndex + 1, 0, frame);
sizeItems(container.frames, frame);
if (!this.identifierMap[keystring]) {
let containerIndex = this.newFrameLocation.length ? this.newFrameLocation[0] : 0;
let container = this.containers[containerIndex];
let frameIndex = this.newFrameLocation.length ? this.newFrameLocation[1] : container.frames.length;
let frame = new Frame(domainObject.identifier);
this.newFrameLocation = [];
this.persist(containerIndex);
container.frames.splice(frameIndex + 1, 0, frame);
sizeItems(container.frames, frame);
this.newFrameLocation = [];
this.persist(containerIndex);
this.identifierMap[keystring] = true;
}
},
deleteFrame(frameId) {
let container = this.containers
@@ -254,16 +263,15 @@ export default {
.frames
.filter((f => f.id === frameId))[0];
this.removeFromComposition(frame.domainObjectIdentifier)
.then(() => {
sizeToFill(container.frames);
this.setSelectionToParent();
});
this.removeFromComposition(frame.domainObjectIdentifier);
this.$nextTick().then(() => {
sizeToFill(container.frames);
this.setSelectionToParent();
});
},
removeFromComposition(identifier) {
return this.openmct.objects.get(identifier).then((childDomainObject) => {
this.RemoveAction.removeFromComposition(this.domainObject, childDomainObject);
});
this.composition.remove({identifier});
},
setSelectionToParent() {
this.$el.click();
@@ -342,6 +350,9 @@ export default {
removeChildObject(identifier) {
let removeIdentifier = this.openmct.objects.makeKeyString(identifier);
this.identifierMap[removeIdentifier] = undefined;
delete this.identifierMap[removeIdentifier];
this.containers.forEach(container => {
container.frames = container.frames.filter(frame => {
let frameIdentifier = this.openmct.objects.makeKeyString(frame.domainObjectIdentifier);

View File

@@ -58,10 +58,10 @@
import ObjectFrame from '../../../ui/components/ObjectFrame.vue';
export default {
inject: ['openmct'],
components: {
ObjectFrame
},
inject: ['openmct'],
props: {
frame: {
type: Object,

View File

@@ -44,15 +44,15 @@ define([
return {
show: function (element, isEditing) {
component = new Vue({
el: element,
components: {
FlexibleLayoutComponent: FlexibleLayoutComponent.default
},
provide: {
openmct,
objectPath,
layoutObject: domainObject
},
el: element,
components: {
FlexibleLayoutComponent: FlexibleLayoutComponent.default
},
data() {
return {
isEditing: isEditing

View File

@@ -22,18 +22,22 @@
define([
'./components/GridView.vue',
'./constants.js',
'vue'
], function (
GridViewComponent,
constants,
Vue
) {
function FolderGridView(openmct) {
const ALLOWED_FOLDER_TYPES = constants.ALLOWED_FOLDER_TYPES;
return {
key: 'grid',
name: 'Grid View',
cssClass: 'icon-thumbs-strip',
canView: function (domainObject) {
return domainObject.type === 'folder';
return ALLOWED_FOLDER_TYPES.includes(domainObject.type);
},
view: function (domainObject) {
let component;

View File

@@ -22,20 +22,24 @@
define([
'./components/ListView.vue',
'./constants.js',
'vue',
'moment'
], function (
ListViewComponent,
constants,
Vue,
Moment
) {
function FolderListView(openmct) {
const ALLOWED_FOLDER_TYPES = constants.ALLOWED_FOLDER_TYPES;
return {
key: 'list-view',
name: 'List View',
cssClass: 'icon-list-view',
canView: function (domainObject) {
return domainObject.type === 'folder';
return ALLOWED_FOLDER_TYPES.includes(domainObject.type);
},
view: function (domainObject) {
let component;

View File

@@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, United States Government
* Open MCT, Copyright (c) 2014-2021, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
@@ -20,13 +20,4 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
define([
"EventEmitter"
], function (
EventEmitter
) {
/**
* Provides a singleton event bus for sharing between objects.
*/
return new EventEmitter();
});
export const ALLOWED_FOLDER_TYPES = ['folder', 'noneditable.folder'];

View File

@@ -0,0 +1,73 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 plugin", () => {
let openmct;
let goToFolderAction;
let mockObjectPath;
beforeEach((done) => {
openmct = createOpenMct();
openmct.on('start', done);
openmct.startHeadless();
goToFolderAction = openmct.actions._allActions.goToOriginal;
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('installs the go to folder action', () => {
expect(goToFolderAction).toBeDefined();
});
describe('when invoked', () => {
beforeEach(() => {
mockObjectPath = [{
name: 'mock folder',
type: 'folder',
identifier: {
key: 'mock-folder',
namespace: ''
}
}];
spyOn(openmct.objects, 'get').and.returnValue(Promise.resolve({
identifier: {
namespace: '',
key: 'test'
}
}));
goToFolderAction.invoke(mockObjectPath);
});
it('goes to the original location', () => {
expect(window.location.href).toContain('context.html#/browse/?tc.mode=fixed&tc.startBound=0&tc.endBound=1&tc.timeSystem=utc');
});
});
});

View File

@@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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 ImageryViewLayout from './components/ImageryViewLayout.vue';
import Vue from 'vue';

View File

@@ -0,0 +1,124 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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.
*****************************************************************************/
<template>
<div
class="c-compass"
:style="compassDimensionsStyle"
>
<CompassHUD
v-if="hasCameraFieldOfView"
:sun-heading="sunHeading"
:camera-angle-of-view="cameraAngleOfView"
:camera-pan="cameraPan"
/>
<CompassRose
v-if="hasCameraFieldOfView"
:heading="heading"
:sun-heading="sunHeading"
:camera-angle-of-view="cameraAngleOfView"
:camera-pan="cameraPan"
:lock-compass="lockCompass"
@toggle-lock-compass="toggleLockCompass"
/>
</div>
</template>
<script>
import CompassHUD from './CompassHUD.vue';
import CompassRose from './CompassRose.vue';
const CAMERA_ANGLE_OF_VIEW = 70;
export default {
components: {
CompassHUD,
CompassRose
},
props: {
containerWidth: {
type: Number,
required: true
},
containerHeight: {
type: Number,
required: true
},
naturalAspectRatio: {
type: Number,
required: true
},
image: {
type: Object,
required: true
},
lockCompass: {
type: Boolean,
required: true
}
},
computed: {
hasCameraFieldOfView() {
return this.cameraPan !== undefined && this.cameraAngleOfView > 0;
},
// horizontal rotation from north in degrees
heading() {
return this.image.heading;
},
// horizontal rotation from north in degrees
sunHeading() {
return this.image.sunOrientation;
},
// horizontal rotation from north in degrees
cameraPan() {
return this.image.cameraPan;
},
cameraAngleOfView() {
return CAMERA_ANGLE_OF_VIEW;
},
compassDimensionsStyle() {
const containerAspectRatio = this.containerWidth / this.containerHeight;
let width;
let height;
if (containerAspectRatio < this.naturalAspectRatio) {
width = '100%';
height = `${ this.containerWidth / this.naturalAspectRatio }px`;
} else {
width = `${ this.containerHeight * this.naturalAspectRatio }px`;
height = '100%';
}
return {
width: width,
height: height
};
}
},
methods: {
toggleLockCompass() {
this.$emit('toggle-lock-compass');
}
}
};
</script>

View File

@@ -0,0 +1,141 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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.
*****************************************************************************/
<template>
<div
class="c-compass__hud c-hud"
>
<div
v-for="point in visibleCompassPoints"
:key="point.direction"
:class="point.class"
:style="point.style"
>
{{ point.direction }}
</div>
<div
v-if="isSunInRange"
ref="sun"
class="c-hud__sun"
:style="sunPositionStyle"
></div>
<div class="c-hud__range"></div>
</div>
</template>
<script>
import {
rotate,
inRange,
percentOfRange
} from './utils';
const COMPASS_POINTS = [
{
direction: 'N',
class: 'c-hud__dir',
degrees: 0
},
{
direction: 'NE',
class: 'c-hud__dir--sub',
degrees: 45
},
{
direction: 'E',
class: 'c-hud__dir',
degrees: 90
},
{
direction: 'SE',
class: 'c-hud__dir--sub',
degrees: 135
},
{
direction: 'S',
class: 'c-hud__dir',
degrees: 180
},
{
direction: 'SW',
class: 'c-hud__dir--sub',
degrees: 225
},
{
direction: 'W',
class: 'c-hud__dir',
degrees: 270
},
{
direction: 'NW',
class: 'c-hud__dir--sub',
degrees: 315
}
];
export default {
props: {
sunHeading: {
type: Number,
default: undefined
},
cameraAngleOfView: {
type: Number,
default: undefined
},
cameraPan: {
type: Number,
required: true
}
},
computed: {
visibleCompassPoints() {
return COMPASS_POINTS
.filter(point => inRange(point.degrees, this.visibleRange))
.map(point => {
const percentage = percentOfRange(point.degrees, this.visibleRange);
point.style = Object.assign(
{ left: `${ percentage * 100 }%` }
);
return point;
});
},
isSunInRange() {
return inRange(this.sunHeading, this.visibleRange);
},
sunPositionStyle() {
const percentage = percentOfRange(this.sunHeading, this.visibleRange);
return {
left: `${ percentage * 100 }%`
};
},
visibleRange() {
return [
rotate(this.cameraPan, -this.cameraAngleOfView / 2),
rotate(this.cameraPan, this.cameraAngleOfView / 2)
];
}
}
};
</script>

View File

@@ -0,0 +1,261 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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.
*****************************************************************************/
<template>
<div
class="c-direction-rose"
@click="toggleLockCompass"
>
<div
class="c-nsew"
:style="compassRoseStyle"
>
<svg
class="c-nsew__minor-ticks"
viewBox="0 0 100 100"
>
<rect
class="c-nsew__tick c-tick-ne"
x="49"
y="0"
width="2"
height="5"
/>
<rect
class="c-nsew__tick c-tick-se"
x="95"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-sw"
x="49"
y="95"
width="2"
height="5"
/>
<rect
class="c-nsew__tick c-tick-nw"
x="0"
y="49"
width="5"
height="2"
/>
</svg>
<svg
class="c-nsew__ticks"
viewBox="0 0 100 100"
>
<polygon
class="c-nsew__tick c-tick-n"
points="50,0 57,5 43,5"
/>
<rect
class="c-nsew__tick c-tick-e"
x="95"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-w"
x="0"
y="49"
width="5"
height="2"
/>
<rect
class="c-nsew__tick c-tick-s"
x="49"
y="95"
width="2"
height="5"
/>
<text
class="c-nsew__label c-label-n"
text-anchor="middle"
:transform="northTextTransform"
>N</text>
<text
class="c-nsew__label c-label-e"
text-anchor="middle"
:transform="eastTextTransform"
>E</text>
<text
class="c-nsew__label c-label-w"
text-anchor="middle"
:transform="southTextTransform"
>W</text>
<text
class="c-nsew__label c-label-s"
text-anchor="middle"
:transform="westTextTransform"
>S</text>
</svg>
</div>
<div
v-if="hasHeading"
class="c-spacecraft-body"
:style="headingStyle"
>
</div>
<div
v-if="hasSunHeading"
class="c-sun"
:style="sunHeadingStyle"
></div>
<div
class="c-cam-field"
:style="cameraPanStyle"
>
<div class="cam-field-half cam-field-half-l">
<div
class="cam-field-area"
:style="cameraFOVStyleLeftHalf"
></div>
</div>
<div class="cam-field-half cam-field-half-r">
<div
class="cam-field-area"
:style="cameraFOVStyleRightHalf"
></div>
</div>
</div>
</div>
</template>
<script>
import { rotate } from './utils';
export default {
props: {
heading: {
type: Number,
required: true
},
sunHeading: {
type: Number,
default: undefined
},
cameraAngleOfView: {
type: Number,
default: undefined
},
cameraPan: {
type: Number,
required: true
},
lockCompass: {
type: Boolean,
required: true
}
},
computed: {
north() {
return this.lockCompass ? rotate(-this.cameraPan) : 0;
},
compassRoseStyle() {
return { transform: `rotate(${ this.north }deg)` };
},
northTextTransform() {
return this.cardinalPointsTextTransform.north;
},
eastTextTransform() {
return this.cardinalPointsTextTransform.east;
},
southTextTransform() {
return this.cardinalPointsTextTransform.south;
},
westTextTransform() {
return this.cardinalPointsTextTransform.west;
},
cardinalPointsTextTransform() {
/**
* cardinal points text must be rotated
* in the opposite direction that north is rotated
* to keep text vertically oriented
*/
const rotation = `rotate(${ -this.north })`;
return {
north: `translate(50,15) ${ rotation }`,
east: `translate(87,50) ${ rotation }`,
south: `translate(13,50) ${ rotation }`,
west: `translate(50,87) ${ rotation }`
};
},
hasHeading() {
return this.heading !== undefined;
},
headingStyle() {
const rotation = rotate(this.north, this.heading);
return {
transform: `translateX(-50%) rotate(${ rotation }deg)`
};
},
hasSunHeading() {
return this.sunHeading !== undefined;
},
sunHeadingStyle() {
const rotation = rotate(this.north, this.sunHeading);
return {
transform: `rotate(${ rotation }deg)`
};
},
cameraPanStyle() {
const rotation = rotate(this.north, this.cameraPan);
return {
transform: `rotate(${ rotation }deg)`
};
},
// left half of camera field of view
// rotated counter-clockwise from camera pan angle
cameraFOVStyleLeftHalf() {
return {
transform: `translateX(50%) rotate(${ -this.cameraAngleOfView / 2 }deg)`
};
},
// right half of camera field of view
// rotated clockwise from camera pan angle
cameraFOVStyleRightHalf() {
return {
transform: `translateX(-50%) rotate(${ this.cameraAngleOfView / 2 }deg)`
};
}
},
methods: {
toggleLockCompass() {
this.$emit('toggle-lock-compass');
}
}
};
</script>

View File

@@ -0,0 +1,214 @@
/***************************** THEME/UI CONSTANTS AND MIXINS */
$interfaceKeyColor: #00B9C5;
$elemBg: rgba(black, 0.7);
@mixin sun($position: 'circle closest-side') {
$color: #ff9900;
$gradEdgePerc: 60%;
background: radial-gradient(#{$position}, $color, $color $gradEdgePerc, rgba($color, 0.4) $gradEdgePerc + 5%, transparent);
}
.c-compass {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
@include userSelectNone;
}
/***************************** COMPASS HUD */
.c-hud {
// To be placed within a imagery view, in the bounding box of the image
$m: 1px;
$padTB: 2px;
$padLR: $padTB;
color: $interfaceKeyColor;
font-size: 0.8em;
position: absolute;
top: $m; right: $m; left: $m;
height: 18px;
svg, div {
position: absolute;
}
&__display {
height: 30px;
pointer-events: all;
position: absolute;
top: 0;
right: 0;
left: 0;
}
&__range {
border: 1px solid $interfaceKeyColor;
border-top-color: transparent;
position: absolute;
top: 50%; right: $padLR; bottom: $padTB; left: $padLR;
}
[class*="__dir"] {
// NSEW
display: inline-block;
font-weight: bold;
text-shadow: 0 1px 2px black;
top: 50%;
transform: translate(-50%,-50%);
z-index: 2;
}
[class*="__dir--sub"] {
font-weight: normal;
opacity: 0.5;
}
&__sun {
$s: 10px;
@include sun('circle farthest-side at bottom');
bottom: $padTB + 2px;
height: $s; width: $s*2;
opacity: 0.8;
transform: translateX(-50%);
z-index: 1;
}
}
/***************************** COMPASS DIRECTIONS */
.c-nsew {
$color: $interfaceKeyColor;
$inset: 7%;
$tickHeightPerc: 15%;
text-shadow: black 0 0 10px;
top: $inset; right: $inset; bottom: $inset; left: $inset;
z-index: 3;
&__tick,
&__label {
fill: $color;
}
&__minor-ticks {
opacity: 0.5;
transform-origin: center;
transform: rotate(45deg);
}
&__label {
dominant-baseline: central;
font-size: 0.8em;
font-weight: bold;
}
.c-label-n {
font-size: 1.1em;
}
}
/***************************** CAMERA FIELD ANGLE */
.c-cam-field {
$color: white;
opacity: 0.2;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
.cam-field-half {
top: 0;
right: 0;
bottom: 0;
left: 0;
.cam-field-area {
background: $color;
top: -30%;
right: 0;
bottom: -30%;
left: 0;
}
// clip-paths overlap a bit to avoid a gap between halves
&-l {
clip-path: polygon(0 0, 50.5% 0, 50.5% 100%, 0 100%);
.cam-field-area {
transform-origin: left center;
}
}
&-r {
clip-path: polygon(49.5% 0, 100% 0, 100% 100%, 49.5% 100%);
.cam-field-area {
transform-origin: right center;
}
}
}
}
/***************************** SPACECRAFT BODY */
.c-spacecraft-body {
$color: $interfaceKeyColor;
$s: 30%;
background: $color;
border-radius: 3px;
height: $s; width: $s;
left: 50%; top: 50%;
opacity: 0.4;
transform-origin: center top;
&:before {
// Direction arrow
$color: rgba(black, 0.5);
$arwPointerY: 60%;
$arwBodyOffset: 25%;
background: $color;
content: '';
display: block;
position: absolute;
top: 10%; right: 20%; bottom: 50%; left: 20%;
clip-path: polygon(50% 0, 100% $arwPointerY, 100%-$arwBodyOffset $arwPointerY, 100%-$arwBodyOffset 100%, $arwBodyOffset 100%, $arwBodyOffset $arwPointerY, 0 $arwPointerY);
}
}
/***************************** DIRECTION ROSE */
.c-direction-rose {
$d: 100px;
$c2: rgba(white, 0.1);
background: $elemBg;
background-image: radial-gradient(circle closest-side, transparent, transparent 80%, $c2);
width: $d;
height: $d;
transform-origin: 0 0;
position: absolute;
bottom: 10px; left: 10px;
clip-path: circle(50% at 50% 50%);
border-radius: 100%;
svg, div {
position: absolute;
}
// Sun
.c-sun {
top: 0;
right: 0;
bottom: 0;
left: 0;
&:before {
$s: 35%;
@include sun();
content: '';
display: block;
position: absolute;
opacity: 0.7;
top: 0; left: 50%;
height:$s; width: $s;
transform: translate(-50%, -60%);
}
}
}

View File

@@ -0,0 +1,84 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 Compass from './Compass.vue';
import Vue from 'vue';
const COMPASS_ROSE_CLASS = '.c-direction-rose';
const COMPASS_HUD_CLASS = '.c-compass__hud';
describe("The Compass component", () => {
let app;
let instance;
beforeEach(() => {
let imageDatum = {
heading: 100,
roll: 90,
pitch: 90,
cameraTilt: 100,
cameraPan: 90,
sunAngle: 30
};
let propsData = {
containerWidth: 600,
containerHeight: 600,
naturalAspectRatio: 0.9,
image: imageDatum
};
app = new Vue({
components: { Compass },
data() {
return propsData;
},
template: `<Compass
:container-width="containerWidth"
:container-height="containerHeight"
:natural-aspect-ratio="naturalAspectRatio"
:image="image" />`
});
instance = app.$mount();
});
afterAll(() => {
app.$destroy();
});
describe("when a heading value exists on the image", () => {
it("should display a compass rose", () => {
let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS
);
expect(compassRoseElement).toBeDefined();
});
it("should display a compass HUD", () => {
let compassHUDElement = instance.$el.querySelector(COMPASS_HUD_CLASS);
expect(compassHUDElement).toBeDefined();
});
});
});

View File

@@ -0,0 +1,44 @@
/**
*
* sums an arbitrary number of absolute rotations
* (meaning rotations relative to one common direction 0)
* normalizes the rotation to the range [0, 360)
*
* @param {...number} rotations in degrees
* @returns {number} normalized sum of all rotations - [0, 360) degrees
*/
export function rotate(...rotations) {
const rotation = rotations.reduce((a, b) => a + b, 0);
return normalizeCompassDirection(rotation);
}
export function inRange(degrees, [min, max]) {
const point = rotate(degrees);
return min > max
? (point >= min && point < 360) || (point <= max && point >= 0)
: point >= min && point <= max;
}
export function percentOfRange(degrees, [min, max]) {
let distance = rotate(degrees);
let minRange = min;
let maxRange = max;
if (min > max) {
if (distance < max) {
distance += 360;
}
maxRange += 360;
}
return (distance - minRange) / (maxRange - minRange);
}
function normalizeCompassDirection(degrees) {
const base = degrees % 360;
return base >= 0 ? base : 360 + base;
}

View File

@@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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.
*****************************************************************************/
<template>
<div
tabindex="0"
@@ -36,14 +58,25 @@
<div class="c-imagery__main-image__bg"
:class="{'paused unnsynced': isPaused,'stale':false }"
>
<div class="c-imagery__main-image__image js-imageryView-image"
:style="{
'background-image': imageUrl ? `url(${imageUrl})` : 'none',
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`
}"
:data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString"
></div>
<img
ref="focusedImage"
class="c-imagery__main-image__image js-imageryView-image"
:src="imageUrl"
:style="{
'filter': `brightness(${filters.brightness}%) contrast(${filters.contrast}%)`
}"
:data-openmct-image-timestamp="time"
:data-openmct-object-keystring="keyString"
>
<Compass
v-if="shouldDisplayCompass"
:container-width="imageContainerWidth"
:container-height="imageContainerHeight"
:natural-aspect-ratio="focusedImageNaturalAspectRatio"
:image="focusedImage"
:lock-compass="lockCompass"
@toggle-lock-compass="toggleLockCompass"
/>
</div>
<div class="c-local-controls c-local-controls--show-on-hover c-imagery__prev-next-buttons">
<button class="c-nav c-nav--prev"
@@ -61,11 +94,25 @@
<div class="c-imagery__control-bar">
<div class="c-imagery__time">
<div class="c-imagery__timestamp u-style-receiver js-style-receiver">{{ time }}</div>
<!-- image fresh -->
<div
v-if="canTrackDuration"
:class="{'c-imagery--new': isImageNew && !refreshCSS}"
class="c-imagery__age icon-timer"
>{{ formattedDuration }}</div>
<!-- spacecraft position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
>POS</div>
<!-- camera position fresh -->
<div
v-if="relatedTelemetry.hasRelatedTelemetry && isCameraPositionFresh"
class="c-imagery__age icon-check c-imagery--new"
>CAM</div>
</div>
<div class="h-local-controls">
<button
@@ -76,28 +123,32 @@
</div>
</div>
</div>
<div ref="thumbsWrapper"
class="c-imagery__thumbs-wrapper"
:class="{'is-paused': isPaused}"
@scroll="handleScroll"
<div
ref="thumbsWrapper"
class="c-imagery__thumbs-wrapper"
:class="{'is-paused': isPaused}"
@scroll="handleScroll"
>
<div v-for="(datum, index) in imageHistory"
:key="datum.url"
<div v-for="(image, index) in imageHistory"
:key="image.url + image.time"
class="c-imagery__thumb c-thumb"
:class="{ selected: focusedImageIndex === index && isPaused }"
@click="setFocusedImage(index, thumbnailClick)"
>
<img class="c-thumb__image"
:src="formatImageUrl(datum)"
:src="image.url"
>
<div class="c-thumb__timestamp">{{ formatTime(datum) }}</div>
<div class="c-thumb__timestamp">{{ image.formattedTime }}</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash';
import moment from 'moment';
import Compass from './Compass/Compass.vue';
import RelatedTelemetry from './RelatedTelemetry/RelatedTelemetry';
const DEFAULT_DURATION_FORMATTER = 'duration';
const REFRESH_CSS_MS = 500;
@@ -116,6 +167,9 @@ const ARROW_RIGHT = 39;
const ARROW_LEFT = 37;
export default {
components: {
Compass
},
inject: ['openmct', 'domainObject'],
data() {
let timeSystem = this.openmct.time.timeSystem();
@@ -137,7 +191,15 @@ export default {
refreshCSS: false,
keyString: undefined,
focusedImageIndex: undefined,
numericDuration: undefined
focusedImageRelatedTelemetry: {},
numericDuration: undefined,
metadataEndpoints: {},
relatedTelemetry: {},
latestRelatedTelemetry: {},
focusedImageNaturalAspectRatio: undefined,
imageContainerWidth: undefined,
imageContainerHeight: undefined,
lockCompass: true
};
},
computed: {
@@ -195,15 +257,83 @@ export default {
}
return result;
},
shouldDisplayCompass() {
return this.focusedImage !== undefined
&& this.focusedImageNaturalAspectRatio !== undefined
&& this.imageContainerWidth !== undefined
&& this.imageContainerHeight !== undefined;
},
isSpacecraftPositionFresh() {
let isFresh = undefined;
let latest = this.latestRelatedTelemetry;
let focused = this.focusedImageRelatedTelemetry;
if (this.relatedTelemetry.hasRelatedTelemetry) {
isFresh = true;
for (let key of this.spacecraftPositionKeys) {
if (this.relatedTelemetry[key] && latest[key] && focused[key]) {
isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key]));
} else {
isFresh = false;
}
}
}
return isFresh;
},
isSpacecraftOrientationFresh() {
let isFresh = undefined;
let latest = this.latestRelatedTelemetry;
let focused = this.focusedImageRelatedTelemetry;
if (this.relatedTelemetry.hasRelatedTelemetry) {
isFresh = true;
for (let key of this.spacecraftOrientationKeys) {
if (this.relatedTelemetry[key] && latest[key] && focused[key]) {
isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key]));
} else {
isFresh = false;
}
}
}
return isFresh;
},
isCameraPositionFresh() {
let isFresh = undefined;
let latest = this.latestRelatedTelemetry;
let focused = this.focusedImageRelatedTelemetry;
if (this.relatedTelemetry.hasRelatedTelemetry) {
isFresh = true;
// camera freshness relies on spacecraft position freshness
if (this.isSpacecraftPositionFresh && this.isSpacecraftOrientationFresh) {
for (let key of this.cameraKeys) {
if (this.relatedTelemetry[key] && latest[key] && focused[key]) {
isFresh = isFresh && Boolean(this.relatedTelemetry[key].comparisonFunction(latest[key], focused[key]));
} else {
isFresh = false;
}
}
} else {
isFresh = false;
}
}
return isFresh;
}
},
watch: {
focusedImageIndex() {
this.trackDuration();
this.resetAgeCSS();
this.updateRelatedTelemetryForFocusedImage();
this.getImageNaturalDimensions();
}
},
mounted() {
async mounted() {
// listen
this.openmct.time.on('bounds', this.boundsChange);
this.openmct.time.on('timeSystem', this.timeSystemChange);
@@ -212,8 +342,15 @@ export default {
// set
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.imageHints = { ...this.metadata.valuesForHints(['image'])[0] };
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.metadata.valuesForHints(['image'])[0]);
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints);
// related telemetry keys
this.spacecraftPositionKeys = ['positionX', 'positionY', 'positionZ'];
this.spacecraftOrientationKeys = ['heading'];
this.cameraKeys = ['cameraPan', 'cameraTilt'];
this.sunKeys = ['sunOrientation'];
// initialize
this.timeKey = this.timeSystem.key;
@@ -222,6 +359,18 @@ export default {
// kickoff
this.subscribe();
this.requestHistory();
// related telemetry
await this.initializeRelatedTelemetry();
this.updateRelatedTelemetryForFocusedImage();
this.trackLatestRelatedTelemetry();
// for scrolling through images quickly and resizing the object view
_.debounce(this.updateRelatedTelemetryForFocusedImage, 400);
_.debounce(this.resizeImageContainer, 400);
this.imageContainerResizeObserver = new ResizeObserver(this.resizeImageContainer);
this.imageContainerResizeObserver.observe(this.$refs.focusedImage);
},
updated() {
this.scrollToRight();
@@ -232,12 +381,120 @@ export default {
delete this.unsubscribe;
}
this.imageContainerResizeObserver.disconnect();
if (this.relatedTelemetry.hasRelatedTelemetry) {
this.relatedTelemetry.destroy();
}
this.stopDurationTracking();
this.openmct.time.off('bounds', this.boundsChange);
this.openmct.time.off('timeSystem', this.timeSystemChange);
this.openmct.time.off('clock', this.clockChange);
// unsubscribe from related telemetry
if (this.relatedTelemetry.hasRelatedTelemetry) {
for (let key of this.relatedTelemetry.keys) {
if (this.relatedTelemetry[key] && this.relatedTelemetry[key].unsubscribe) {
this.relatedTelemetry[key].unsubscribe();
}
}
}
},
methods: {
async initializeRelatedTelemetry() {
this.relatedTelemetry = new RelatedTelemetry(
this.openmct,
this.domainObject,
[...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys]
);
if (this.relatedTelemetry.hasRelatedTelemetry) {
await this.relatedTelemetry.load();
}
},
async getMostRecentRelatedTelemetry(key, targetDatum) {
if (!this.relatedTelemetry.hasRelatedTelemetry) {
throw new Error(`${this.domainObject.name} does not have any related telemetry`);
}
if (!this.relatedTelemetry[key]) {
throw new Error(`${key} does not exist on related telemetry`);
}
let mostRecent;
let valueKey = this.relatedTelemetry[key].historical.valueKey;
let valuesOnTelemetry = this.relatedTelemetry[key].hasTelemetryOnDatum;
if (valuesOnTelemetry) {
mostRecent = targetDatum[valueKey];
if (mostRecent) {
return mostRecent;
} else {
console.warn(`Related Telemetry for ${key} does NOT exist on this telemetry datum as configuration implied.`);
return;
}
}
mostRecent = await this.relatedTelemetry[key].requestLatestFor(targetDatum);
return mostRecent[valueKey];
},
// will subscribe to data for this key if not already done
subscribeToDataForKey(key) {
if (this.relatedTelemetry[key].isSubscribed) {
return;
}
if (this.relatedTelemetry[key].realtimeDomainObject) {
this.relatedTelemetry[key].unsubscribe = this.openmct.telemetry.subscribe(
this.relatedTelemetry[key].realtimeDomainObject, datum => {
this.relatedTelemetry[key].listeners.forEach(callback => {
callback(datum);
});
}
);
this.relatedTelemetry[key].isSubscribed = true;
}
},
async updateRelatedTelemetryForFocusedImage() {
if (!this.relatedTelemetry.hasRelatedTelemetry || !this.focusedImage) {
return;
}
// set data ON image telemetry as well as in focusedImageRelatedTelemetry
for (let key of this.relatedTelemetry.keys) {
if (
this.relatedTelemetry[key]
&& this.relatedTelemetry[key].historical
&& this.relatedTelemetry[key].requestLatestFor
) {
let valuesOnTelemetry = this.relatedTelemetry[key].hasTelemetryOnDatum;
let value = await this.getMostRecentRelatedTelemetry(key, this.focusedImage);
if (!valuesOnTelemetry) {
this.$set(this.imageHistory[this.focusedImageIndex], key, value); // manually add to telemetry
}
this.$set(this.focusedImageRelatedTelemetry, key, value);
}
}
},
trackLatestRelatedTelemetry() {
[...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys].forEach(key => {
if (this.relatedTelemetry[key] && this.relatedTelemetry[key].subscribe) {
this.relatedTelemetry[key].subscribe((datum) => {
let valueKey = this.relatedTelemetry[key].realtime.valueKey;
this.$set(this.latestRelatedTelemetry, key, datum[valueKey]);
});
}
});
},
focusElement() {
this.$el.focus();
},
@@ -358,6 +615,7 @@ export default {
this.requestCount++;
const requestId = this.requestCount;
this.imageHistory = [];
let data = await this.openmct.telemetry
.request(this.domainObject, bounds) || [];
@@ -393,7 +651,12 @@ export default {
return;
}
this.imageHistory.push(datum);
let image = { ...datum };
image.formattedTime = this.formatTime(datum);
image.url = this.formatImageUrl(datum);
image.time = datum[this.timeKey];
this.imageHistory.push(image);
if (setFocused) {
this.setFocusedImage(this.imageHistory.length - 1);
@@ -509,6 +772,28 @@ export default {
},
isLeftOrRightArrowKey(keyCode) {
return [ARROW_RIGHT, ARROW_LEFT].includes(keyCode);
},
getImageNaturalDimensions() {
this.focusedImageNaturalAspectRatio = undefined;
const img = this.$refs.focusedImage;
// TODO - should probably cache this
img.addEventListener('load', () => {
this.focusedImageNaturalAspectRatio = img.naturalWidth / img.naturalHeight;
}, { once: true });
},
resizeImageContainer() {
if (this.$refs.focusedImage.clientWidth !== this.imageContainerWidth) {
this.imageContainerWidth = this.$refs.focusedImage.clientWidth;
}
if (this.$refs.focusedImage.clientHeight !== this.imageContainerHeight) {
this.imageContainerHeight = this.$refs.focusedImage.clientHeight;
}
},
toggleLockCompass() {
this.lockCompass = !this.lockCompass;
}
}
};

View File

@@ -0,0 +1,164 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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.
*****************************************************************************/
function copyRelatedMetadata(metadata) {
let compare = metadata.comparisonFunction;
let copiedMetadata = JSON.parse(JSON.stringify(metadata));
copiedMetadata.comparisonFunction = compare;
return copiedMetadata;
}
export default class RelatedTelemetry {
constructor(openmct, domainObject, telemetryKeys) {
this._openmct = openmct;
this._domainObject = domainObject;
let metadata = this._openmct.telemetry.getMetadata(this._domainObject);
let imageHints = metadata.valuesForHints(['image'])[0];
this.hasRelatedTelemetry = imageHints.relatedTelemetry !== undefined;
if (this.hasRelatedTelemetry) {
this.keys = telemetryKeys;
this._timeFormatter = undefined;
this._timeSystemChange(this._openmct.time.timeSystem());
// grab related telemetry metadata
for (let key of this.keys) {
if (imageHints.relatedTelemetry[key]) {
this[key] = copyRelatedMetadata(imageHints.relatedTelemetry[key]);
}
}
this.load = this.load.bind(this);
this._parseTime = this._parseTime.bind(this);
this._timeSystemChange = this._timeSystemChange.bind(this);
this.destroy = this.destroy.bind(this);
this._openmct.time.on('timeSystem', this._timeSystemChange);
}
}
async load() {
if (!this.hasRelatedTelemetry) {
throw new Error('This domain object does not have related telemetry, use "hasRelatedTelemetry" to check before loading.');
}
await Promise.all(
this.keys.map(async (key) => {
if (this[key]) {
if (this[key].historical) {
await this._initializeHistorical(key);
}
if (this[key].realtime && this[key].realtime.telemetryObjectId && this[key].realtime.telemetryObjectId !== '') {
await this._intializeRealtime(key);
}
}
})
);
}
async _initializeHistorical(key) {
if (!this[key].historical.telemetryObjectId) {
this[key].historical.hasTelemetryOnDatum = true;
} else if (this[key].historical.telemetryObjectId !== '') {
this[key].historicalDomainObject = await this._openmct.objects.get(this[key].historical.telemetryObjectId);
this[key].requestLatestFor = async (datum) => {
const options = {
start: this._openmct.time.bounds().start,
end: this._parseTime(datum),
strategy: 'latest'
};
let results = await this._openmct.telemetry
.request(this[key].historicalDomainObject, options);
return results[results.length - 1];
};
}
}
async _intializeRealtime(key) {
this[key].realtimeDomainObject = await this._openmct.objects.get(this[key].realtime.telemetryObjectId);
this[key].listeners = [];
this[key].subscribe = (callback) => {
if (!this[key].isSubscribed) {
this._subscribeToDataForKey(key);
}
if (!this[key].listeners.includes(callback)) {
this[key].listeners.push(callback);
return () => {
this[key].listeners.remove(callback);
};
} else {
return () => {};
}
};
}
_subscribeToDataForKey(key) {
if (this[key].isSubscribed) {
return;
}
if (this[key].realtimeDomainObject) {
this[key].unsubscribe = this._openmct.telemetry.subscribe(
this[key].realtimeDomainObject, datum => {
this[key].listeners.forEach(callback => {
callback(datum);
});
}
);
this[key].isSubscribed = true;
}
}
_parseTime(datum) {
return this._timeFormatter.parse(datum);
}
_timeSystemChange(system) {
let key = system.key;
let metadata = this._openmct.telemetry.getMetadata(this._domainObject);
let metadataValue = metadata.value(key) || { format: key };
this._timeFormatter = this._openmct.telemetry.getValueFormatter(metadataValue);
}
destroy() {
this._openmct.time.off('timeSystem', this._timeSystemChange);
for (let key of this.keys) {
if (this[key] && this[key].unsubscribe) {
this[key].unsubscribe();
}
}
}
}

View File

@@ -23,6 +23,7 @@
background-color: $colorPlotBg;
border: 1px solid transparent;
flex: 1 1 auto;
height: 0;
&.unnsynced{
@include sUnsynced();
@@ -30,10 +31,9 @@
}
&__image {
@include abs(); // Safari fix
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 100%;
width: 100%;
object-fit: contain;
}
}
@@ -71,13 +71,14 @@
}
&__age {
border-radius: $controlCr;
border-radius: $smallCr;
display: flex;
flex-shrink: 0;
align-items: baseline;
padding: 1px $interiorMarginSm;
align-items: center;
padding: 2px $interiorMarginSm;
&:before {
font-size: 0.9em;
opacity: 0.5;
margin-right: $interiorMarginSm;
}
@@ -86,8 +87,9 @@
&--new {
// New imagery
$bgColor: $colorOk;
color: $colorOkFg;
background: rgba($bgColor, 0.5);
@include flash($animName: flashImageAge, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
@include flash($animName: flashImageAge, $iter: 2, $dur: 250ms, $valStart: rgba($colorOk, 0.7), $valEnd: rgba($colorOk, 0));
}
&__thumbs-wrapper {

View File

@@ -1,3 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2021, 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 ImageryViewProvider from './ImageryViewProvider';
export default function () {

View File

@@ -32,12 +32,25 @@ const TEN_MINUTES = ONE_MINUTE * 10;
const MAIN_IMAGE_CLASS = '.js-imageryView-image';
const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new';
const REFRESH_CSS_MS = 500;
const TOLERANCE = 0.50;
function comparisonFunction(valueOne, valueTwo) {
let larger = valueOne;
let smaller = valueTwo;
if (larger < smaller) {
larger = valueTwo;
smaller = valueOne;
}
return (larger - smaller) < TOLERANCE;
}
function getImageInfo(doc) {
let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0];
let timestamp = imageElement.dataset.openmctImageTimestamp;
let identifier = imageElement.dataset.openmctObjectKeystring;
let url = imageElement.style.backgroundImage;
let url = imageElement.src;
return {
timestamp,
@@ -63,7 +76,8 @@ function generateTelemetry(start, count) {
"name": stringRep + " Imagery",
"utc": start + (i * ONE_MINUTE),
"url": location.host + '/' + logo + '?time=' + stringRep,
"timeId": stringRep
"timeId": stringRep,
"value": 100
});
}
@@ -105,7 +119,51 @@ describe("The Imagery View Layout", () => {
"image": 1,
"priority": 3
},
"source": "url"
"source": "url",
"relatedTelemetry": {
"heading": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "heading",
"valueKey": "value"
}
},
"roll": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "roll",
"valueKey": "value"
}
},
"pitch": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "pitch",
"valueKey": "value"
}
},
"cameraPan": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "cameraPan",
"valueKey": "value"
}
},
"cameraTilt": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "cameraTilt",
"valueKey": "value"
}
},
"sunOrientation": {
"comparisonFunction": comparisonFunction,
"historical": {
"telemetryObjectId": "sunOrientation",
"valueKey": "value"
}
}
}
},
{
"name": "Name",
@@ -151,6 +209,11 @@ describe("The Imagery View Layout", () => {
child = document.createElement('div');
parent.appendChild(child);
spyOn(window, 'ResizeObserver').and.returnValue({
observe() {},
disconnect() {}
});
spyOn(openmct.telemetry, 'request').and.returnValue(Promise.resolve([]));
imageryPlugin = new ImageryPlugin();
@@ -172,7 +235,7 @@ describe("The Imagery View Layout", () => {
});
it("should provide an imagery view only for imagery producing objects", () => {
let applicableViews = openmct.objectViews.get(imageryObject);
let applicableViews = openmct.objectViews.get(imageryObject, []);
let imageryView = applicableViews.find(
viewProvider => viewProvider.key === imageryKey
);
@@ -202,7 +265,7 @@ describe("The Imagery View Layout", () => {
end: bounds.end + 100
});
applicableViews = openmct.objectViews.get(imageryObject);
applicableViews = openmct.objectViews.get(imageryObject, []);
imageryViewProvider = applicableViews.find(viewProvider => viewProvider.key === imageryKey);
imageryView = imageryViewProvider.view(imageryObject);
imageryView.show(child);
@@ -213,6 +276,10 @@ describe("The Imagery View Layout", () => {
return done();
});
afterEach(() => {
imageryView.destroy();
});
it("on mount should show the the most recent image", () => {
const imageInfo = getImageInfo(parent);

View File

@@ -0,0 +1,99 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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";
import InterceptorPlugin from "./plugin";
describe('the plugin', function () {
let element;
let child;
let openmct;
const TEST_NAMESPACE = 'test';
beforeEach((done) => {
const appHolder = document.createElement('div');
appHolder.style.width = '640px';
appHolder.style.height = '480px';
openmct = createOpenMct();
openmct.install(new InterceptorPlugin(openmct));
element = document.createElement('div');
element.style.width = '640px';
element.style.height = '480px';
child = document.createElement('div');
child.style.width = '640px';
child.style.height = '480px';
element.appendChild(child);
openmct.on('start', done);
openmct.startHeadless(appHolder);
});
afterEach(() => {
return resetApplicationState(openmct);
});
describe('the missingObjectInterceptor', () => {
let mockProvider;
beforeEach(() => {
mockProvider = jasmine.createSpyObj("mock provider", [
"get"
]);
mockProvider.get.and.returnValue(Promise.resolve(undefined));
openmct.objects.addProvider(TEST_NAMESPACE, mockProvider);
});
it('returns missing objects', (done) => {
const identifier = {
namespace: TEST_NAMESPACE,
key: 'hello'
};
openmct.objects.get(identifier).then((testObject) => {
expect(testObject).toEqual({
identifier,
type: 'unknown',
name: 'Missing: test:hello'
});
done();
});
});
it('returns the My items object if not found', (done) => {
const identifier = {
namespace: TEST_NAMESPACE,
key: 'mine'
};
openmct.objects.get(identifier).then((testObject) => {
expect(testObject).toEqual({
identifier,
"name": "My Items",
"type": "folder",
"composition": [],
"location": "ROOT"
});
done();
});
});
});
});

View File

@@ -0,0 +1,33 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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.
*****************************************************************************/
export default function () {
return function (openmct) {
openmct.types.addType("noneditable.folder", {
name: "Non-Editable Folder",
key: "noneditable.folder",
description: "Create folders to organize other objects or links to objects without the ability to edit it's properties.",
cssClass: "icon-folder",
creatable: false
});
};
}

View File

@@ -0,0 +1,50 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 plugin", () => {
const NON_EDITABLE_FOLDER_KEY = 'noneditable.folder';
let openmct;
beforeEach((done) => {
openmct = createOpenMct();
openmct.install(openmct.plugins.NonEditableFolder());
openmct.on('start', done);
openmct.startHeadless();
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('adds the new non-editable folder type', () => {
const type = openmct.types.get(NON_EDITABLE_FOLDER_KEY);
expect(type).toBeDefined();
expect(type.definition.creatable).toBeFalse();
});
});

View File

@@ -97,7 +97,8 @@
:selected-page="getSelectedPage()"
:selected-section="getSelectedSection()"
:read-only="false"
@updateEntries="updateEntries"
@deleteEntry="deleteEntry"
@updateEntry="updateEntry"
/>
</div>
</div>
@@ -111,19 +112,20 @@ import Search from '@/ui/components/search.vue';
import SearchResults from './SearchResults.vue';
import Sidebar from './Sidebar.vue';
import { clearDefaultNotebook, getDefaultNotebook, setDefaultNotebook, setDefaultNotebookSection, setDefaultNotebookPage } from '../utils/notebook-storage';
import { addNotebookEntry, createNewEmbed, getNotebookEntries, mutateObject } from '../utils/notebook-entries';
import { addNotebookEntry, createNewEmbed, getEntryPosById, getNotebookEntries, mutateObject } from '../utils/notebook-entries';
import objectUtils from 'objectUtils';
import { throttle } from 'lodash';
import objectLink from '../../../ui/mixins/object-link';
export default {
inject: ['openmct', 'domainObject', 'snapshotContainer'],
components: {
NotebookEntry,
Search,
SearchResults,
Sidebar
},
inject: ['openmct', 'domainObject', 'snapshotContainer'],
data() {
return {
defaultPageId: getDefaultNotebook() ? getDefaultNotebook().page.id : '',
@@ -182,7 +184,9 @@ export default {
mounted() {
this.unlisten = this.openmct.objects.observe(this.internalDomainObject, '*', this.updateInternalDomainObject);
this.formatSidebar();
window.addEventListener('orientationchange', this.formatSidebar);
window.addEventListener("hashchange", this.navigateToSectionPage, false);
this.navigateToSectionPage();
},
@@ -190,6 +194,9 @@ export default {
if (this.unlisten) {
this.unlisten();
}
window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener("hashchange", this.navigateToSectionPage);
},
updated: function () {
this.$nextTick(() => {
@@ -225,17 +232,50 @@ export default {
},
createNotebookStorageObject() {
const notebookMeta = {
identifier: this.internalDomainObject.identifier
name: this.internalDomainObject.name,
identifier: this.internalDomainObject.identifier,
link: this.getLinktoNotebook()
};
const page = this.getSelectedPage();
const section = this.getSelectedSection();
return {
notebookMeta,
section,
page
page,
section
};
},
deleteEntry(entryId) {
const self = this;
const entryPos = getEntryPosById(entryId, this.internalDomainObject, this.selectedSection, this.selectedPage);
if (entryPos === -1) {
this.openmct.notifications.alert('Warning: unable to delete entry');
console.error(`unable to delete entry ${entryId} from section ${this.selectedSection}, page ${this.selectedPage}`);
return;
}
const dialog = this.openmct.overlays.dialog({
iconClass: 'alert',
message: 'This action will permanently delete this entry. Do you wish to continue?',
buttons: [
{
label: "Ok",
emphasis: true,
callback: () => {
const entries = getNotebookEntries(self.internalDomainObject, self.selectedSection, self.selectedPage);
entries.splice(entryPos, 1);
self.updateEntries(entries);
dialog.dismiss();
}
},
{
label: "Cancel",
callback: () => dialog.dismiss()
}
]
});
},
dragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
@@ -309,6 +349,20 @@ export default {
return this.openmct.objects.get(oldNotebookStorage.notebookMeta.identifier);
},
getLinktoNotebook() {
const objectPath = this.openmct.router.path;
const link = objectLink.computed.objectLink.call({
objectPath,
openmct: this.openmct
});
const selectedSection = this.selectedSection;
const selectedPage = this.selectedPage;
const sectionId = selectedSection ? selectedSection.id : '';
const pageId = selectedPage ? selectedPage.id : '';
return `${link}?sectionId=${sectionId}&pageId=${pageId}`;
},
getPage(section, id) {
return section.pages.find(p => p.id === id);
},
@@ -393,6 +447,12 @@ export default {
return s;
});
const selectedSectionId = this.selectedSection && this.selectedSection.id;
const selectedPageId = this.selectedPage && this.selectedPage.id;
if (selectedPageId === pageId && selectedSectionId === sectionId) {
return;
}
this.sectionsChanged({ sections });
},
newEntry(embed = null) {
@@ -512,6 +572,13 @@ export default {
setDefaultNotebookSection(section);
},
updateEntry(entry) {
const entries = getNotebookEntries(this.internalDomainObject, this.selectedSection, this.selectedPage);
const entryPos = getEntryPosById(entry.id, this.internalDomainObject, this.selectedSection, this.selectedPage);
entries[entryPos] = entry;
this.updateEntries(entries);
},
updateEntries(entries) {
const configuration = this.internalDomainObject.configuration;
const notebookEntries = configuration.entries || {};

View File

@@ -33,10 +33,10 @@ import SnapshotTemplate from './snapshot-template.html';
import Vue from 'vue';
export default {
inject: ['openmct'],
components: {
PopupMenu
},
inject: ['openmct'],
props: {
embed: {
type: Object,

View File

@@ -12,11 +12,15 @@
<div class="c-ne__content">
<div :id="entry.id"
class="c-ne__text"
:class="{'c-ne__input' : !readOnly }"
tabindex="0"
:class="{ 'c-ne__input' : !readOnly }"
:contenteditable="!readOnly"
@blur="updateEntryValue($event, entry.id)"
@focus="updateCurrentEntryValue($event, entry.id)"
>{{ entry.text }}</div>
@blur="updateEntryValue($event)"
@keydown.enter.exact.prevent
@keyup.enter.exact.prevent="forceBlur($event)"
v-text="entry.text"
>
</div>
<div class="c-snapshots c-ne__embeds">
<NotebookEmbed v-for="embed in entry.embeds"
:key="embed.id"
@@ -33,6 +37,7 @@
>
<button class="c-icon-button c-icon-button--major icon-trash"
title="Delete this entry"
tabindex="-1"
@click="deleteEntry"
>
</button>
@@ -57,14 +62,14 @@
<script>
import NotebookEmbed from './NotebookEmbed.vue';
import { createNewEmbed, getEntryPosById, getNotebookEntries } from '../utils/notebook-entries';
import { createNewEmbed } from '../utils/notebook-entries';
import Moment from 'moment';
export default {
inject: ['openmct', 'snapshotContainer'],
components: {
NotebookEmbed
},
inject: ['openmct', 'snapshotContainer'],
props: {
domainObject: {
type: Object,
@@ -103,11 +108,6 @@ export default {
}
}
},
data() {
return {
currentEntryValue: ''
};
},
computed: {
createdOnDate() {
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
@@ -117,10 +117,20 @@ export default {
}
},
mounted() {
this.updateEntries = this.updateEntries.bind(this);
this.dropOnEntry = this.dropOnEntry.bind(this);
},
methods: {
addNewEmbed(objectPath) {
const bounds = this.openmct.time.bounds();
const snapshotMeta = {
bounds,
link: null,
objectPath,
openmct: this.openmct
};
const newEmbed = createNewEmbed(snapshotMeta);
this.entry.embeds.push(newEmbed);
},
cancelEditMode(event) {
const isEditing = this.openmct.editor.isEditing();
if (isEditing) {
@@ -132,63 +142,23 @@ export default {
event.dataTransfer.dropEffect = "copy";
},
deleteEntry() {
const self = this;
const entryPosById = self.entryPosById(self.entry.id);
if (entryPosById === -1) {
return;
}
const dialog = this.openmct.overlays.dialog({
iconClass: 'alert',
message: 'This action will permanently delete this entry. Do you wish to continue?',
buttons: [
{
label: "Ok",
emphasis: true,
callback: () => {
const entries = getNotebookEntries(self.domainObject, self.selectedSection, self.selectedPage);
entries.splice(entryPosById, 1);
self.updateEntries(entries);
dialog.dismiss();
}
},
{
label: "Cancel",
callback: () => {
dialog.dismiss();
}
}
]
});
this.$emit('deleteEntry', this.entry.id);
},
dropOnEntry($event) {
event.stopImmediatePropagation();
const snapshotId = $event.dataTransfer.getData('openmct/snapshot/id');
if (snapshotId.length) {
this.moveSnapshot(snapshotId);
return;
const snapshot = this.snapshotContainer.getSnapshot(snapshotId);
this.snapshotContainer.removeSnapshot(snapshotId);
this.entry.embeds.push(snapshot);
} else {
const data = $event.dataTransfer.getData('openmct/domain-object-path');
const objectPath = JSON.parse(data);
this.addNewEmbed(objectPath);
}
const data = $event.dataTransfer.getData('openmct/domain-object-path');
const objectPath = JSON.parse(data);
const entryPos = this.entryPosById(this.entry.id);
const bounds = this.openmct.time.bounds();
const snapshotMeta = {
bounds,
link: null,
objectPath,
openmct: this.openmct
};
const newEmbed = createNewEmbed(snapshotMeta);
const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
const currentEntryEmbeds = entries[entryPos].embeds;
currentEntryEmbeds.push(newEmbed);
this.updateEntries(entries);
},
entryPosById(entryId) {
return getEntryPosById(entryId, this.domainObject, this.selectedSection, this.selectedPage);
this.$emit('updateEntry', this.entry);
},
findPositionInArray(array, id) {
let position = -1;
@@ -203,15 +173,12 @@ export default {
return position;
},
forceBlur(event) {
event.target.blur();
},
formatTime(unixTime, timeFormat) {
return Moment.utc(unixTime).format(timeFormat);
},
moveSnapshot(snapshotId) {
const snapshot = this.snapshotContainer.getSnapshot(snapshotId);
this.entry.embeds.push(snapshot);
this.updateEntry(this.entry);
this.snapshotContainer.removeSnapshot(snapshotId);
},
navigateToPage() {
this.$emit('changeSectionPage', {
sectionId: this.result.section.id,
@@ -227,15 +194,8 @@ export default {
removeEmbed(id) {
const embedPosition = this.findPositionInArray(this.entry.embeds, id);
this.entry.embeds.splice(embedPosition, 1);
this.updateEntry(this.entry);
},
updateCurrentEntryValue($event) {
if (this.readOnly) {
return;
}
const target = $event.target;
this.currentEntryValue = target ? target.textContent : '';
this.$emit('updateEntry', this.entry);
},
updateEmbed(newEmbed) {
this.entry.embeds.some(e => {
@@ -247,44 +207,14 @@ export default {
return found;
});
this.updateEntry(this.entry);
this.$emit('updateEntry', this.entry);
},
updateEntry(newEntry) {
const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
entries.some(entry => {
const found = (entry.id === newEntry.id);
if (found) {
entry = newEntry;
}
return found;
});
this.updateEntries(entries);
},
updateEntryValue($event, entryId) {
if (!this.domainObject || !this.selectedSection || !this.selectedPage) {
return;
updateEntryValue($event) {
const value = $event.target.innerText;
if (value !== this.entry.text && value.match(/\S/)) {
this.entry.text = value;
this.$emit('updateEntry', this.entry);
}
const target = $event.target;
if (!target) {
return;
}
const entryPos = this.entryPosById(entryId);
const value = target.textContent.trim();
if (this.currentEntryValue !== value) {
target.textContent = value;
const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
entries[entryPos].text = value;
this.updateEntries(entries);
}
},
updateEntries(entries) {
this.$emit('updateEntries', entries);
}
}
};

View File

@@ -56,11 +56,11 @@ import { NOTEBOOK_SNAPSHOT_MAX_COUNT } from '../snapshot-container';
import { EVENT_SNAPSHOTS_UPDATED } from '../notebook-constants';
export default {
inject: ['openmct', 'snapshotContainer'],
components: {
NotebookEmbed,
PopupMenu
},
inject: ['openmct', 'snapshotContainer'],
props: {
toggleSnapshot: {
type: Function,

View File

@@ -69,14 +69,14 @@ export default {
const divElement = document.querySelector('.l-shell__drawer div');
this.component = new Vue({
provide: {
openmct,
snapshotContainer
},
el: divElement,
components: {
SnapshotContainerComponent
},
provide: {
openmct,
snapshotContainer
},
data() {
return {
toggleSnapshot

View File

@@ -22,10 +22,10 @@ import { getDefaultNotebook } from '../utils/notebook-storage';
import Page from './PageComponent.vue';
export default {
inject: ['openmct'],
components: {
Page
},
inject: ['openmct'],
props: {
defaultPageId: {
type: String,
@@ -69,7 +69,7 @@ export default {
methods: {
deletePage(id) {
const selectedSection = this.sections.find(s => s.isSelected);
const page = this.pages.find(p => p.id !== id);
const page = this.pages.find(p => p.id === id);
deleteNotebookEntries(this.openmct, this.domainObject, selectedSection, page);
const selectedPage = this.pages.find(p => p.isSelected);

View File

@@ -18,10 +18,10 @@ import PopupMenu from './PopupMenu.vue';
import RemoveDialog from '../utils/removeDialog';
export default {
inject: ['openmct'],
components: {
PopupMenu
},
inject: ['openmct'],
props: {
defaultPageId: {
type: String,

View File

@@ -21,10 +21,10 @@
import NotebookEntry from './NotebookEntry.vue';
export default {
inject: ['openmct', 'snapshotContainer'],
components: {
NotebookEntry
},
inject: ['openmct', 'snapshotContainer'],
props: {
domainObject: {
type: Object,

View File

@@ -22,10 +22,10 @@ import { getDefaultNotebook } from '../utils/notebook-storage';
import sectionComponent from './SectionComponent.vue';
export default {
inject: ['openmct'],
components: {
sectionComponent
},
inject: ['openmct'],
props: {
defaultSectionId: {
type: String,

View File

@@ -21,10 +21,10 @@ import PopupMenu from './PopupMenu.vue';
import RemoveDialog from '../utils/removeDialog';
export default {
inject: ['openmct'],
components: {
PopupMenu
},
inject: ['openmct'],
props: {
defaultSectionId: {
type: String,

View File

@@ -61,11 +61,11 @@ import PageCollection from './PageCollection.vue';
import uuid from 'uuid';
export default {
inject: ['openmct'],
components: {
SectionCollection,
PageCollection
},
inject: ['openmct'],
props: {
defaultPageId: {
type: String,

View File

@@ -88,13 +88,13 @@ export default function NotebookPlugin() {
const snapshotContainer = new SnapshotContainer(openmct);
const notebookSnapshotIndicator = new Vue ({
components: {
NotebookSnapshotIndicator
},
provide: {
openmct,
snapshotContainer
},
components: {
NotebookSnapshotIndicator
},
template: '<NotebookSnapshotIndicator></NotebookSnapshotIndicator>'
});
const indicator = {

View File

@@ -101,7 +101,7 @@ describe("Notebook plugin:", () => {
creatable: true
};
const applicableViews = openmct.objectViews.get(notebookViewObject);
const applicableViews = openmct.objectViews.get(notebookViewObject, []);
notebookViewProvider = applicableViews.find(viewProvider => viewProvider.key === notebookObject.key);
notebookView = notebookViewProvider.view(notebookViewObject);

View File

@@ -1,5 +1,5 @@
import { addNotebookEntry, createNewEmbed } from './utils/notebook-entries';
import { getDefaultNotebook } from './utils/notebook-storage';
import { getDefaultNotebook, getDefaultNotebookLink, setDefaultNotebook } from './utils/notebook-storage';
import { NOTEBOOK_DEFAULT } from '@/plugins/notebook/notebook-constants';
import SnapshotContainer from './snapshot-container';
@@ -45,12 +45,21 @@ export default class Snapshot {
_saveToDefaultNoteBook(embed) {
const notebookStorage = getDefaultNotebook();
this.openmct.objects.get(notebookStorage.notebookMeta.identifier)
.then(domainObject => {
.then(async (domainObject) => {
addNotebookEntry(this.openmct, domainObject, notebookStorage, embed);
let link = notebookStorage.notebookMeta.link;
// Backwards compatibility fix (old notebook model without link)
if (!link) {
link = await getDefaultNotebookLink(this.openmct, domainObject);
notebookStorage.notebookMeta.link = link;
setDefaultNotebook(this.openmct, notebookStorage);
}
const defaultPath = `${domainObject.name} - ${notebookStorage.section.name} - ${notebookStorage.page.name}`;
const msg = `Saved to Notebook ${defaultPath}`;
this._showNotification(msg);
this._showNotification(msg, link);
});
}
@@ -58,16 +67,29 @@ export default class Snapshot {
* @private
*/
_saveToNotebookSnapshots(embed) {
const saved = this.snapshotContainer.addSnapshot(embed);
if (!saved) {
return;
this.snapshotContainer.addSnapshot(embed);
}
_showNotification(msg, url) {
const options = {
autoDismissTimeout: 30000,
link: {
cssClass: '',
text: 'click to view',
onClick: this._navigateToNotebook(url)
}
};
this.openmct.notifications.info(msg, options);
}
_navigateToNotebook(url = null) {
if (!url) {
return () => {};
}
const msg = 'Saved to Notebook Snapshots - click to view.';
this._showNotification(msg);
}
_showNotification(msg) {
this.openmct.notifications.info(msg);
return () => {
window.location.href = window.location.origin + url;
};
}
}

View File

@@ -109,10 +109,23 @@ const selectedPage = {
};
let openmct;
let mockIdentifierService;
describe('Notebook Entries:', () => {
beforeEach(done => {
openmct = createOpenMct();
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
['parse']
);
mockIdentifierService.parse.and.returnValue({
getSpace: () => {
return '';
}
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
openmct.types.addType('notebook', {
creatable: true
});

View File

@@ -48,14 +48,29 @@ export function getDefaultNotebook() {
return JSON.parse(notebookStorage);
}
export async function getDefaultNotebookLink(openmct, domainObject = null) {
if (!domainObject) {
return null;
}
const path = await openmct.objects.getOriginalPath(domainObject.identifier)
.then(objectPath => objectPath
.map(o => o && openmct.objects.makeKeyString(o.identifier))
.reverse()
.join('/')
);
const { page, section } = getDefaultNotebook();
return `#/browse/${path}?sectionId=${section.id}&pageId=${page.id}`;
}
export function setDefaultNotebook(openmct, notebookStorage, domainObject) {
observeDefaultNotebookObject(openmct, notebookStorage.notebookMeta, domainObject);
observeDefaultNotebookObject(openmct, notebookStorage, domainObject);
saveDefaultNotebook(notebookStorage);
}
export function setDefaultNotebookSection(section) {
const notebookStorage = getDefaultNotebook();
notebookStorage.section = section;
saveDefaultNotebook(notebookStorage);
}

View File

@@ -56,11 +56,24 @@ const notebookStorage = {
}
};
let openmct = createOpenMct();
let openmct;
let mockIdentifierService;
describe('Notebook Storage:', () => {
beforeEach((done) => {
openmct = createOpenMct();
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
['parse']
);
mockIdentifierService.parse.and.returnValue({
getSpace: () => {
return '';
}
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
window.localStorage.setItem('notebook-storage', null);
openmct.objects.addProvider('', jasmine.createSpyObj('mockNotebookProvider', [
'create',

View File

@@ -27,10 +27,10 @@
import NotificationsList from './NotificationsList.vue';
export default {
inject: ['openmct'],
components: {
NotificationsList
},
inject: ['openmct'],
data() {
return {
notifications: this.openmct.notifications.notifications,

View File

@@ -25,12 +25,12 @@ import NotificationIndicator from './components/NotificationIndicator.vue';
export default function plugin() {
return function install(openmct) {
let component = new Vue ({
provide: {
openmct
},
components: {
NotificationIndicator: NotificationIndicator
},
provide: {
openmct
},
template: '<NotificationIndicator></NotificationIndicator>'
});

View File

@@ -25,20 +25,52 @@ import CouchObjectQueue from "./CouchObjectQueue";
const REV = "_rev";
const ID = "_id";
const HEARTBEAT = 50000;
export default class CouchObjectProvider {
constructor(openmct, url, namespace) {
// options {
// url: couchdb url,
// disableObserve: disable auto feed from couchdb to keep objects in sync,
// filter: selector to find objects to sync in couchdb
// }
constructor(openmct, options, namespace) {
options = this._normalize(options);
this.openmct = openmct;
this.url = url;
this.url = options.url;
this.namespace = namespace;
this.objectQueue = {};
this.observeEnabled = options.disableObserve !== true;
this.observers = {};
if (this.observeEnabled) {
this.observeObjectChanges(options.filter);
}
}
request(subPath, method, value) {
return fetch(this.url + '/' + subPath, {
method: method,
body: JSON.stringify(value)
}).then(response => response.json())
//backwards compatibility, options used to be a url. Now it's an object
_normalize(options) {
if (typeof options === 'string') {
return {
url: options
};
}
return options;
}
request(subPath, method, body, signal) {
let fetchOptions = {
method,
body,
signal
};
// stringify body if needed
if (fetchOptions.body) {
fetchOptions.body = JSON.stringify(fetchOptions.body);
}
return fetch(this.url + '/' + subPath, fetchOptions)
.then(response => response.json())
.then(function (response) {
return response;
}, function () {
@@ -98,8 +130,166 @@ export default class CouchObjectProvider {
}
}
get(identifier) {
return this.request(identifier.key, "GET").then(this.getModel.bind(this));
get(identifier, abortSignal) {
return this.request(identifier.key, "GET", undefined, abortSignal).then(this.getModel.bind(this));
}
async getObjectsByFilter(filter) {
let objects = [];
let url = `${this.url}/_find`;
let body = {};
if (filter) {
body = JSON.stringify(filter);
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body
});
const reader = response.body.getReader();
let completed = false;
let decoder = new TextDecoder("utf-8");
let decodedChunk = '';
while (!completed) {
const {done, value} = await reader.read();
//done is true when we lose connection with the provider
if (done) {
completed = true;
}
if (value) {
let chunk = new Uint8Array(value.length);
chunk.set(value, 0);
const partial = decoder.decode(chunk, {stream: !completed});
decodedChunk = decodedChunk + partial;
}
}
try {
const json = JSON.parse(decodedChunk);
if (json) {
let docs = json.docs;
docs.forEach(doc => {
let object = this.getModel(doc);
if (object) {
objects.push(object);
}
});
}
} catch (e) {
//do nothing
}
return objects;
}
observe(identifier, callback) {
if (!this.observeEnabled) {
return;
}
const keyString = this.openmct.objects.makeKeyString(identifier);
this.observers[keyString] = this.observers[keyString] || [];
this.observers[keyString].push(callback);
return () => {
this.observers[keyString] = this.observers[keyString].filter(observer => observer !== callback);
};
}
abortGetChanges() {
if (this.controller) {
this.controller.abort();
this.controller = undefined;
}
return true;
}
async observeObjectChanges(filter) {
let intermediateResponse = this.getIntermediateResponse();
if (!this.observeEnabled) {
intermediateResponse.reject('Observe for changes is disabled');
}
const controller = new AbortController();
const signal = controller.signal;
if (this.controller) {
this.abortGetChanges();
}
this.controller = controller;
// feed=continuous maintains an indefinitely open connection with a keep-alive of HEARTBEAT milliseconds until this client closes the connection
// style=main_only returns only the current winning revision of the document
let url = `${this.url}/_changes?feed=continuous&style=main_only&heartbeat=${HEARTBEAT}`;
let body = {};
if (filter) {
url = `${url}&filter=_selector`;
body = JSON.stringify(filter);
}
const response = await fetch(url, {
method: 'POST',
signal,
headers: {
"Content-Type": 'application/json'
},
body
});
const reader = response.body.getReader();
let completed = false;
while (!completed) {
const {done, value} = await reader.read();
//done is true when we lose connection with the provider
if (done) {
completed = true;
}
if (value) {
let chunk = new Uint8Array(value.length);
chunk.set(value, 0);
const decodedChunk = new TextDecoder("utf-8").decode(chunk).split('\n');
if (decodedChunk.length && decodedChunk[decodedChunk.length - 1] === '') {
decodedChunk.forEach((doc, index) => {
try {
const object = JSON.parse(doc);
object.identifier = {
namespace: this.namespace,
key: object.id
};
let keyString = this.openmct.objects.makeKeyString(object.identifier);
let observersForObject = this.observers[keyString];
if (observersForObject) {
observersForObject.forEach(async (observer) => {
const updatedObject = await this.get(object.identifier);
observer(updatedObject);
});
}
} catch (e) {
//do nothing;
}
});
}
}
}
//We're done receiving from the provider. No more chunks.
intermediateResponse.resolve(true);
return intermediateResponse.promise;
}
getIntermediateResponse() {
@@ -132,7 +322,8 @@ export default class CouchObjectProvider {
this.enqueueObject(key, model, intermediateResponse);
this.objectQueue[key].pending = true;
const queued = this.objectQueue[key].dequeue();
this.request(key, "PUT", new CouchDocument(key, queued.model)).then((response) => {
let document = new CouchDocument(key, queued.model);
this.request(key, "PUT", document).then((response) => {
this.checkResponse(response, queued.intermediateResponse);
});
@@ -143,7 +334,8 @@ export default class CouchObjectProvider {
if (!this.objectQueue[key].pending) {
this.objectQueue[key].pending = true;
const queued = this.objectQueue[key].dequeue();
this.request(key, "PUT", new CouchDocument(key, queued.model, this.objectQueue[key].rev)).then((response) => {
let document = new CouchDocument(key, queued.model, this.objectQueue[key].rev);
this.request(key, "PUT", document).then((response) => {
this.checkResponse(response, queued.intermediateResponse);
});
}

View File

@@ -24,8 +24,9 @@ import CouchObjectProvider from './CouchObjectProvider';
const NAMESPACE = '';
const PERSISTENCE_SPACE = 'mct';
export default function CouchPlugin(url) {
export default function CouchPlugin(options) {
return function install(openmct) {
openmct.objects.addProvider(PERSISTENCE_SPACE, new CouchObjectProvider(openmct, url, NAMESPACE));
install.couchProvider = new CouchObjectProvider(openmct, options, NAMESPACE);
openmct.objects.addProvider(PERSISTENCE_SPACE, install.couchProvider);
};
}

View File

@@ -32,18 +32,42 @@ describe('the plugin', () => {
let child;
let provider;
let testPath = '/test/db';
let options;
let mockIdentifierService;
let mockDomainObject;
beforeEach((done) => {
mockDomainObject = {
identifier: {
namespace: 'mct',
namespace: '',
key: 'some-value'
},
type: 'mock-type'
};
options = {
url: testPath,
filter: {},
disableObserve: true
};
openmct = createOpenMct(false);
openmct.install(new CouchPlugin(testPath));
spyOnBuiltins(['fetch'], window);
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj(
'identifierService',
['parse']
);
mockIdentifierService.parse.and.returnValue({
getSpace: () => {
return 'mct';
}
});
openmct.$injector.get.and.returnValue(mockIdentifierService);
openmct.install(new CouchPlugin(options));
openmct.types.addType('mock-type', {creatable: true});
element = document.createElement('div');
@@ -57,62 +81,67 @@ describe('the plugin', () => {
spyOn(provider, 'get').and.callThrough();
spyOn(provider, 'create').and.callThrough();
spyOn(provider, 'update').and.callThrough();
spyOnBuiltins(['fetch'], window);
fetch.and.returnValue(Promise.resolve({
json: () => {
return {
ok: true,
_id: 'some-value',
_rev: 1,
model: {}
};
}
}));
});
afterEach(() => {
return resetApplicationState(openmct);
});
it('gets an object', () => {
openmct.objects.get(mockDomainObject.identifier).then((result) => {
expect(result.identifier.key).toEqual(mockDomainObject.identifier.key);
describe('the provider', () => {
let mockPromise;
beforeEach(() => {
mockPromise = Promise.resolve({
json: () => {
return {
ok: true,
_id: 'some-value',
_rev: 1,
model: {}
};
}
});
fetch.and.returnValue(mockPromise);
});
});
it('creates an object', () => {
openmct.objects.save(mockDomainObject).then((result) => {
expect(provider.create).toHaveBeenCalled();
expect(result).toBeTrue();
});
});
it('updates an object', () => {
openmct.objects.save(mockDomainObject).then((result) => {
expect(result).toBeTrue();
expect(provider.create).toHaveBeenCalled();
openmct.objects.save(mockDomainObject).then((updatedResult) => {
expect(updatedResult).toBeTrue();
expect(provider.update).toHaveBeenCalled();
it('gets an object', () => {
openmct.objects.get(mockDomainObject.identifier).then((result) => {
expect(result.identifier.key).toEqual(mockDomainObject.identifier.key);
});
});
});
it('updates queued objects', () => {
let couchProvider = new CouchObjectProvider(openmct, 'http://localhost', '');
let intermediateResponse = couchProvider.getIntermediateResponse();
spyOn(couchProvider, 'updateQueued');
couchProvider.enqueueObject(mockDomainObject.identifier.key, mockDomainObject, intermediateResponse);
couchProvider.objectQueue[mockDomainObject.identifier.key].updateRevision(1);
couchProvider.update(mockDomainObject);
expect(couchProvider.objectQueue[mockDomainObject.identifier.key].hasNext()).toBe(2);
couchProvider.checkResponse({
ok: true,
rev: 2,
id: mockDomainObject.identifier.key
}, intermediateResponse);
it('creates an object', () => {
openmct.objects.save(mockDomainObject).then((result) => {
expect(provider.create).toHaveBeenCalled();
expect(result).toBeTrue();
});
});
expect(couchProvider.updateQueued).toHaveBeenCalledTimes(2);
it('updates an object', () => {
openmct.objects.save(mockDomainObject).then((result) => {
expect(result).toBeTrue();
expect(provider.create).toHaveBeenCalled();
openmct.objects.save(mockDomainObject).then((updatedResult) => {
expect(updatedResult).toBeTrue();
expect(provider.update).toHaveBeenCalled();
});
});
});
it('updates queued objects', () => {
let couchProvider = new CouchObjectProvider(openmct, options, '');
let intermediateResponse = couchProvider.getIntermediateResponse();
spyOn(couchProvider, 'updateQueued');
couchProvider.enqueueObject(mockDomainObject.identifier.key, mockDomainObject, intermediateResponse);
couchProvider.objectQueue[mockDomainObject.identifier.key].updateRevision(1);
couchProvider.update(mockDomainObject);
expect(couchProvider.objectQueue[mockDomainObject.identifier.key].hasNext()).toBe(2);
couchProvider.checkResponse({
ok: true,
rev: 2,
id: mockDomainObject.identifier.key
}, intermediateResponse);
expect(couchProvider.updateQueued).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,222 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 CouchDocument from "../couch/CouchDocument";
import CouchObjectQueue from "../couch/CouchObjectQueue";
import PouchDB from 'pouchdb';
const REV = "_rev";
const ID = "_id";
const HEARTBEAT = 50000;
export default class CouchObjectProvider {
// options {
// url: couchdb url,
// disableObserve: disable auto feed from couchdb to keep objects in sync,
// filter: selector to find objects to sync in couchdb
// }
constructor(openmct, options, namespace) {
options = this._normalize(options);
this.openmct = openmct;
this.url = options.url;
this.remoteCouch = options.remoteCouch;
this.namespace = namespace;
this.objectQueue = {};
this.observers = {};
this.pouchdb = new PouchDB(options.name);
this.observeChanges();
let replicateOptions = {live: true};
this.pouchdb.replicate.to(this.remoteCouch, replicateOptions, this.logError);
this.pouchdb.replicate.from(this.remoteCouch, replicateOptions, this.logError);
}
//backwards compatibility, options used to be a url. Now it's an object
_normalize(options) {
if (typeof options === 'string') {
return {
url: options
};
}
return options;
}
observeChanges() {
this.pouchdb.changes({
since: 'now',
live: true
}).on('change', this.updateObject.bind(this));
}
observe(identifier, callback) {
const keyString = this.openmct.objects.makeKeyString(identifier);
this.observers[keyString] = this.observers[keyString] || [];
this.observers[keyString].push(callback);
return () => {
this.observers[keyString] = this.observers[keyString].filter(observer => observer !== callback);
};
}
logError(error) {
console.log(error);
}
async updateObject(response) {
const object = {};
object.identifier = {
namespace: this.namespace,
key: response.id
};
let keyString = this.openmct.objects.makeKeyString(object.identifier);
let observersForObject = this.observers[keyString];
if (observersForObject) {
observersForObject.forEach(async (observer) => {
const updatedObject = await this.get(object.identifier);
observer(updatedObject);
});
}
}
// Check the response to a create/update/delete request;
// track the rev if it's valid, otherwise return false to
// indicate that the request failed.
// persist any queued objects
checkResponse(response, intermediateResponse) {
let requestSuccess = false;
const id = response ? response.id : undefined;
let rev;
if (response && response.ok) {
rev = response.rev;
requestSuccess = true;
}
intermediateResponse.resolve(requestSuccess);
if (id) {
if (!this.objectQueue[id]) {
this.objectQueue[id] = new CouchObjectQueue(undefined, rev);
}
this.objectQueue[id].updateRevision(rev);
this.objectQueue[id].pending = false;
if (this.objectQueue[id].hasNext()) {
this.updateQueued(id);
}
}
}
getModel(response) {
if (response && response.model) {
let key = response[ID];
let object = response.model;
object.identifier = {
namespace: this.namespace,
key: key
};
if (!this.objectQueue[key]) {
this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]);
}
this.objectQueue[key].updateRevision(response[REV]);
return object;
} else {
return undefined;
}
}
get(identifier, abortSignal) {
return this.pouchdb.get(identifier.key).then(this.getModel.bind(this));
}
abortGetChanges() {
if (this.controller) {
this.controller.abort();
this.controller = undefined;
}
return true;
}
getIntermediateResponse() {
let intermediateResponse = {};
intermediateResponse.promise = new Promise(function (resolve, reject) {
intermediateResponse.resolve = resolve;
intermediateResponse.reject = reject;
});
return intermediateResponse;
}
enqueueObject(key, model, intermediateResponse) {
if (this.objectQueue[key]) {
this.objectQueue[key].enqueue({
model,
intermediateResponse
});
} else {
this.objectQueue[key] = new CouchObjectQueue({
model,
intermediateResponse
});
}
}
create(model) {
let intermediateResponse = this.getIntermediateResponse();
const key = model.identifier.key;
this.enqueueObject(key, model, intermediateResponse);
this.objectQueue[key].pending = true;
const queued = this.objectQueue[key].dequeue();
let document = new CouchDocument(key, queued.model);
this.pouchdb.put(document).then((response) => {
this.checkResponse(response, queued.intermediateResponse);
});
return intermediateResponse.promise;
}
updateQueued(key) {
if (!this.objectQueue[key].pending) {
this.objectQueue[key].pending = true;
const queued = this.objectQueue[key].dequeue();
let document = new CouchDocument(key, queued.model, this.objectQueue[key].rev);
this.pouchdb.put(document).then((response) => {
this.checkResponse(response, queued.intermediateResponse);
});
}
}
update(model) {
let intermediateResponse = this.getIntermediateResponse();
const key = model.identifier.key;
this.enqueueObject(key, model, intermediateResponse);
this.updateQueued(key);
return intermediateResponse.promise;
}
}

View File

@@ -0,0 +1,34 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 PouchObjectProvider from './PouchObjectProvider';
const NAMESPACE = '';
const PERSISTENCE_SPACE = 'mct';
export default function PouchPlugin(options) {
return function install(openmct) {
options = options || {name: 'openmct', remoteCouch: 'http://127.0.0.1:5984/openmct'};
install.pouchProvider = new PouchObjectProvider(openmct, options, NAMESPACE);
openmct.objects.addProvider(PERSISTENCE_SPACE, install.pouchProvider);
};
}

483
src/plugins/plan/Plan.vue Normal file
View File

@@ -0,0 +1,483 @@
<template>
<div ref="plan"
class="c-plan c-timeline-holder"
>
<template v-if="viewBounds && !options.compact">
<swim-lane>
<template slot="label">{{ timeSystem.name }}</template>
<timeline-axis
slot="object"
:bounds="viewBounds"
:time-system="timeSystem"
:content-height="height"
:rendering-engine="renderingEngine"
/>
</swim-lane>
</template>
<div ref="planHolder"
class="c-plan__contents u-contents"
>
</div>
</div>
</template>
<script>
import * as d3Scale from 'd3-scale';
import TimelineAxis from "../../ui/components/TimeSystemAxis.vue";
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
import { getValidatedPlan } from "./util";
import Vue from "vue";
//TODO: UI direction needed for the following property values
const PADDING = 1;
const OUTER_TEXT_PADDING = 12;
const INNER_TEXT_PADDING = 17;
const TEXT_LEFT_PADDING = 5;
const ROW_PADDING = 12;
const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 25;
const LINE_HEIGHT = 12;
const MAX_TEXT_WIDTH = 300;
const EDGE_ROUNDING = 5;
const DEFAULT_COLOR = '#cc9922';
export default {
components: {
TimelineAxis,
SwimLane
},
inject: ['openmct', 'domainObject'],
props: {
options: {
type: Object,
default() {
return {
compact: false
};
}
},
renderingEngine: {
type: String,
default() {
return 'svg';
}
}
},
data() {
return {
viewBounds: undefined,
timeSystem: undefined,
height: 0
};
},
mounted() {
this.getPlanData(this.domainObject);
this.canvas = this.$refs.plan.appendChild(document.createElement('canvas'));
this.canvas.height = 0;
this.canvasContext = this.canvas.getContext('2d');
this.setDimensions();
this.updateViewBounds();
this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities);
this.openmct.time.on("bounds", this.updateViewBounds);
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges);
},
beforeDestroy() {
clearInterval(this.resizeTimer);
this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities);
this.openmct.time.off("bounds", this.updateViewBounds);
if (this.unlisten) {
this.unlisten();
}
},
methods: {
observeForChanges(mutatedObject) {
this.getPlanData(mutatedObject);
this.setScaleAndPlotActivities();
},
resize() {
let clientWidth = this.getClientWidth();
if (clientWidth !== this.width) {
this.setDimensions();
this.updateViewBounds();
}
},
getClientWidth() {
let clientWidth = this.$refs.plan.clientWidth;
if (!clientWidth) {
//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) {
clientWidth = parent.getBoundingClientRect().width;
}
}
return clientWidth - 200;
},
getPlanData(domainObject) {
this.planData = getValidatedPlan(domainObject);
},
updateViewBounds() {
this.viewBounds = this.openmct.time.bounds();
if (this.timeSystem === undefined) {
this.timeSystem = this.openmct.time.timeSystem();
}
this.setScaleAndPlotActivities();
},
setScaleAndPlotActivities(timeSystem) {
if (timeSystem !== undefined) {
this.timeSystem = timeSystem;
}
this.setScale(this.timeSystem);
this.clearPreviousActivities();
if (this.xScale) {
this.calculatePlanLayout();
this.drawPlan();
}
},
clearPreviousActivities() {
let activities = this.$el.querySelectorAll(".c-plan__contents > div");
activities.forEach(activity => activity.remove());
},
setDimensions() {
const planHolder = this.$refs.plan;
this.width = this.getClientWidth();
this.height = Math.round(planHolder.getBoundingClientRect().height);
},
setScale(timeSystem) {
if (!this.width) {
return;
}
if (timeSystem === undefined) {
timeSystem = this.openmct.time.timeSystem();
}
if (timeSystem.isUTCBased) {
this.xScale = d3Scale.scaleUtc();
this.xScale.domain(
[new Date(this.viewBounds.start), new Date(this.viewBounds.end)]
);
} else {
this.xScale = d3Scale.scaleLinear();
this.xScale.domain(
[this.viewBounds.start, this.viewBounds.end]
);
}
this.xScale.range([PADDING, this.width - PADDING * 2]);
},
isActivityInBounds(activity) {
return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start);
},
getTextWidth(name) {
let metrics = this.canvasContext.measureText(name);
return parseInt(metrics.width, 10);
},
sortFn(a, b) {
const numA = parseInt(a, 10);
const numB = parseInt(b, 10);
if (numA > numB) {
return 1;
}
if (numA < numB) {
return -1;
}
return 0;
},
// Get the row where the next activity will land.
getRowForActivity(rectX, width, activitiesByRow) {
let currentRow;
let sortedActivityRows = Object.keys(activitiesByRow).sort(this.sortFn);
function getOverlap(rects) {
return rects.every(rect => {
const { start, end } = rect;
const calculatedEnd = rectX + width;
const hasOverlap = (rectX >= start && rectX <= end) || (calculatedEnd >= start && calculatedEnd <= end) || (rectX <= start && calculatedEnd >= end);
return !hasOverlap;
});
}
for (let i = 0; i < sortedActivityRows.length; i++) {
let row = sortedActivityRows[i];
if (getOverlap(activitiesByRow[row])) {
currentRow = row;
break;
}
}
if (currentRow === undefined && sortedActivityRows.length) {
let row = parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10);
currentRow = row + ROW_HEIGHT + ROW_PADDING;
}
return (currentRow || 0);
},
calculatePlanLayout() {
let groups = Object.keys(this.planData);
this.groupActivities = {};
groups.forEach((key, index) => {
let activitiesByRow = {};
let currentRow = 0;
let activities = this.planData[key];
activities.forEach((activity) => {
if (this.isActivityInBounds(activity)) {
const currentStart = Math.max(this.viewBounds.start, activity.start);
const currentEnd = Math.min(this.viewBounds.end, activity.end);
const rectX = this.xScale(currentStart);
const rectY = this.xScale(currentEnd);
const rectWidth = rectY - rectX;
const activityNameWidth = this.getTextWidth(activity.name) + TEXT_LEFT_PADDING;
//TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text
const activityNameFitsRect = (rectWidth >= activityNameWidth);
const textStart = (activityNameFitsRect ? rectX : rectY) + TEXT_LEFT_PADDING;
const color = activity.color || DEFAULT_COLOR;
let textColor = '';
if (activity.textColor) {
textColor = activity.textColor;
} else if (activityNameFitsRect) {
textColor = this.getContrastingColor(color);
}
let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect);
const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING;
if (activityNameFitsRect) {
currentRow = this.getRowForActivity(rectX, rectWidth, activitiesByRow);
} else {
currentRow = this.getRowForActivity(rectX, textWidth, activitiesByRow);
}
let textY = parseInt(currentRow, 10) + (activityNameFitsRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING);
if (!activitiesByRow[currentRow]) {
activitiesByRow[currentRow] = [];
}
activitiesByRow[currentRow].push({
activity: {
color: color,
textColor: textColor,
name: activity.name,
exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(activity.start),
end: this.xScale(this.viewBounds.end) < this.xScale(activity.end)
}
},
textLines: textLines,
textStart: textStart,
textClass: activityNameFitsRect ? "" : "activity-label--outside-rect",
textY: textY,
start: rectX,
end: activityNameFitsRect ? rectY : textStart + textWidth,
rectWidth: rectWidth
});
}
});
this.groupActivities[key] = {
heading: key,
activitiesByRow
};
});
},
getActivityDisplayText(context, text, activityNameFitsRect) {
//TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen)
let words = text.split(' ');
let line = '';
let activityText = [];
let rows = 1;
for (let n = 0; (n < words.length) && (rows <= 2); n++) {
let testLine = line + words[n] + ' ';
let metrics = context.measureText(testLine);
let testWidth = metrics.width;
if (!activityNameFitsRect && (testWidth > MAX_TEXT_WIDTH && n > 0)) {
activityText.push(line);
line = words[n] + ' ';
testLine = line + words[n] + ' ';
rows = rows + 1;
}
line = testLine;
}
return activityText.length ? activityText : [line];
},
getGroupContainer(activityRows, heading) {
let svgHeight = 30;
let svgWidth = 200;
const rows = Object.keys(activityRows);
const isNested = this.options.isChildObject;
if (rows.length) {
const lastActivityRow = rows[rows.length - 1];
svgHeight = parseInt(lastActivityRow, 10) + ROW_HEIGHT;
svgWidth = this.width;
}
let component = new Vue({
components: {
SwimLane
},
data() {
return {
heading,
isNested,
height: svgHeight,
width: svgWidth
};
},
template: `<swim-lane :is-nested="isNested"><template slot="label">{{heading}}</template><template slot="object"><svg :height="height" :width="width"></svg></template></swim-lane>`
});
this.$refs.planHolder.appendChild(component.$mount().$el);
let groupLabel = component.$el.querySelector('div:nth-child(1)');
let groupSVG = component.$el.querySelector('svg');
return {
groupLabel,
groupSVG
};
},
drawPlan() {
Object.keys(this.groupActivities).forEach((group, index) => {
const activitiesByRow = this.groupActivities[group].activitiesByRow;
const heading = this.groupActivities[group].heading;
const groupElements = this.getGroupContainer(activitiesByRow, heading);
let groupSVG = groupElements.groupSVG;
let activityRows = Object.keys(activitiesByRow);
if (activityRows.length <= 0) {
this.plotNoItems(groupSVG);
}
activityRows.forEach((row) => {
const items = activitiesByRow[row];
items.forEach(item => {
//TODO: Don't draw the left-border of the rectangle if the activity started before viewBounds.start
this.plotActivity(item, parseInt(row, 10), groupSVG);
});
});
});
},
plotNoItems(svgElement) {
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.setNSAttributesForElement(textElement, {
x: "10",
y: "20",
class: "activity-label--outside-rect"
});
textElement.innerHTML = 'No activities within timeframe';
svgElement.appendChild(textElement);
},
setNSAttributesForElement(element, attributes) {
Object.keys(attributes).forEach((key) => {
element.setAttributeNS(null, key, attributes[key]);
});
},
// Experimental for now - unused
addForeignElement(svgElement, label, x, y) {
let foreign = document.createElementNS('http://www.w3.org/2000/svg', "foreignObject");
this.setNSAttributesForElement(foreign, {
width: String(MAX_TEXT_WIDTH),
height: String(LINE_HEIGHT * 2),
x: x,
y: y
});
let textEl = document.createElement('div');
let textNode = document.createTextNode(label);
textEl.appendChild(textNode);
foreign.appendChild(textEl);
svgElement.appendChild(foreign);
},
plotActivity(item, row, svgElement) {
const activity = item.activity;
let width = item.rectWidth;
let rectElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
if (item.activity.exceeds.start) {
width = width + EDGE_ROUNDING;
}
if (item.activity.exceeds.end) {
width = width + EDGE_ROUNDING;
}
width = Math.max(width, 1); // Set width to a minimum of 1
// rx: don't round corners if the width of the rect is smaller than the rounding radius
this.setNSAttributesForElement(rectElement, {
class: 'activity-bounds',
x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start,
y: row,
rx: (width < EDGE_ROUNDING * 2) ? 0 : EDGE_ROUNDING,
width: width,
height: String(ROW_HEIGHT),
fill: activity.color
});
svgElement.appendChild(rectElement);
item.textLines.forEach((line, index) => {
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.setNSAttributesForElement(textElement, {
class: `activity-label ${item.textClass}`,
x: item.textStart,
y: item.textY + (index * LINE_HEIGHT),
fill: activity.textColor
});
const textNode = document.createTextNode(line);
textElement.appendChild(textNode);
svgElement.appendChild(textElement);
});
// this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT);
},
cutHex(h, start, end) {
const hStr = (h.charAt(0) === '#') ? h.substring(1, 7) : h;
return parseInt(hStr.substring(start, end), 16);
},
getContrastingColor(hexColor) {
// https://codepen.io/davidhalford/pen/ywEva/
// TODO: move this into a general utility function?
const cThreshold = 130;
if (hexColor.indexOf('#') === -1) {
// We weren't given a hex color
return "#ff0000";
}
const hR = this.cutHex(hexColor, 0, 2);
const hG = this.cutHex(hexColor, 2, 4);
const hB = this.cutHex(hexColor, 4, 6);
const cBrightness = ((hR * 299) + (hG * 587) + (hB * 114)) / 1000;
return cBrightness > cThreshold ? "#000000" : "#ffffff";
}
}
};
</script>

View File

@@ -0,0 +1,77 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2020, 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 Plan from './Plan.vue';
import Vue from 'vue';
export default function PlanViewProvider(openmct) {
function isCompactView(objectPath) {
return objectPath.find(object => object.type === 'time-strip') !== undefined;
}
return {
key: 'plan.view',
name: 'Plan',
cssClass: 'icon-calendar',
canView(domainObject) {
return domainObject.type === 'plan';
},
canEdit(domainObject) {
return domainObject.type === 'plan';
},
view: function (domainObject, objectPath) {
let component;
return {
show: function (element) {
let isCompact = isCompactView(objectPath);
component = new Vue({
el: element,
components: {
Plan
},
provide: {
openmct,
domainObject
},
data() {
return {
options: {
compact: isCompact,
isChildObject: isCompact
}
};
},
template: '<plan :options="options"></plan>'
});
},
destroy: function () {
component.$destroy();
component = undefined;
}
};
}
};
}

View File

@@ -0,0 +1,19 @@
.c-plan {
svg {
text-rendering: geometricPrecision;
text {
stroke: none;
}
.activity-label {
&--outside-rect {
fill: $colorBodyFg !important;
}
}
}
canvas {
display: none;
}
}

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