Compare commits

...

46 Commits

Author SHA1 Message Date
84d8d8b0e8 Merge branch 'master' of https://github.com/nasa/openmct into conditionset-errors 2025-04-28 11:26:59 -07:00
573bbb041e Fix partial matches (#8047)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Support character escaping
2025-04-24 16:41:12 +00:00
6a450a0e89 Couch search indexes (#8037)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
* Defined search index for object names. Add index for searching by object type
* Feature detect if views are defined to support optimized search. If not, fall back on filter-based search
* Suppress github codedcov annotations for now, they are not accurate and generate noise.
* Allow nested describes. They're good.
* Add a noop search function to couch search folder object provider. Actual search is provided by Couch provider, but need a stub to prevent in-memory indexing
* Adhere to our own interface and ensure identifiers are always returned by default composition provider
2025-04-23 11:58:51 -07:00
e5631c9f6c Marked sanitize html (#8021)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
* chore(deps-dev): bump sanitize-html from 2.12.1 to 2.15.0

Bumps [sanitize-html](https://github.com/apostrophecms/sanitize-html) from 2.12.1 to 2.15.0.
- [Changelog](https://github.com/apostrophecms/sanitize-html/blob/main/CHANGELOG.md)
- [Commits](https://github.com/apostrophecms/sanitize-html/compare/2.12.1...2.15.0)

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

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

* bump marked

* Added package lock:

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 09:03:23 -07:00
15b5d1405d chore(deps-dev): bump webpack-merge from 5.10.0 to 6.0.1 (#8017)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Bumps [webpack-merge](https://github.com/survivejs/webpack-merge) from 5.10.0 to 6.0.1.
- [Changelog](https://github.com/survivejs/webpack-merge/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/survivejs/webpack-merge/compare/v5.10.0...v6.0.1)

---
updated-dependencies:
- dependency-name: webpack-merge
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2025-04-01 15:52:06 -07:00
0377788533 chore(deps-dev): bump uuid from 9.0.1 to 11.1.0 (#8018)
Bumps [uuid](https://github.com/uuidjs/uuid) from 9.0.1 to 11.1.0.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v9.0.1...v11.1.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2025-04-01 15:27:29 -07:00
28dcff7e89 chore(deps-dev): bump copy-webpack-plugin from 12.0.2 to 13.0.0 (#8015)
Bumps [copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin) from 12.0.2 to 13.0.0.
- [Release notes](https://github.com/webpack-contrib/copy-webpack-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/copy-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/copy-webpack-plugin/compare/v12.0.2...v13.0.0)

---
updated-dependencies:
- dependency-name: copy-webpack-plugin
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2025-04-01 14:30:27 -07:00
b191eb9d64 chore(deps-dev): bump npm-run-all2 from 6.1.2 to 7.0.2 (#8014)
Bumps [npm-run-all2](https://github.com/bcomnes/npm-run-all2) from 6.1.2 to 7.0.2.
- [Release notes](https://github.com/bcomnes/npm-run-all2/releases)
- [Changelog](https://github.com/bcomnes/npm-run-all2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bcomnes/npm-run-all2/compare/v6.1.2...v7.0.2)

---
updated-dependencies:
- dependency-name: npm-run-all2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2025-04-01 13:39:07 -07:00
f8e4aba922 Update all remaining dependencies in one go (#8011)
* Update all remaining dependencies in one go

* Support changed marked API

* Adapt to use new library

* Removed commented code

* Fixed typo in anchor tag
2025-04-01 13:21:13 -07:00
28f6987dd7 chore(deps-dev): bump jasmine-core from 5.1.1 to 5.5.0 (#7952)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Bumps [jasmine-core](https://github.com/jasmine/jasmine) from 5.1.1 to 5.5.0.
- [Release notes](https://github.com/jasmine/jasmine/releases)
- [Changelog](https://github.com/jasmine/jasmine/blob/main/RELEASE.md)
- [Commits](https://github.com/jasmine/jasmine/compare/v5.1.1...v5.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 16:19:20 -07:00
f3047093d6 chore(deps): bump codecov/codecov-action from 4 to 5 (#7931)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 16:01:58 -07:00
34e57ef300 chore(deps-dev): bump nyc from 15.1.0 to 17.1.0 (#7852)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Bumps [nyc](https://github.com/istanbuljs/nyc) from 15.1.0 to 17.1.0.
- [Release notes](https://github.com/istanbuljs/nyc/releases)
- [Changelog](https://github.com/istanbuljs/nyc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/istanbuljs/nyc/compare/v15.1.0...nyc-v17.1.0)

---
updated-dependencies:
- dependency-name: nyc
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 15:26:34 -07:00
88e6557782 chore(deps-dev): bump babel-plugin-istanbul from 6.1.1 to 7.0.0 (#7804)
Bumps [babel-plugin-istanbul](https://github.com/istanbuljs/babel-plugin-istanbul) from 6.1.1 to 7.0.0.
- [Release notes](https://github.com/istanbuljs/babel-plugin-istanbul/releases)
- [Changelog](https://github.com/istanbuljs/babel-plugin-istanbul/blob/master/CHANGELOG.md)
- [Commits](https://github.com/istanbuljs/babel-plugin-istanbul/compare/v6.1.1...v7.0.0)

---
updated-dependencies:
- dependency-name: babel-plugin-istanbul
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 15:14:17 -07:00
dbd4abebae chore(deps-dev): bump style-loader from 3.3.3 to 4.0.0 (#7802)
Bumps [style-loader](https://github.com/webpack-contrib/style-loader) from 3.3.3 to 4.0.0.
- [Release notes](https://github.com/webpack-contrib/style-loader/releases)
- [Changelog](https://github.com/webpack-contrib/style-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/style-loader/compare/v3.3.3...v4.0.0)

---
updated-dependencies:
- dependency-name: style-loader
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 15:03:21 -07:00
50ca27e54f chore(deps-dev): bump d3-shape and @types/d3-shape (#7605)
* chore(deps-dev): bump d3-shape and @types/d3-shape

Bumps [d3-shape](https://github.com/d3/d3-shape) and [@types/d3-shape](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/d3-shape). These dependencies needed to be updated together.

Updates `d3-shape` from 3.0.0 to 3.2.0
- [Release notes](https://github.com/d3/d3-shape/releases)
- [Commits](https://github.com/d3/d3-shape/compare/v3.0.0...v3.2.0)

Updates `@types/d3-shape` from 3.0.0 to 3.1.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/d3-shape)

---
updated-dependencies:
- dependency-name: d3-shape
  dependency-type: direct:development
  update-type: version-update:semver-minor
- dependency-name: "@types/d3-shape"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* chore(deps-dev): bump d3-shape and @types/d3-shape

Bumps [d3-shape](https://github.com/d3/d3-shape) and [@types/d3-shape](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/d3-shape). These dependencies needed to be updated together.

Updates `d3-shape` from 3.0.0 to 3.2.0
- [Release notes](https://github.com/d3/d3-shape/releases)
- [Commits](https://github.com/d3/d3-shape/compare/v3.0.0...v3.2.0)

Updates `@types/d3-shape` from 3.0.0 to 3.1.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/d3-shape)

---
updated-dependencies:
- dependency-name: d3-shape
  dependency-type: direct:development
  update-type: version-update:semver-minor
- dependency-name: "@types/d3-shape"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* Dependency upgrade changed rendering of SVG. Visually verified that it is correct and updated the test

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2025-03-28 14:25:25 -07:00
2955092c86 chore(deps-dev): bump mini-css-extract-plugin from 2.7.6 to 2.8.1 (#7573)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 2.7.6 to 2.8.1.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v2.7.6...v2.8.1)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 13:51:10 -07:00
28b5d7c41c Time strip marcus banes line "now line" fix for right y-axis and when now is out of bounds (#7993)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
* Account for right y-axes when calculating now line position.
Don't show the now line if it's out of bounds of the time axis

* Add test for now marker in realtime and out of bounds modes
2025-02-17 18:23:48 +00:00
ecd120387c Independent time conductor related handling for plot synchronization. (#7956)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
* Ensure that the mode set when independent time conductor is enabled/disabled is propagated correctly.
Also ensure that global time conductor changes are not picked up by the independent time conductor when the user has enabled it at least once before

* Use structuredClone instead of deep copy

* Add e2e test

* Assert that you're in fixed mode after sync time conductor

* Comment explaining new time context test

* Change test to be a little less complicated

* Fix linting errors
2025-02-10 21:46:00 +00:00
a6517bb33e migrate from actions/upload-artifact: v3 to v4. (#8000)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
* migrate from actions/upload-artifact: v3 to v4.
https://github.com/actions/upload-artifact/blob/main/docs/MIGRATION.md

* Add names for artifacts and allow overwriting them
2025-02-07 18:03:00 +00:00
0d675c13c7 handle no data 2025-01-31 15:58:42 -08:00
bc4893f70b debug 2025-01-31 15:54:03 -08:00
9dca981130 debug 2025-01-31 15:38:25 -08:00
19a77b37dd initial debug 2025-01-31 15:33:41 -08:00
1fde0d9e38 Don't disallow mouse events when in compact mode for plots (#7975)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
e2e-couchdb / e2e-couchdb (push) Has been cancelled
e2e-perf / e2e-full (push) Has been cancelled
e2e-pr / e2e-full (ubuntu-latest) (push) Has been cancelled
e2e-pr / e2e-full (windows-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/hydrogen, macos-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/hydrogen, ubuntu-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/hydrogen, windows-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/iron, macos-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/iron, ubuntu-latest) (push) Has been cancelled
pr-platform / Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} (x64, lts/iron, windows-latest) (push) Has been cancelled
pr-platform / Node lts/hydrogen - x64 on macos-latest (push) Has been cancelled
pr-platform / Node lts/hydrogen - x64 on ubuntu-latest (push) Has been cancelled
pr-platform / Node lts/hydrogen - x64 on windows-latest (push) Has been cancelled
pr-platform / Node lts/iron - x64 on macos-latest (push) Has been cancelled
pr-platform / Node lts/iron - x64 on ubuntu-latest (push) Has been cancelled
pr-platform / Node lts/iron - x64 on windows-latest (push) Has been cancelled
* Allow highlights and locking highlight points for plots in compact mode, but still disallow pan and zoom.

* Remove unnecessary watch on cursor guides and grid lines

* Test for cursor guides in compact mode
2025-01-18 15:50:24 +00:00
5be103ea72 modified the sanitizeForSerialization method to remove unnecessary re… (#7950)
modified the sanitizeForSerialization method to remove unnecessary recursion, update e2e test to CORRECTLY test the functionality
2024-12-09 20:34:07 +00:00
d74e1b19b6 In progress activities that are out of bounds are shown (#7945)
If an activity is out of bounds, but in progress, display it in the currently visible list.
2024-12-06 22:38:27 +00:00
5bb6a18cd4 [Notebook] Browse Bar holding onto stale model, reverts changes (#7944)
* moving rename methods to appActions

* importing back into original test

* reverting

* add the ability to change the name in the browse bar

* add test to verify entries are not being lost

* addding aria labels for tests

* when an object is changed, store the whole new object, not just the name

* typo!
2024-12-06 14:13:08 -08:00
14b947c101 fix vue reactivity of rows by changing the reference of the updated row (#7940)
* do not call `updateVisibleRows` on horizontal scroll
* add example provider for in place row updates
2024-12-04 11:27:52 -08:00
61b982ab99 [Telemetry API] Prevent Subscriptions with different options from overwriting each other (#7930)
* initial implementation

* cleaning up a bit

* adding the hash method back as we dont want gigantic keys

* adding a line

* added filtering to state generator, updated filters readme to fix error, more robust hash function

* removing unnecessary changes in wrong file

* adding a test to confirm each endpoint has a separate subscription based of filtering

* lint

* adding back in hints, accidentally removed

* remove some redundant code and convert sanitization method into a replacer function for stringify

* tweaking serialize replacer to handle arrays correctly, adding more determinative row addition check to test

* more focused selector for the table

* simplified the serialization method even further and added some more docs
2024-12-04 03:33:15 +00:00
ba4d8a428b [Gauge Plugin] Fix Missing Object handling (#7923)
* checking if the metadata exists before acting on it

* added a test to catch missing object errors in gauges

* remove waitForTimeout and add in check for time conductor successful start offset update

* hardening the test by checking for the time before the time change

* add "pageerror" to cspell
2024-12-03 15:13:51 -08:00
ea9947cab5 Use the disabled attribute on a valid element - the button. (#7914)
* Use the disabled attribute on a valid tag - the button.

* Add e2e test to check for add criteria button being enabled

* Improve test

* Check for add criteria button to have attribute disabled

* Remove focused test
2024-11-05 20:53:28 +00:00
2010f2e377 chore: bump @playwright/test to v1.48.1 (#7913) 2024-10-21 17:03:54 -07:00
3241e9ba57 chore: remove release.yml (#7907) 2024-10-18 15:43:57 -07:00
057a5f997c Encode urls for css background images (#7906)
* Add new utility to encode urls.
Use the encode urls utility to encode all background images in css

* need a commit to pull exampleimagery from

* skip and fix on otherside

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-10-18 19:59:02 +00:00
078cd341a5 Bump references to support node22 (#7901)
* Bump references to support node22

* strings!

---------

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2024-10-18 10:07:26 -07:00
518b55cf0f fix(vipergc-660): identify axis keys upon adding object to composition (#7897)
* fix: identify axis keys upon adding object to composition

* fix: set yKey to 'none' if nonArrayValues
2024-10-17 15:08:52 -07:00
3e23dceb64 fix(#7892): restore "now" (marcus bains) line to planning views (#7898)
* Initialize alignment offset to 0. (it was undefined). Also handle a small bug with swimlane configuration not getting replaced when the plan used by a gantt chart was changed

* lint: fix

* test: update visual tests to mock clock and show now line

---------

Co-authored-by: Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC] <jesse.d.mazzella@nasa.gov>
2024-10-17 14:24:26 -07:00
7f8b5e09e5 Fix gantt chart swimlane order (#7895)
* Use the planObject to get ordered swimlane names

* If there is no plan object, don't try to render the chart

---------

Co-authored-by: Jamie V. <jamie.j.vigliotta@nasa.gov>
2024-10-17 17:28:55 +00:00
7c2bb16bfd Bugfix/7873 time conductor input validation (#7886)
* validate on change because input is too aggressive
* validate logical bounds on submit
* perfection
2024-10-16 18:57:56 -07:00
890ddcac4e Revert d8c5095ebb (#7894) 2024-10-16 09:57:24 -07:00
d8c5095ebb add environment variable check 2024-10-16 09:08:34 -07:00
ccf7ed91af fix(vipergc-574): Use selected shelve duration for fault management (#7890)
* refactor: `Indefinite` -> `Unlimited`
* refactor: remove unused const
* fix: use the selected shelveDuration
* fix: set and use default if none selected
* fix: bad optional chaining
* fix: handle the case of no provider
* fix: don't assign defaults if no provider
2024-10-15 23:26:57 +00:00
2b8673941a Don't persist current tab when display is locked (#7882)
Check if a display is locked before saving current tab. 

---------

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-11 16:17:29 -07:00
703186adf1 Add script to lock object sub-tree and fix object locking bugs (#7855)
* Script for locking an object tree

* Show lock button if locked

* Do not allow properties editing of locked objects

* Remove package-lock.json

* Added p-debounce

* Allow duplication of locked objects

* Better user feedback

* Add semaphores to prevent file handle exhaustion

* Leverage official Apache Couch library - nano. Clean up dependencies. Default to environment variables for couch config. Simplify batching mechanism to make it synchronouse

* Added lock user attribution

* Remove unused code

* Modify open script for adding auth design doc

* Added script for creating auth design doc

* Add css class for disallow unlock

* Add user attribution to lock button

* Fix import

* Typo

* User it was locked by, not current user. Wow.

* Closes #7877
- Front-end sanding and shimming: displays <span> instead of button when domainObject.disallowUnlock.

* Fixed bug where lock is shown even if object is not locked

---------

Co-authored-by: Charles Hacskaylo <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2024-10-10 15:09:39 -07:00
c43ef64733 [Telemetry Tables] Fix sort issues (#7875)
* Issue where immutable objects sort order was not being set correctly in telemetry tables. 
* Configuration couldn't be saved and the sort order was not being saved in memory. 
* Telemetry was not being re-requested when the sort order of a table was changed and the table was in performance (limited) mode.
* We've moved sort order to both configuration and in-memory and we will re-request telemetry if changing sort order in performance mode.

---------

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-10 14:24:31 -07:00
f4cf9c756b test(visual): Stabilize compass per timestamp (#7866)
* feat: add function to generate a seeded random value

* fix: produce the same compass orientation per timestamp
- helps for consistent visual tests

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-10-10 10:52:42 -07:00
90 changed files with 2905 additions and 1227 deletions

View File

@ -5,7 +5,7 @@ orbs:
executors: executors:
pw-focal-development: pw-focal-development:
docker: docker:
- image: mcr.microsoft.com/playwright:v1.47.2-focal - image: mcr.microsoft.com/playwright:v1.48.1-focal
environment: environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
@ -198,7 +198,7 @@ jobs:
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen node-version: lts/hydrogen
- run: npx playwright@1.47.2 install #Necessary for bare ubuntu machine - run: npx playwright@1.48.1 install #Necessary for bare ubuntu machine
- run: | - run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs) export $(cat src/plugins/persistence/couch/.env.ci | xargs)
docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach
@ -286,8 +286,8 @@ workflows:
overall-circleci-commit-status: #These jobs run on every commit overall-circleci-commit-status: #These jobs run on every commit
jobs: jobs:
- lint: - lint:
name: node20-lint name: node22-lint
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen
@ -304,8 +304,8 @@ workflows:
the-nightly: #These jobs do not run on PRs, but against master at night the-nightly: #These jobs do not run on PRs, but against master at night
jobs: jobs:
- unit-test: - unit-test:
name: node20-chrome-nightly name: node22-chrome-nightly
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen

View File

@ -483,7 +483,8 @@
"countup", "countup",
"darkmatter", "darkmatter",
"Undeletes", "Undeletes",
"SSSZ" "SSSZ",
"pageerror"
], ],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
"ignorePaths": [ "ignorePaths": [

View File

@ -37,7 +37,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: npx playwright@1.47.2 install - run: npx playwright@1.48.1 install
- name: Start CouchDB Docker Container and Init with Setup Scripts - name: Start CouchDB Docker Container and Init with Setup Scripts
run: | run: |
@ -56,7 +56,7 @@ jobs:
run: npm run cov:e2e:report run: npm run cov:e2e:report
- name: Publish Results to Codecov.io - name: Publish Results to Codecov.io
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v5
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e/lcov.info files: ./coverage/e2e/lcov.info
@ -66,15 +66,19 @@ jobs:
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-test-results
path: test-results path: test-results
overwrite: true
- name: Archive html test results - name: Archive html test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-html-test-results
path: html-test-results path: html-test-results
overwrite: true
- name: Remove pr:e2e:couchdb label (if present) - name: Remove pr:e2e:couchdb label (if present)
if: always() if: always()

View File

@ -30,7 +30,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npx playwright@1.47.2 install - run: npx playwright@1.48.1 install
- run: npm ci --no-audit --progress=false - run: npm ci --no-audit --progress=false
- name: Run E2E Tests (Repeated 10 Times) - name: Run E2E Tests (Repeated 10 Times)
@ -38,9 +38,11 @@ jobs:
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-flakefinder-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:flakefinder label (if present) - name: Remove pr:e2e:flakefinder label (if present)
if: always() if: always()

View File

@ -28,16 +28,18 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npx playwright@1.47.2 install - run: npx playwright@1.48.1 install
- run: npm ci --no-audit --progress=false - run: npm ci --no-audit --progress=false
- run: npm run test:perf:localhost - run: npm run test:perf:localhost
- run: npm run test:perf:contract - run: npm run test:perf:contract
- run: npm run test:perf:memory - run: npm run test:perf:memory
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-perf-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:perf label (if present) - name: Remove pr:e2e:perf label (if present)
if: always() if: always()

View File

@ -45,9 +45,11 @@ jobs:
npm run cov:e2e:full:publish npm run cov:e2e:full:publish
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-pr-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e label (if present) - name: Remove pr:e2e label (if present)
if: always() if: always()

View File

@ -1,116 +0,0 @@
# GitHub Actions Workflow for Automated Releases
name: Automated Release Workflow
on:
schedule:
# Nightly builds at 6 PM PST every day
- cron: '0 2 * * *'
release:
types:
- created
- published
jobs:
nightly-build:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
name: Nightly Build and Release
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/iron' # Specify your Node.js version
registry-url: 'https://registry.npmjs.org/'
- name: Install Dependencies
run: npm ci
- name: Bump Version for Nightly
id: bump_version
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
DATE=$(date +%Y%m%d)
NIGHTLY_VERSION=$(echo $PACKAGE_VERSION | awk -F. -v OFS=. '{$NF+=1; print}')-nightly-$DATE
echo "NIGHTLY_VERSION=${NIGHTLY_VERSION}" >> $GITHUB_ENV
- name: Update package.json
run: |
npm version $NIGHTLY_VERSION --no-git-tag-version
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
git commit -m "chore: bump version to $NIGHTLY_VERSION for nightly build"
- name: Push Changes
uses: ad-m/github-push-action@v0.6.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
- name: Build Project
run: npm run build:prod
- name: Publish Nightly to NPM
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
npm publish --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
prerelease-build:
if: github.event.release.prerelease == true
runs-on: ubuntu-latest
name: Pre-release (Beta) Build and Publish
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: '16' # Specify your Node.js version
registry-url: 'https://registry.npmjs.org/'
- name: Install Dependencies
run: npm ci
- name: Build Project
run: npm run build:prod
- name: Publish Beta to NPM
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
stable-release-build:
if: github.event.release.prerelease == false
runs-on: ubuntu-latest
name: Stable Release Build and Publish
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: '16' # Specify your Node.js version
registry-url: 'https://registry.npmjs.org/'
- name: Install Dependencies
run: npm ci
- name: Build Project
run: npm run build:prod
- name: Publish to NPM
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,6 +1,10 @@
codecov: codecov:
require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass
# Disabling annotations for now. They are incorrectly labelling lines as lacking coverage when they are in fact covered by tests.
github_checks:
annotations: false
coverage: coverage:
status: status:
project: project:

View File

@ -2,7 +2,6 @@
module.exports = { module.exports = {
extends: ['plugin:playwright/recommended'], extends: ['plugin:playwright/recommended'],
rules: { rules: {
'playwright/max-nested-describe': ['error', { max: 1 }],
'playwright/expect-expect': 'off' 'playwright/expect-expect': 'off'
}, },
overrides: [ overrides: [

View File

@ -510,6 +510,10 @@ async function setTimeConductorBounds(page, { submitChanges = true, ...bounds })
// Open the time conductor popup // Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
if (startDate) { if (startDate) {
await page.getByLabel('Start date').fill(startDate); await page.getByLabel('Start date').fill(startDate);
} }
@ -678,6 +682,21 @@ async function linkParameterToObject(page, parameterName, objectName) {
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
} }
/**
* Rename the currently viewed `domainObject` from the browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function renameCurrentObjectFromBrowseBar(page, newName) {
const nameInput = page.getByLabel('Browse bar object name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(newName);
// Click the browse bar container to save changes
await page.getByLabel('Browse bar', { exact: true }).click();
}
export { export {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject, createExampleTelemetryObject,
@ -689,6 +708,7 @@ export {
linkParameterToObject, linkParameterToObject,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime, navigateToObjectWithRealTime,
renameCurrentObjectFromBrowseBar,
setEndOffset, setEndOffset,
setFixedIndependentTimeConductorBounds, setFixedIndependentTimeConductorBounds,
setFixedTimeMode, setFixedTimeMode,

View File

@ -103,25 +103,40 @@ const extendedTest = test.extend({
* Default: `true` * Default: `true`
*/ */
failOnConsoleError: [true, { option: true }], failOnConsoleError: [true, { option: true }],
ignore404s: [[], { option: true }],
/** /**
* Extends the base page class to enable console log error detection. * Extends the base page class to enable console log error detection.
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion} * @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
*/ */
page: async ({ page, failOnConsoleError }, use) => { page: async ({ page, failOnConsoleError, ignore404s }, use) => {
// Capture any console errors during test execution // Capture any console errors during test execution
const messages = []; let messages = [];
page.on('console', (msg) => messages.push(msg)); page.on('console', (msg) => messages.push(msg));
await use(page); await use(page);
if (ignore404s.length > 0) {
messages = messages.filter((msg) => {
let keep = true;
if (msg.text().match(/404 \((Object )?Not Found\)/) !== null) {
keep = ignore404s.every((ignoreRule) => {
return msg.location().url.match(ignoreRule) === null;
});
}
return keep;
});
}
// Assert against console errors during teardown // Assert against console errors during teardown
if (failOnConsoleError) { if (failOnConsoleError) {
messages.forEach((msg) => messages.forEach((msg) => {
// eslint-disable-next-line playwright/no-standalone-expect // eslint-disable-next-line playwright/no-standalone-expect
expect expect
.soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`) .soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`)
.not.toEqual('error') .not.toEqual('error');
); });
} }
} }
}); });

View File

@ -129,6 +129,7 @@ export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl
*/ */
export function getEarliestStartTime(planJson) { export function getEarliestStartTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.min(...activities.map((activity) => activity.start)); return Math.min(...activities.map((activity) => activity.start));
} }
@ -139,6 +140,7 @@ export function getEarliestStartTime(planJson) {
*/ */
export function getLatestEndTime(planJson) { export function getLatestEndTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.max(...activities.map((activity) => activity.end)); return Math.max(...activities.map((activity) => activity.end));
} }
@ -151,6 +153,7 @@ export function getFirstActivity(planJson) {
const groups = Object.keys(planJson); const groups = Object.keys(planJson);
const firstGroupKey = groups[0]; const firstGroupKey = groups[0];
const firstGroupItems = planJson[firstGroupKey]; const firstGroupItems = planJson[firstGroupKey];
return firstGroupItems[0]; return firstGroupItems[0];
} }

View File

@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@percy/cli": "1.27.4", "@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4", "@percy/playwright": "1.0.4",
"@playwright/test": "1.47.2", "@playwright/test": "1.48.1",
"@axe-core/playwright": "4.8.5" "@axe-core/playwright": "4.8.5"
}, },
"author": { "author": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,67 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, 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 { createDomainObjectWithDefaults, setRealTimeMode } from '../../../appActions.js';
import { MISSION_TIME } from '../../../constants.js';
import { expect, test } from '../../../pluginFixtures.js';
const TELEMETRY_RATE = 2500;
test.describe('Example Event Generator Acknowledge with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await createDomainObjectWithDefaults(page, {
type: 'Event Message Generator with Acknowledge'
});
});
test('Rows are updatable in place', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7938'
});
await test.step('First telemetry datum gets added as new row', async () => {
await page.clock.fastForward(TELEMETRY_RATE);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).not.toHaveAttribute('title', 'OK');
});
await test.step('Incoming Telemetry datum matching an existing rows in place update key has data merged to existing row', async () => {
await page.clock.fastForward(TELEMETRY_RATE * 2);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).toHaveAttribute('title', 'OK');
});
});
});

View File

@ -54,8 +54,7 @@ const examplePlanSmall1 = JSON.parse(
const TIME_TO_FROM_COLUMN = 2; const TIME_TO_FROM_COLUMN = 2;
const HEADER_ROW = 0; const HEADER_ROW = 0;
const NUM_COLUMNS = 5; const NUM_COLUMNS = 5;
const FULL_CIRCLE_PATH = const FULL_CIRCLE_PATH = 'M0,-50A50,50,0,1,1,0,50A50,50,0,1,1,0,-50Z';
'M3.061616997868383e-15,-50A50,50,0,1,1,-3.061616997868383e-15,50A50,50,0,1,1,3.061616997868383e-15,-50Z';
/** /**
* The regular expression used to parse the countdown string. * The regular expression used to parse the countdown string.

View File

@ -24,7 +24,9 @@ import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createPlanFromJSON, createPlanFromJSON,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
setFixedIndependentTimeConductorBounds setFixedIndependentTimeConductorBounds,
setFixedTimeMode,
setTimeConductorBounds
} from '../../../appActions.js'; } from '../../../appActions.js';
import { expect, test } from '../../../pluginFixtures.js'; import { expect, test } from '../../../pluginFixtures.js';
@ -74,21 +76,14 @@ const testPlan = {
}; };
test.describe('Time Strip', () => { test.describe('Time Strip', () => {
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({ let timestrip;
page let plan;
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5627'
});
// Constant locators
const activityBounds = page.locator('.activity-bounds');
test.beforeEach(async ({ page }) => {
// Goto baseURL // Goto baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
const timestrip = await test.step('Create a Time Strip', async () => { timestrip = await test.step('Create a Time Strip', async () => {
const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' }); const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
const objectName = await page.locator('.l-browse-bar__object-name').innerText(); const objectName = await page.locator('.l-browse-bar__object-name').innerText();
expect(objectName).toBe(createdTimeStrip.name); expect(objectName).toBe(createdTimeStrip.name);
@ -96,7 +91,7 @@ test.describe('Time Strip', () => {
return createdTimeStrip; return createdTimeStrip;
}); });
const plan = await test.step('Create a Plan and add it to the timestrip', async () => { plan = await test.step('Create a Plan and add it to the timestrip', async () => {
const createdPlan = await createPlanFromJSON(page, { const createdPlan = await createPlanFromJSON(page, {
name: 'Test Plan', name: 'Test Plan',
json: testPlan json: testPlan
@ -110,6 +105,22 @@ test.describe('Time Strip', () => {
.dragTo(page.getByLabel('Object View')); .dragTo(page.getByLabel('Object View'));
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
return createdPlan;
});
});
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({
page
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5627'
});
// Constant locators
const activityBounds = page.locator('.activity-bounds');
await test.step('Set time strip to fixed timespan mode and verify activities', async () => {
const startBound = testPlan.TEST_GROUP[0].start; const startBound = testPlan.TEST_GROUP[0].start;
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
@ -119,8 +130,6 @@ test.describe('Time Strip', () => {
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.locator('.activity-bounds').count(); const eventCount = await page.locator('.activity-bounds').count();
expect(eventCount).toEqual(testPlan.TEST_GROUP.length); expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
return createdPlan;
}); });
await test.step('TimeStrip can use the Independent Time Conductor', async () => { await test.step('TimeStrip can use the Independent Time Conductor', async () => {
@ -177,4 +186,48 @@ test.describe('Time Strip', () => {
expect(await activityBounds.count()).toEqual(1); expect(await activityBounds.count()).toEqual(1);
}); });
}); });
test('Time strip now line', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7817'
});
await test.step('Is displayed in realtime mode', async () => {
await expect(page.getByLabel('Now Marker')).toBeVisible();
});
await test.step('Is hidden when out of bounds of the time axis', async () => {
// Switch to fixed timespan mode
await setFixedTimeMode(page);
// Get the end bounds
const endBounds = await page.getByLabel('End bounds').textContent();
// Add 2 minutes to end bound datetime and use it as the new end time
let endTimeStamp = new Date(endBounds);
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() + 2);
const endDate = endTimeStamp.toISOString().split('T')[0];
const milliseconds = endTimeStamp.getMilliseconds();
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
// Subtract 1 minute from the end bound and use it as the new start time
let startTimeStamp = new Date(endBounds);
startTimeStamp.setUTCMinutes(startTimeStamp.getUTCMinutes() + 1);
const startDate = startTimeStamp.toISOString().split('T')[0];
const startMilliseconds = startTimeStamp.getMilliseconds();
const startTime = startTimeStamp
.toISOString()
.split('T')[1]
.replace(`.${startMilliseconds}Z`, '');
// Set fixed timespan mode to the future so that "now" is out of bounds.
await setTimeConductorBounds(page, {
startDate,
endDate,
startTime,
endTime
});
await expect(page.getByLabel('Now Marker')).toBeHidden();
});
});
}); });

View File

@ -287,6 +287,41 @@ test.describe('Basic Condition Set Use', () => {
description: 'https://github.com/nasa/openmct/issues/7484' description: 'https://github.com/nasa/openmct/issues/7484'
}); });
}); });
test('ConditionSet has add criteria button enabled/disabled when composition is and is not available', async ({
page
}) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create a condition
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
// Validate that the add criteria button is disabled
await expect(page.getByLabel('Add Criteria - Disabled')).toHaveAttribute('disabled');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Validate that the add criteria button is enabled and adds a new criterion
await expect(page.getByLabel('Add Criteria - Enabled')).not.toHaveAttribute('disabled');
await page.getByLabel('Add Criteria - Enabled').click();
const numOfUnnamedCriteria = await page.getByLabel('Criterion Telemetry Selection').count();
expect(numOfUnnamedCriteria).toEqual(2);
});
}); });
test.describe('Condition Set Composition', () => { test.describe('Condition Set Composition', () => {

View File

@ -507,8 +507,140 @@ test.describe('Display Layout', () => {
// In real time mode, we don't fetch annotations at all // In real time mode, we don't fetch annotations at all
await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0); await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0);
}); });
test('Same objects with different request options have unique subscriptions', async ({
page
}) => {
// Expand My Items
await page.getByLabel('Expand My Items folder').click();
// Create a Display Layout
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display'
}); });
// Create a State Generator, set to higher frequency updates
const stateGenerator = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'State Generator'
});
const stateGeneratorTreeItem = page.getByRole('treeitem', {
name: stateGenerator.name
});
await stateGeneratorTreeItem.click({ button: 'right' });
await page.getByLabel('Edit Properties...').click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('0.1');
await page.getByLabel('Save').click();
// Create a Table for filtering ON values
const tableFilterOnValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter On Value'
});
const tableFilterOnTreeItem = page.getByRole('treeitem', {
name: tableFilterOnValue.name
});
// Create a Table for filtering OFF values
const tableFilterOffValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter Off Value'
});
const tableFilterOffTreeItem = page.getByRole('treeitem', {
name: tableFilterOffValue.name
});
// Navigate to ON filtering table and add state generator and setup filters
await page.goto(tableFilterOnValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '1');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to OFF filtering table and add state generator and setup filters
await page.goto(tableFilterOffValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '0');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to the display layout and edit it
await page.goto(displayLayout.url);
// Add the tables to the display layout
await page.getByLabel('Edit Object').click();
await tableFilterOffTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 300 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 500 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await tableFilterOnTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 100 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 300 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Get the tables so we can verify filtering is working as expected
const tableFilterOn = page.getByLabel(`${tableFilterOnValue.name} Frame`, {
exact: true
});
const tableFilterOff = page.getByLabel(`${tableFilterOffValue.name} Frame`, {
exact: true
});
// Verify filtering is working correctly
// Check that no filtered values appear for at least 2 seconds
const VERIFICATION_TIME = 2000; // 2 seconds
const CHECK_INTERVAL = 100; // Check every 100ms
// Create a promise that will check for filtered values periodically
const checkForCorrectValues = new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const offCount = await tableFilterOn.locator('td[title="OFF"]').count();
const onCount = await tableFilterOff.locator('td[title="ON"]').count();
if (offCount > 0 || onCount > 0) {
clearInterval(interval);
reject(
new Error(
`Found ${offCount} OFF and ${onCount} ON values when expecting 0 OFF and 0 ON`
)
);
}
}, CHECK_INTERVAL);
// After VERIFICATION_TIME, if no filtered values were found, resolve successfully
setTimeout(() => {
clearInterval(interval);
resolve();
}, VERIFICATION_TIME);
});
await expect(checkForCorrectValues).resolves.toBeUndefined();
});
});
async function selectFilterOption(page, filterOption) {
await page.getByRole('tab', { name: 'Filters' }).click();
await page
.getByLabel('Inspector Views')
.locator('li')
.filter({ hasText: 'State Generator' })
.locator('span')
.click();
await page.getByRole('switch').click();
await page.selectOption('select[name="setSelectionThreshold"]', filterOption);
}
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) { async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0); await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject); await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);

View File

@ -28,7 +28,9 @@ import { v4 as uuid } from 'uuid';
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject createExampleTelemetryObject,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
@ -166,6 +168,57 @@ test.describe('Gauge', () => {
); );
}); });
test('Gauge does not break when an object is missing', async ({ page }) => {
// Set up error listeners
const pageErrors = [];
// Listen for uncaught exceptions
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
await setRealTimeMode(page);
// Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Gauge with missing object'
});
// Create a Sine Wave Generator in the Gauge with a loading delay
const missingSWG = await createExampleTelemetryObject(page, gauge.uuid);
// Remove the object from local storage
await page.evaluate(
([missingObject]) => {
const mct = localStorage.getItem('mct');
const mctObjects = JSON.parse(mct);
delete mctObjects[missingObject.uuid];
localStorage.setItem('mct', JSON.stringify(mctObjects));
},
[missingSWG]
);
// Verify start bounds
await expect(page.getByLabel('Start offset: 00:30:00')).toBeVisible();
// Nav to the Gauge
await page.goto(gauge.url, { waitUntil: 'domcontentloaded' });
// adjust time bounds and ensure they are updated
await setStartOffset(page, {
startHours: '00',
startMins: '45',
startSecs: '00'
});
// Verify start bounds changed
await expect(page.getByLabel('Start offset: 00:45:00')).toBeVisible();
// // Verify no errors were thrown
expect(pageErrors).toHaveLength(0);
});
test('Gauge enforces composition policy', async ({ page }) => { test('Gauge enforces composition policy', async ({ page }) => {
// Create a Gauge // Create a Gauge
await createDomainObjectWithDefaults(page, { await createDomainObjectWithDefaults(page, {

View File

@ -96,9 +96,6 @@ test.describe('Example Imagery Object', () => {
expect(newPage.url()).toContain('.jpg'); expect(newPage.url()).toContain('.jpg');
}); });
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can adjust image brightness/contrast by dragging the sliders', async ({ test('Can adjust image brightness/contrast by dragging the sliders', async ({
page, page,
browserName browserName

View File

@ -0,0 +1,93 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
* This test suite verifies modifying the image location of the example imagery object.
*/
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Example Imagery Object Custom Images', () => {
let exampleImagery;
test.beforeEach(async ({ page }) => {
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a default 'Example Imagery' object
exampleImagery = await createDomainObjectWithDefaults(page, {
name: 'Example Imagery',
type: 'Example Imagery'
});
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
await page.getByLabel('Focused Image Element').hover({ trial: true });
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can provide a custom image location for the example imagery object', async ({ page }) => {
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg,https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
test.fixme('Can provide a custom image with spaces in name', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7903'
});
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/d8c64f183400afb70137221fc1a035e091bea912/e2e/test-data/rick%20space%20roll.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
});

View File

@ -26,7 +26,10 @@ This test suite is dedicated to tests which verify the basic operations surround
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js'; import {
createDomainObjectWithDefaults,
renameCurrentObjectFromBrowseBar
} from '../../../../appActions.js';
import { copy, paste, selectAll } from '../../../../helper/hotkeys/hotkeys.js'; import { copy, paste, selectAll } from '../../../../helper/hotkeys/hotkeys.js';
import * as nbUtils from '../../../../helper/notebookUtils.js'; import * as nbUtils from '../../../../helper/notebookUtils.js';
import { expect, streamToString, test } from '../../../../pluginFixtures.js'; import { expect, streamToString, test } from '../../../../pluginFixtures.js';
@ -596,4 +599,61 @@ test.describe('Notebook entry tests', () => {
await expect(await page.locator(`text="${TEST_TEXT.repeat(1)}"`).count()).toEqual(1); await expect(await page.locator(`text="${TEST_TEXT.repeat(1)}"`).count()).toEqual(1);
await expect(await page.locator(`text="${TEST_TEXT.repeat(2)}"`).count()).toEqual(0); await expect(await page.locator(`text="${TEST_TEXT.repeat(2)}"`).count()).toEqual(0);
}); });
test('When changing the name of a notebook in the browse bar, new notebook changes are not lost', async ({
page
}) => {
const TEST_TEXT = 'Do not lose me!';
const FIRST_NEW_NAME = 'New Name';
const SECOND_NEW_NAME = 'Second New Name';
await page.goto(notebookObject.url);
await page.getByLabel('Expand My Items folder').click();
await renameCurrentObjectFromBrowseBar(page, FIRST_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, FIRST_NEW_NAME);
// enter one entry
await enterAndCommitTextEntry(page, TEST_TEXT);
// verify the entry is present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
// change the name
await renameCurrentObjectFromBrowseBar(page, SECOND_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, SECOND_NEW_NAME);
// verify the entry is still present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
}); });
});
/**
* Enter text into the last notebook entry and commit it.
*
* @param {import('@playwright/test').Page} page
* @param {string} text
*/
async function enterAndCommitTextEntry(page, text) {
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, text);
await nbUtils.commitEntry(page);
}
/**
* Verify the name change in the tree and browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function verifyNameChange(page, newName) {
await expect(
page.getByRole('treeitem').locator('.is-navigated-object .c-tree__item__name')
).toHaveText(newName);
await expect(page.getByLabel('Browse bar object name')).toHaveText(newName);
}

View File

@ -108,4 +108,42 @@ test.describe('Plot Controls', () => {
// Expect before and after plot points to match // Expect before and after plot points to match
await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait); await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait);
}); });
/*
Test to verify that switching a plot's time context from global to
its own independent time context and then back to global context works correctly.
After switching from fixed time mode (ITC) to real time mode (global context),
the pause control for the plot should be available, indicating that it is following the right context.
*/
test('Plots follow the right time context', async ({ page }) => {
// Set global time conductor to real-time mode
await setRealTimeMode(page);
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since global time conductor is in Real time mode.
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
// Toggle independent time conductor ON
await page.getByLabel('Enable Independent Time Conductor').click();
// Bring up the independent time conductor popup and switch to fixed time mode
await page.getByLabel('Independent Time Conductor Settings').click();
await page.getByLabel('Independent Time Conductor Mode Menu').click();
await page.getByRole('menuitem', { name: /Fixed Timespan/ }).click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is no longer visible since the plot is following the independent time context
await expect(page.getByTitle('Pause incoming real-time data')).toBeHidden();
// Toggle independent time conductor OFF - Note that the global time conductor is still in Real time mode
await page.getByLabel('Disable Independent Time Conductor').click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since the global time conductor is in real time mode
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
});
}); });

View File

@ -0,0 +1,58 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2025, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
* This test suite is dedicated to testing the rendering and interaction of plots.
*
*/
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Plot Controls in compact mode', () => {
let timeStrip;
test.beforeEach(async ({ page }) => {
// Open a browser, navigate to the main page, and wait until all networkevents to resolve
await page.goto('./', { waitUntil: 'domcontentloaded' });
timeStrip = await createDomainObjectWithDefaults(page, {
type: 'Time Strip'
});
// Create an overlay plot with a sine wave generator
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
parent: timeStrip.uuid
});
await page.goto(`${timeStrip.url}`);
});
test('Plots show cursor guides', async ({ page }) => {
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// click on cursor guides control
await page.getByTitle('Toggle cursor guides').click();
await page.getByLabel('Plot Canvas').hover();
await expect(page.getByLabel('Vertical cursor guide')).toBeVisible();
await expect(page.getByLabel('Horizontal cursor guide')).toBeVisible();
});
});

View File

@ -57,7 +57,7 @@ test.describe('Tabs View', () => {
await page.goto(tabsView.url); await page.goto(tabsView.url);
// select first tab // select first tab
await page.getByLabel(`${table.name} tab`, { exact: true }).click(); await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
// ensure table header visible // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
@ -92,6 +92,38 @@ test.describe('Tabs View', () => {
// no canvas (i.e., sine wave generator) in the document should be visible // no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas[id=webglContext]')).toBeHidden(); await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
}); });
test('Changing the displayed tab should not be persisted if the view is locked', async ({
page
}) => {
await page.goto(tabsView.url);
//lock the view
await page.getByLabel('Unlocked for editing, click to lock.', { exact: true }).click();
// get the initial tab index
const initialTab = page.getByLabel(/- selected/);
// switch to a different tab in the view
const swgTab = page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true });
await swgTab.click();
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
// navigate away from the tabbed view and back
await page.getByRole('treeitem', { name: 'My Items' }).click();
await page.goto(tabsView.url);
// check that the initial tab is displayed
const lockedSelectedTab = page.getByLabel(/- selected/);
await expect(lockedSelectedTab).toHaveText(await initialTab.textContent());
//unlock the view
await page.getByLabel('Locked for editing. Click to unlock.', { exact: true }).click();
// switch to a different tab in the view
await swgTab.click();
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
// navigate away from the tabbed view and back
await page.getByRole('treeitem', { name: 'My Items' }).click();
await page.goto(tabsView.url);
// check that the newly selected tab is displayed
const unlockedSelectedTab = page.getByLabel(/- selected/);
await expect(unlockedSelectedTab).toBeVisible();
});
}); });
test.describe('Tabs View CRUD', () => { test.describe('Tabs View CRUD', () => {

View File

@ -117,7 +117,8 @@ test.describe('Telemetry Table', () => {
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5); endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5);
const endDate = endTimeStamp.toISOString().split('T')[0]; const endDate = endTimeStamp.toISOString().split('T')[0];
const endTime = endTimeStamp.toISOString().split('T')[1]; const milliseconds = endTimeStamp.getMilliseconds();
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
await setTimeConductorBounds(page, { endDate, endTime }); await setTimeConductorBounds(page, { endDate, endTime });

View File

@ -24,65 +24,210 @@ import {
setEndOffset, setEndOffset,
setFixedTimeMode, setFixedTimeMode,
setRealTimeMode, setRealTimeMode,
setStartOffset, setStartOffset
setTimeConductorBounds
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Time conductor operations', () => { test.describe('Time conductor operations', () => {
test('validate start time does not exceed end time', async ({ page }) => { const DAY = '2024-01-01';
const DAY_AFTER = '2024-01-02';
const ONE_O_CLOCK = '01:00:00';
const TWO_O_CLOCK = '02:00:00';
test.beforeEach(async ({ page }) => {
// Go to baseURL // Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
const year = new Date().getFullYear(); });
// Set initial valid time bounds test('validate date and time inputs are validated on input event', async ({ page }) => {
const startDate = `${year}-01-01`; const submitButtonLocator = page.getByLabel('Submit time bounds');
const startTime = '01:00:00';
const endDate = `${year}-01-01`;
const endTime = '02:00:00';
await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime });
// Open the time conductor popup // Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// Test invalid start date await test.step('invalid start date disables submit button', async () => {
const invalidStartDate = `${year}-01-02`; const initialStartDate = await page.getByLabel('Start date').inputValue();
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
await page.getByLabel('Start date').fill(invalidStartDate); await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start date').fill(startDate); await page.getByLabel('Start date').fill(initialStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); await expect(submitButtonLocator).toBeEnabled();
});
// Test invalid end date await test.step('invalid start time disables submit button', async () => {
const invalidEndDate = `${year - 1}-12-31`; const initialStartTime = await page.getByLabel('Start time').inputValue();
await page.getByLabel('End date').fill(invalidEndDate); const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End date').fill(endDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid start time
const invalidStartTime = '42:00:00';
await page.getByLabel('Start time').fill(invalidStartTime); await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start time').fill(startTime); await page.getByLabel('Start time').fill(initialStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); await expect(submitButtonLocator).toBeEnabled();
});
await test.step('disable/enable submit button also works with multiple invalid inputs', async () => {
const initialEndDate = await page.getByLabel('End date').inputValue();
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
const initialStartTime = await page.getByLabel('Start time').inputValue();
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('End date').fill(invalidEndDate);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('End date').fill(initialEndDate);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start time').fill(initialStartTime);
await expect(submitButtonLocator).toBeEnabled();
});
});
test('validate date and time inputs validation is reported on change event', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await test.step('invalid start date is reported on change event, not on input event', async () => {
const initialStartDate = await page.getByLabel('Start date').inputValue();
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('Start date').press('Tab');
await expect(page.getByLabel('Start date')).toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('Start date').fill(initialStartDate);
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
});
await test.step('invalid start time is reported on change event, not on input event', async () => {
const initialStartTime = await page.getByLabel('Start time').inputValue();
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('Start time').press('Tab');
await expect(page.getByLabel('Start time')).toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('Start time').fill(initialStartTime);
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
});
await test.step('invalid end date is reported on change event, not on input event', async () => {
const initialEndDate = await page.getByLabel('End date').inputValue();
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
await page.getByLabel('End date').fill(invalidEndDate);
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('End date').press('Tab');
await expect(page.getByLabel('End date')).toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('End date').fill(initialEndDate);
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
});
await test.step('invalid end time is reported on change event, not on input event', async () => {
const initialEndTime = await page.getByLabel('End time').inputValue();
const invalidEndTime = `${initialEndTime.substring(0, 5)}${initialEndTime.substring(6)}`;
// Test invalid end time
const invalidEndTime = '43:00:00';
await page.getByLabel('End time').fill(invalidEndTime); await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('End time').fill(endTime); await page.getByLabel('End time').press('Tab');
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); await expect(page.getByLabel('End time')).toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('End time').fill(initialEndTime);
await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
});
});
// Submit valid time bounds test('validate start time does not exceed end time on submit', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(TWO_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click(); await page.getByLabel('Submit time bounds').click();
// Verify the submitted time bounds await expect(page.getByLabel('Start date')).toHaveAttribute(
await expect(page.getByLabel('Start bounds')).toHaveText( 'title',
new RegExp(`${startDate} ${startTime}.000Z`) 'Specified start date exceeds end bound'
); );
await expect(page.getByLabel('End bounds')).toHaveText( await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
new RegExp(`${endDate} ${endTime}.000Z`) await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(TWO_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
});
test('validate start datetime does not exceed end datetime on submit', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await page.getByLabel('Start date').fill(DAY_AFTER);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start date')).toHaveAttribute(
'title',
'Specified start date exceeds end bound'
); );
await expect(page.getByLabel('Start bounds')).not.toHaveText(
`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`
);
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
});
test('cancelling form does not set bounds', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7791'
});
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Discard changes and close time popup').click();
await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
}); });
}); });
@ -131,77 +276,6 @@ test.describe('Global Time Conductor', () => {
await expect(page.getByLabel('End offset: 01:30:31')).toBeVisible(); await expect(page.getByLabel('End offset: 01:30:31')).toBeVisible();
}); });
test('Input field validation: fixed time mode', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7791'
});
// Switch to fixed time mode
await setFixedTimeMode(page);
// Define valid time bounds for testing
const validBounds = {
startDate: '2024-04-20',
startTime: '00:04:20',
endDate: '2024-04-20',
endTime: '16:04:20'
};
// Set valid time conductor bounds ✌️
await setTimeConductorBounds(page, validBounds);
// Verify that the time bounds are set correctly
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
// Open the Time Conductor Mode popup
await page.getByLabel('Time Conductor Mode').click();
// Test invalid start date
const invalidStartDate = '2024-04-21';
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start date').fill(validBounds.startDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid end date
const invalidEndDate = '2024-04-19';
await page.getByLabel('End date').fill(invalidEndDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End date').fill(validBounds.endDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid start time
const invalidStartTime = '16:04:21';
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start time').fill(validBounds.startTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid end time
const invalidEndTime = '00:04:19';
await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End time').fill(validBounds.endTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Verify that the time bounds remain unchanged after invalid inputs
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
// Discard changes and verify that bounds remain unchanged
await setTimeConductorBounds(page, {
startDate: validBounds.startDate,
startTime: '04:20:00',
endDate: validBounds.endDate,
endTime: '04:20:20',
submitChanges: false
});
// Verify that the original time bounds are still displayed after discarding changes
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
});
/** /**
* Verify that offsets and url params are preserved when switching * Verify that offsets and url params are preserved when switching
* between fixed timespan and real-time mode. * between fixed timespan and real-time mode.

View File

@ -31,6 +31,8 @@ import { expect, test } from '../../pluginFixtures.js';
test.describe('Grand Search', () => { test.describe('Grand Search', () => {
let grandSearchInput; let grandSearchInput;
test.use({ ignore404s: [/_design\/object_names\/_view\/object_names$/] });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
grandSearchInput = page grandSearchInput = page
.getByLabel('OpenMCT Search') .getByLabel('OpenMCT Search')
@ -191,7 +193,88 @@ test.describe('Grand Search', () => {
await expect(searchResults).toContainText(folderName); await expect(searchResults).toContainText(folderName);
}); });
test.describe('Search will test for the presence of the object_names index, and', () => {
test('use index if available @couchdb @network', async ({ page }) => {
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isObjectNamesUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isObjectNamesRequest && isPostRequest) {
isObjectNamesUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
expect(isObjectNamesViewAvailable).toBe(true);
expect(isObjectNamesUsedForSearch).toBe(true);
});
test('fall-back on base index if index not available @couchdb @network', async ({ page }) => {
await page.route('**/_view/object_names', (route) => {
route.fulfill({
status: 404
});
});
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isFindUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isFindRequest = request.url().endsWith('_find');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isFindRequest && isPostRequest) {
isFindUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
console.info(
`isObjectNamesViewAvailable: ${isObjectNamesViewAvailable} | isFindUsedForSearch: ${isFindUsedForSearch}`
);
expect(isObjectNamesViewAvailable).toBe(false);
expect(isFindUsedForSearch).toBe(true);
});
});
test('Search results are debounced @couchdb @network', async ({ page }) => { test('Search results are debounced @couchdb @network', async ({ page }) => {
// Unfortunately 404s are always logged to the JavaScript console and can't be suppressed
// A 404 is now thrown when we test for the presence of the object names view used by search.
test.info().annotations.push({ test.info().annotations.push({
type: 'issue', type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6179' description: 'https://github.com/nasa/openmct/issues/6179'
@ -199,11 +282,17 @@ test.describe('Grand Search', () => {
await createObjectsForSearch(page); await createObjectsForSearch(page);
let networkRequests = []; let networkRequests = [];
page.on('request', (request) => { page.on('request', (request) => {
const searchRequest = const isSearchRequest =
request.url().endsWith('_find') || request.url().includes('by_keystring'); request.url().endsWith('object_names') ||
const fetchRequest = request.resourceType() === 'fetch'; request.url().endsWith('_find') ||
if (searchRequest && fetchRequest) { request.url().includes('by_keystring');
const isFetchRequest = request.resourceType() === 'fetch';
// CouchDB search results in a one-time head request to test for the presence of an index.
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isSearchRequest && isFetchRequest && !isHeadRequest) {
networkRequests.push(request); networkRequests.push(request);
} }
}); });

View File

@ -213,7 +213,6 @@ test.describe('Navigation memory leak is not detected in', () => {
page, page,
'example-imagery-memory-leak-test' 'example-imagery-memory-leak-test'
); );
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected. // If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -317,6 +316,12 @@ test.describe('Navigation memory leak is not detected in', () => {
// Manually invoke the garbage collector once all references are removed. // Manually invoke the garbage collector once all references are removed.
window.gc(); window.gc();
window.gc();
window.gc();
setTimeout(() => {
window.gc();
}, 1000);
return gcPromise; return gcPromise;
}); });

View File

@ -64,7 +64,7 @@ test.describe('Tabs View', () => {
page.goto(tabsView.url); page.goto(tabsView.url);
// select first tab // select first tab
await page.getByLabel(`${table.name} tab`, { exact: true }).click(); await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
// ensure table header visible // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();

View File

@ -26,14 +26,25 @@ import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
import { scanForA11yViolations, test } from '../../avpFixtures.js'; import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js'; import { VISUAL_FIXED_URL } from '../../constants.js';
import { setBoundsToSpanAllActivities, setDraftStatusForPlan } from '../../helper/planningUtils.js'; import {
getFirstActivity,
setBoundsToSpanAllActivities,
setDraftStatusForPlan
} from '../../helper/planningUtils.js';
const examplePlanSmall2 = JSON.parse( const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url)) fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
); );
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Gantt Chart @a11y', () => { test.describe('Visual - Gantt Chart @a11y', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });
test('Gantt Chart View', async ({ page, theme }) => { test('Gantt Chart View', async ({ page, theme }) => {

View File

@ -27,14 +27,21 @@ import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appAct
import { scanForA11yViolations, test } from '../../avpFixtures.js'; import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { waitForAnimations } from '../../baseFixtures.js'; import { waitForAnimations } from '../../baseFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js'; import { VISUAL_FIXED_URL } from '../../constants.js';
import { setBoundsToSpanAllActivities } from '../../helper/planningUtils.js'; import { getFirstActivity, setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
const examplePlanSmall2 = JSON.parse( const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url)) fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
); );
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Time Strip @a11y', () => { test.describe('Visual - Time Strip @a11y', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });
test('Time Strip View', async ({ page, theme }) => { test('Time Strip View', async ({ page, theme }) => {

View File

@ -42,6 +42,7 @@ const examplePlanSmall2 = JSON.parse(
); );
const FIRST_ACTIVITY_SMALL_1 = getFirstActivity(examplePlanSmall1); const FIRST_ACTIVITY_SMALL_1 = getFirstActivity(examplePlanSmall1);
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Timelist progress bar @clock @a11y', () => { test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -59,6 +60,11 @@ test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.describe('Visual - Plan View @a11y', () => { test.describe('Visual - Plan View @a11y', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });

View File

@ -46,6 +46,24 @@ class EventMetadataProvider {
] ]
} }
}; };
const inPlaceUpdateMetadataValue = {
key: 'messageId',
name: 'row identifier',
format: 'string',
useToUpdateInPlace: true
};
const eventAcknowledgeMetadataValue = {
key: 'acknowledge',
name: 'Acknowledge',
format: 'string'
};
const eventGeneratorWithAcknowledge = structuredClone(this.METADATA_BY_TYPE.eventGenerator);
eventGeneratorWithAcknowledge.values.push(inPlaceUpdateMetadataValue);
eventGeneratorWithAcknowledge.values.push(eventAcknowledgeMetadataValue);
this.METADATA_BY_TYPE.eventGeneratorWithAcknowledge = eventGeneratorWithAcknowledge;
} }
supportsMetadata(domainObject) { supportsMetadata(domainObject) {

View File

@ -0,0 +1,70 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, 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.
*****************************************************************************/
/**
* Module defining EventTelemetryProvider. Created by chacskaylo on 06/18/2015.
*/
import EventTelemetryProvider from './EventTelemetryProvider.js';
class EventWithAcknowledgeTelemetryProvider extends EventTelemetryProvider {
constructor() {
super();
this.unAcknowledgedData = undefined;
}
generateData(firstObservedTime, count, startTime, duration, name) {
if (this.unAcknowledgedData === undefined) {
const unAcknowledgedData = super.generateData(
firstObservedTime,
count,
startTime,
duration,
name
);
unAcknowledgedData.messageId = unAcknowledgedData.message;
this.unAcknowledgedData = unAcknowledgedData;
return this.unAcknowledgedData;
} else {
const acknowledgedData = {
...this.unAcknowledgedData,
acknowledge: 'OK'
};
this.unAcknowledgedData = undefined;
return acknowledgedData;
}
}
supportsRequest(domainObject) {
return false;
}
supportsSubscribe(domainObject) {
return domainObject.type === 'eventGeneratorWithAcknowledge';
}
}
export default EventWithAcknowledgeTelemetryProvider;

View File

@ -21,6 +21,7 @@
*****************************************************************************/ *****************************************************************************/
import EventMetadataProvider from './EventMetadataProvider.js'; import EventMetadataProvider from './EventMetadataProvider.js';
import EventTelemetryProvider from './EventTelemetryProvider.js'; import EventTelemetryProvider from './EventTelemetryProvider.js';
import EventWithAcknowledgeTelemetryProvider from './EventWithAcknowledgeTelemetryProvider.js';
export default function EventGeneratorPlugin(options) { export default function EventGeneratorPlugin(options) {
return function install(openmct) { return function install(openmct) {
@ -38,5 +39,20 @@ export default function EventGeneratorPlugin(options) {
}); });
openmct.telemetry.addProvider(new EventTelemetryProvider()); openmct.telemetry.addProvider(new EventTelemetryProvider());
openmct.telemetry.addProvider(new EventMetadataProvider()); openmct.telemetry.addProvider(new EventMetadataProvider());
openmct.types.addType('eventGeneratorWithAcknowledge', {
name: 'Event Message Generator with Acknowledge',
description:
'For development use. Creates sample event message data stream and updates the event row with an acknowledgement.',
cssClass: 'icon-generator-events',
creatable: true,
initialize: function (object) {
object.telemetry = {
duration: 2.5
};
}
});
openmct.telemetry.addProvider(new EventWithAcknowledgeTelemetryProvider());
}; };
} }

View File

@ -108,6 +108,16 @@ const METADATA_BY_TYPE = {
string: 'ON' string: 'ON'
} }
], ],
filters: [
{
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ label: 'OFF', value: 0 },
{ label: 'ON', value: 1 }
]
}
],
hints: { hints: {
range: 1 range: 1
} }

View File

@ -34,14 +34,16 @@ StateGeneratorProvider.prototype.supportsSubscribe = function (domainObject) {
return domainObject.type === 'example.state-generator'; return domainObject.type === 'example.state-generator';
}; };
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback) { StateGeneratorProvider.prototype.subscribe = function (domainObject, callback, options) {
var duration = domainObject.telemetry.duration * 1000; var duration = domainObject.telemetry.duration * 1000;
var interval = setInterval(function () { var interval = setInterval(() => {
var now = Date.now(); var now = Date.now();
var datum = pointForTimestamp(now, duration, domainObject.name); var datum = pointForTimestamp(now, duration, domainObject.name);
if (!this.shouldBeFiltered(datum, options)) {
datum.value = String(datum.value); datum.value = String(datum.value);
callback(datum); callback(datum);
}
}, duration); }, duration);
return function () { return function () {
@ -63,9 +65,25 @@ StateGeneratorProvider.prototype.request = function (domainObject, options) {
var data = []; var data = [];
while (start <= end && data.length < 5000) { while (start <= end && data.length < 5000) {
data.push(pointForTimestamp(start, duration, domainObject.name)); const point = pointForTimestamp(start, duration, domainObject.name);
if (!this.shouldBeFiltered(point, options)) {
data.push(point);
}
start += duration; start += duration;
} }
return Promise.resolve(data); return Promise.resolve(data);
}; };
StateGeneratorProvider.prototype.shouldBeFiltered = function (point, options) {
const valueToFilter = options?.filters?.state?.equals?.[0];
if (!valueToFilter) {
return false;
}
const { value } = point;
return value !== Number(valueToFilter);
};

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { seededRandom } from 'utils/random.js';
const DEFAULT_IMAGE_SAMPLES = [ const DEFAULT_IMAGE_SAMPLES = [
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18731.jpg', 'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18731.jpg',
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18732.jpg', 'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18732.jpg',
@ -162,8 +164,8 @@ export default function () {
}; };
} }
function getCompassValues(min, max) { function getCompassValues(min, max, timestamp) {
return min + Math.random() * (max - min); return min + seededRandom(timestamp) * (max - min);
} }
function getImageSamples(configuration) { function getImageSamples(configuration) {
@ -283,9 +285,9 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
utc: Math.floor(timestamp / delay) * delay, utc: Math.floor(timestamp / delay) * delay,
local: Math.floor(timestamp / delay) * delay, local: Math.floor(timestamp / delay) * delay,
url, url,
sunOrientation: getCompassValues(0, 360), sunOrientation: getCompassValues(0, 360, timestamp),
cameraAzimuth: getCompassValues(0, 360), cameraAzimuth: getCompassValues(0, 360, timestamp),
heading: getCompassValues(0, 360), heading: getCompassValues(0, 360, timestamp),
transformations: navCamTransformations, transformations: navCamTransformations,
imageDownloadName imageDownloadName
}; };

1344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,25 +16,25 @@
], ],
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "7.23.3", "@babel/eslint-parser": "7.23.3",
"@braintree/sanitize-url": "6.0.4", "@braintree/sanitize-url": "7.1.1",
"@types/d3-axis": "3.0.6", "@types/d3-axis": "3.0.6",
"@types/d3-scale": "4.0.8", "@types/d3-scale": "4.0.8",
"@types/d3-selection": "3.0.10", "@types/d3-selection": "3.0.10",
"@types/d3-shape": "3.0.0", "@types/d3-shape": "3.1.7",
"@types/eventemitter3": "1.2.0", "@types/eventemitter3": "1.2.0",
"@types/jasmine": "5.1.2", "@types/jasmine": "5.1.2",
"@types/lodash": "4.17.0", "@types/lodash": "4.17.0",
"@vue/compiler-sfc": "3.4.3", "@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1", "babel-plugin-istanbul": "7.0.0",
"comma-separated-values": "3.6.4", "comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "13.0.0",
"cspell": "7.3.8", "cspell": "7.3.8",
"css-loader": "6.10.0", "css-loader": "6.10.0",
"d3-axis": "3.0.0", "d3-axis": "3.0.0",
"d3-scale": "4.0.2", "d3-scale": "4.0.2",
"d3-selection": "3.0.0", "d3-selection": "3.0.0",
"d3-shape": "3.0.0", "d3-shape": "3.2.0",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-compat": "4.2.0", "eslint-plugin-compat": "4.2.0",
@ -51,7 +51,7 @@
"git-rev-sync": "3.0.2", "git-rev-sync": "3.0.2",
"html2canvas": "1.4.1", "html2canvas": "1.4.1",
"imports-loader": "5.0.0", "imports-loader": "5.0.0",
"jasmine-core": "5.1.1", "jasmine-core": "5.6.0",
"karma": "6.4.2", "karma": "6.4.2",
"karma-chrome-launcher": "3.2.0", "karma-chrome-launcher": "3.2.0",
"karma-cli": "2.0.0", "karma-cli": "2.0.0",
@ -64,13 +64,14 @@
"karma-webpack": "5.0.1", "karma-webpack": "5.0.1",
"location-bar": "3.0.1", "location-bar": "3.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "12.0.0", "marked": "15.0.7",
"mini-css-extract-plugin": "2.7.6", "mini-css-extract-plugin": "2.9.2",
"moment": "2.30.1", "moment": "2.30.1",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
"npm-run-all2": "6.1.2", "nano": "10.1.4",
"nyc": "15.1.0", "npm-run-all2": "7.0.2",
"nyc": "17.1.0",
"painterro": "1.2.87", "painterro": "1.2.87",
"plotly.js-basic-dist-min": "2.29.1", "plotly.js-basic-dist-min": "2.29.1",
"plotly.js-gl2d-dist-min": "2.20.0", "plotly.js-gl2d-dist-min": "2.20.0",
@ -78,21 +79,21 @@
"prettier-eslint": "16.3.0", "prettier-eslint": "16.3.0",
"printj": "1.3.1", "printj": "1.3.1",
"resolve-url-loader": "5.0.0", "resolve-url-loader": "5.0.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.15.0",
"sass": "1.71.1", "sass": "1.71.1",
"sass-loader": "14.1.1", "sass-loader": "14.1.1",
"style-loader": "3.3.3", "style-loader": "4.0.0",
"terser-webpack-plugin": "5.3.9", "terser-webpack-plugin": "5.3.9",
"tiny-emitter": "2.1.0", "tiny-emitter": "2.1.0",
"typescript": "5.3.3", "typescript": "5.3.3",
"uuid": "9.0.1", "uuid": "11.1.0",
"vue": "3.4.24", "vue": "3.4.24",
"vue-eslint-parser": "9.4.2", "vue-eslint-parser": "9.4.2",
"vue-loader": "16.8.3", "vue-loader": "16.8.3",
"webpack": "5.90.3", "webpack": "5.98.0",
"webpack-cli": "5.1.1", "webpack-cli": "5.1.1",
"webpack-dev-server": "5.0.2", "webpack-dev-server": "5.0.2",
"webpack-merge": "5.10.0" "webpack-merge": "6.0.1"
}, },
"scripts": { "scripts": {
"clean": "rm -rf ./dist ./node_modules ./coverage ./html-test-results ./e2e/test-results ./.nyc_output ./e2e/.nyc_output", "clean": "rm -rf ./dist ./node_modules ./coverage ./html-test-results ./e2e/test-results ./.nyc_output ./e2e/.nyc_output",
@ -138,7 +139,7 @@
"url": "git+https://github.com/nasa/openmct.git" "url": "git+https://github.com/nasa/openmct.git"
}, },
"engines": { "engines": {
"node": ">=18.14.2 <22" "node": ">=18.14.2 <23"
}, },
"browserslist": [ "browserslist": [
"Firefox ESR", "Firefox ESR",

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { isIdentifier } from '../objects/object-utils';
/** /**
* @typedef {import('openmct').DomainObject} DomainObject * @typedef {import('openmct').DomainObject} DomainObject
*/ */
@ -209,9 +211,15 @@ export default class CompositionCollection {
this.#cleanUpMutables(); this.#cleanUpMutables();
const children = await this.#provider.load(this.domainObject); const children = await this.#provider.load(this.domainObject);
const childObjects = await Promise.all( const childObjects = await Promise.all(
children.map((c) => this.#publicAPI.objects.get(c, abortSignal)) children.map((child) => {
if (isIdentifier(child)) {
return this.#publicAPI.objects.get(child, abortSignal);
} else {
return Promise.resolve(child);
}
})
); );
childObjects.forEach((c) => this.add(c, true)); childObjects.forEach((child) => this.add(child, true));
this.#emit('load'); this.#emit('load');
return childObjects; return childObjects;

View File

@ -96,8 +96,9 @@ export default class CompositionProvider {
* object. * object.
* @param {DomainObject} domainObject the domain object * @param {DomainObject} domainObject the domain object
* for which to load composition * for which to load composition
* @returns {Promise<Identifier[]>} a promise for * @returns {Promise<Identifier[] | DomainObject[]>} a promise for
* the Identifiers in this composition * the Identifiers or Domain Objects in this composition. If Identifiers are returned,
* they will be automatically resolved to domain objects by the API.
*/ */
load(domainObject) { load(domainObject) {
throw new Error('This method must be implemented by a subclass.'); throw new Error('This method must be implemented by a subclass.');

View File

@ -21,7 +21,7 @@
*****************************************************************************/ *****************************************************************************/
import { toRaw } from 'vue'; import { toRaw } from 'vue';
import { makeKeyString } from '../objects/object-utils.js'; import { makeKeyString, parseKeyString } from '../objects/object-utils.js';
import CompositionProvider from './CompositionProvider.js'; import CompositionProvider from './CompositionProvider.js';
/** /**
@ -75,7 +75,11 @@ export default class DefaultCompositionProvider extends CompositionProvider {
* the Identifiers in this composition * the Identifiers in this composition
*/ */
load(domainObject) { load(domainObject) {
return Promise.all(domainObject.composition); const identifiers = domainObject.composition
.filter((idOrKeystring) => idOrKeystring !== null && idOrKeystring !== undefined)
.map((idOrKeystring) => parseKeyString(idOrKeystring));
return Promise.all(identifiers);
} }
/** /**
* Attach listeners for changes to the composition of a given domain object. * Attach listeners for changes to the composition of a given domain object.

View File

@ -35,7 +35,7 @@ export const DEFAULT_SHELVE_DURATIONS = [
value: 900000 value: 900000
}, },
{ {
name: 'Indefinite', name: 'Unlimited',
value: null value: null
} }
]; ];
@ -136,17 +136,21 @@ export default class FaultManagementAPI {
/** /**
* Retrieves the available shelve durations from the provider, or the default durations if the * Retrieves the available shelve durations from the provider, or the default durations if the
* provider does not provide any. * provider does not provide any.
* @returns {ShelveDuration[]} * @returns {ShelveDuration[] | undefined}
*/ */
getShelveDurations() { getShelveDurations() {
return this.provider?.getShelveDurations() ?? DEFAULT_SHELVE_DURATIONS; if (!this.provider) {
return;
}
return this.provider.getShelveDurations?.() ?? DEFAULT_SHELVE_DURATIONS;
} }
} }
/** /**
* @typedef {Object} ShelveDuration * @typedef {Object} ShelveDuration
* @property {string} name - The name of the shelve duration * @property {string} name - The name of the shelve duration
* @property {number|null} value - The value of the shelve duration in milliseconds, or null for indefinite * @property {number|null} value - The value of the shelve duration in milliseconds, or null for unlimited
*/ */
/** /**

View File

@ -27,6 +27,7 @@ import ConflictError from './ConflictError.js';
import InMemorySearchProvider from './InMemorySearchProvider.js'; import InMemorySearchProvider from './InMemorySearchProvider.js';
import InterceptorRegistry from './InterceptorRegistry.js'; import InterceptorRegistry from './InterceptorRegistry.js';
import MutableDomainObject from './MutableDomainObject.js'; import MutableDomainObject from './MutableDomainObject.js';
import { isIdentifier, isKeyString } from './object-utils.js';
import RootObjectProvider from './RootObjectProvider.js'; import RootObjectProvider from './RootObjectProvider.js';
import RootRegistry from './RootRegistry.js'; import RootRegistry from './RootRegistry.js';
import Transaction from './Transaction.js'; import Transaction from './Transaction.js';
@ -742,11 +743,19 @@ export default class ObjectAPI {
* @param {AbortSignal} abortSignal (optional) signal to abort fetch requests * @param {AbortSignal} abortSignal (optional) signal to abort fetch requests
* @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects * @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects
*/ */
async getOriginalPath(identifier, path = [], abortSignal = null) { async getOriginalPath(identifierOrObject, path = [], abortSignal = null) {
const domainObject = await this.get(identifier, abortSignal); let domainObject;
if (isKeyString(identifierOrObject) || isIdentifier(identifierOrObject)) {
domainObject = await this.get(identifierOrObject, abortSignal);
} else {
domainObject = identifierOrObject;
}
if (!domainObject) { if (!domainObject) {
return []; return [];
} }
path.push(domainObject); path.push(domainObject);
const { location } = domainObject; const { location } = domainObject;
if (location && !this.#pathContainsDomainObject(location, path)) { if (location && !this.#pathContainsDomainObject(location, path)) {

View File

@ -250,6 +250,90 @@ export default class TelemetryAPI {
return options; return options;
} }
/**
* Sanitizes objects for consistent serialization by:
* 1. Removing non-plain objects (class instances) and functions
* 2. Sorting object keys alphabetically to ensure consistent ordering
*/
sanitizeForSerialization(key, value) {
// Handle null and primitives directly
if (value === null || typeof value !== 'object') {
return value;
}
// Remove functions and non-plain objects (except arrays)
if (
typeof value === 'function' ||
(Object.getPrototypeOf(value) !== Object.prototype && !Array.isArray(value))
) {
return undefined;
}
// For plain objects, just sort the keys
if (!Array.isArray(value)) {
const sortedObject = {};
const sortedKeys = Object.keys(value).sort();
sortedKeys.forEach((objectKey) => {
sortedObject[objectKey] = value[objectKey];
});
return sortedObject;
}
return value;
}
/**
* Generates a numeric hash value for an options object. The hash is consistent
* for equivalent option objects regardless of property order.
*
* This is used to create compact, unique cache keys for telemetry subscriptions with
* different options configurations. The hash function ensures that identical options
* objects will always generate the same hash value, while different options objects
* (even with small differences) will generate different hash values.
*
* @private
* @param {Object} options The options object to hash
* @returns {number} A positive integer hash of the options object
*/
#hashOptions(options) {
const sanitizedOptionsString = JSON.stringify(
options,
this.sanitizeForSerialization.bind(this)
);
let hash = 0;
const prime = 31;
const modulus = 1e9 + 9; // Large prime number
for (let i = 0; i < sanitizedOptionsString.length; i++) {
const char = sanitizedOptionsString.charCodeAt(i);
// Calculate new hash value while keeping numbers manageable
hash = Math.floor((hash * prime + char) % modulus);
}
return Math.abs(hash);
}
/**
* Generates a unique cache key for a telemetry subscription based on the
* domain object identifier and options (which includes strategy).
*
* Uses a hash of the options object to create compact cache keys while still
* ensuring unique keys for different subscription configurations.
*
* @private
* @param {import('openmct').DomainObject} domainObject The domain object being subscribed to
* @param {Object} options The subscription options object (including strategy)
* @returns {string} A unique key string for caching the subscription
*/
#getSubscriptionCacheKey(domainObject, options) {
const keyString = makeKeyString(domainObject.identifier);
return `${keyString}:${this.#hashOptions(options)}`;
}
/** /**
* Register a request interceptor that transforms a request via module:openmct.TelemetryAPI.request * Register a request interceptor that transforms a request via module:openmct.TelemetryAPI.request
* The request will be modified when it is received and will be returned in it's modified state * The request will be modified when it is received and will be returned in it's modified state
@ -418,16 +502,14 @@ export default class TelemetryAPI {
this.#subscribeCache = {}; this.#subscribeCache = {};
} }
const keyString = makeKeyString(domainObject.identifier);
const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST; const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST;
// Override the requested strategy with the strategy supported by the provider // Override the requested strategy with the strategy supported by the provider
const optionsWithSupportedStrategy = { const optionsWithSupportedStrategy = {
...options, ...options,
strategy: supportedStrategy strategy: supportedStrategy
}; };
// If batching is supported, we need to cache a subscription for each strategy -
// latest and batched. const cacheKey = this.#getSubscriptionCacheKey(domainObject, optionsWithSupportedStrategy);
const cacheKey = `${keyString}:${supportedStrategy}`;
let subscriber = this.#subscribeCache[cacheKey]; let subscriber = this.#subscribeCache[cacheKey];
if (!subscriber) { if (!subscriber) {

View File

@ -359,6 +359,18 @@ class IndependentTimeContext extends TimeContext {
} }
} }
/**
* @returns {boolean}
* @override
*/
isFixed() {
if (this.upstreamTimeContext) {
return this.upstreamTimeContext.isFixed(...arguments);
} else {
return super.isFixed(...arguments);
}
}
/** /**
* @returns {number} * @returns {number}
* @override * @override
@ -400,7 +412,7 @@ class IndependentTimeContext extends TimeContext {
} }
/** /**
* Reset the time context to the global time context * Reset the time context from the global time context
*/ */
resetContext() { resetContext() {
if (this.upstreamTimeContext) { if (this.upstreamTimeContext) {
@ -428,6 +440,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds // Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds()); this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds()); this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from previous time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
} }
/** /**
@ -502,6 +518,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds // Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds()); this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds()); this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from the global time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
// now that the view's context is set, tell others to check theirs in case they were following this view's context. // now that the view's context is set, tell others to check theirs in case they were following this view's context.
this.globalTimeContext.emit('refreshContext', viewKey); this.globalTimeContext.emit('refreshContext', viewKey);
} }

View File

@ -23,6 +23,7 @@
import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants'; import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants';
import IndependentTimeContext from '@/api/time/IndependentTimeContext'; import IndependentTimeContext from '@/api/time/IndependentTimeContext';
import { TIME_CONTEXT_EVENTS } from './constants';
import GlobalTimeContext from './GlobalTimeContext.js'; import GlobalTimeContext from './GlobalTimeContext.js';
/** /**
@ -142,7 +143,7 @@ class TimeAPI extends GlobalTimeContext {
addIndependentContext(key, value, clockKey) { addIndependentContext(key, value, clockKey) {
let timeContext = this.getIndependentContext(key); let timeContext = this.getIndependentContext(key);
//stop following upstream time context since the view has it's own //stop following upstream time context since the view has its own
timeContext.resetContext(); timeContext.resetContext();
if (clockKey) { if (clockKey) {
@ -152,6 +153,9 @@ class TimeAPI extends GlobalTimeContext {
timeContext.setMode(FIXED_MODE_KEY, value); timeContext.setMode(FIXED_MODE_KEY, value);
} }
// Also emit the mode in case it's different from the previous time context
timeContext.emit(TIME_CONTEXT_EVENTS.modeChanged, structuredClone(timeContext.getMode()));
// Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context // Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context
this.emit('refreshContext', key); this.emit('refreshContext', key);

View File

@ -24,6 +24,9 @@ export default function (folderName, couchPlugin, searchFilter) {
location: 'ROOT' location: 'ROOT'
}); });
} }
},
search() {
return Promise.resolve([]);
} }
}); });
@ -35,9 +38,17 @@ export default function (folderName, couchPlugin, searchFilter) {
); );
}, },
load() { load() {
return couchProvider.getObjectsByFilter(searchFilter).then((objects) => { let searchResults;
return objects.map((object) => object.identifier);
}); if (searchFilter.viewName !== undefined) {
// Use a view to search, instead of an _all_docs find
searchResults = couchProvider.getObjectsByView(searchFilter);
} else {
// Use the _find endpoint to search _all_docs
searchResults = couchProvider.getObjectsByFilter(searchFilter);
}
return searchResults;
} }
}); });
}; };

View File

@ -332,7 +332,11 @@ export default {
this.domainObject.configuration.axes.xKey === undefined || this.domainObject.configuration.axes.xKey === undefined ||
this.domainObject.configuration.axes.yKey === undefined this.domainObject.configuration.axes.yKey === undefined
) { ) {
return; const { xKey, yKey } = this.identifyAxesKeys(axisMetadata);
this.openmct.objects.mutate(this.domainObject, 'configuration.axes', {
xKey,
yKey
});
} }
let xValues = []; let xValues = [];
@ -431,6 +435,30 @@ export default {
subscribeToAll() { subscribeToAll() {
const telemetryObjects = Object.values(this.telemetryObjects); const telemetryObjects = Object.values(this.telemetryObjects);
telemetryObjects.forEach(this.subscribeToObject); telemetryObjects.forEach(this.subscribeToObject);
},
identifyAxesKeys(metadata) {
const { xAxisMetadata, yAxisMetadata } = metadata;
let xKey;
let yKey;
// If xAxisMetadata contains array values, use the first one for xKey
const arrayValues = xAxisMetadata.filter((metaDatum) => metaDatum.isArrayValue);
const nonArrayValues = xAxisMetadata.filter((metaDatum) => !metaDatum.isArrayValue);
if (arrayValues.length > 0) {
xKey = arrayValues[0].key;
yKey = arrayValues.length > 1 ? arrayValues[1].key : yAxisMetadata.key;
} else if (nonArrayValues.length > 0) {
xKey = nonArrayValues[0].key;
yKey = 'none';
} else {
// Fallback if no valid xKey or yKey is found
xKey = 'none';
yKey = 'none';
}
return { xKey, yKey };
} }
} }
}; };

View File

@ -160,8 +160,10 @@
</div> </div>
</template> </template>
<div class="c-cdef__separator c-row-separator"></div> <div class="c-cdef__separator c-row-separator"></div>
<div class="c-cdef__controls" :disabled="!telemetry.length"> <div class="c-cdef__controls">
<button <button
:disabled="!telemetry.length"
:aria-label="`Add Criteria - ${!telemetry.length ? 'Disabled' : 'Enabled'}`"
class="c-cdef__add-criteria-button c-button c-button--labeled icon-plus" class="c-cdef__add-criteria-button c-button c-button--labeled icon-plus"
@click="addCriteria" @click="addCriteria"
> >

View File

@ -28,11 +28,7 @@
{ 'is-style-invisible': styleItem.style && styleItem.style.isStyleInvisible }, { 'is-style-invisible': styleItem.style && styleItem.style.isStyleInvisible },
{ 'c-style-thumb--mixed': mixedStyles.indexOf('backgroundColor') > -1 } { 'c-style-thumb--mixed': mixedStyles.indexOf('backgroundColor') > -1 }
]" ]"
:style="[ :style="[encodedImageUrl ? { backgroundImage: 'url(' + encodedImageUrl + ')' } : itemStyle]"
styleItem.style.imageUrl
? { backgroundImage: 'url(' + styleItem.style.imageUrl + ')' }
: itemStyle
]"
class="c-style-thumb" class="c-style-thumb"
> >
<span <span
@ -62,7 +58,7 @@
@change="updateStyleValue" @change="updateStyleValue"
/> />
<ToolbarButton <ToolbarButton
v-if="hasProperty(styleItem.style.imageUrl)" v-if="hasProperty(encodedImageUrl)"
class="c-style__toolbar-button--image-url" class="c-style__toolbar-button--image-url"
:options="imageUrlOption" :options="imageUrlOption"
@change="updateStyleValue" @change="updateStyleValue"
@ -93,6 +89,8 @@ import ToolbarButton from '@/ui/toolbar/components/ToolbarButton.vue';
import ToolbarColorPicker from '@/ui/toolbar/components/ToolbarColorPicker.vue'; import ToolbarColorPicker from '@/ui/toolbar/components/ToolbarColorPicker.vue';
import ToolbarToggleButton from '@/ui/toolbar/components/ToolbarToggleButton.vue'; import ToolbarToggleButton from '@/ui/toolbar/components/ToolbarToggleButton.vue';
import { encode_url } from '../../../../utils/encoding';
export default { export default {
name: 'StyleEditor', name: 'StyleEditor',
components: { components: {
@ -183,11 +181,14 @@ export default {
}, },
property: 'imageUrl', property: 'imageUrl',
formKeys: ['url'], formKeys: ['url'],
value: { url: this.styleItem.style.imageUrl }, value: { url: this.encodedImageUrl },
isEditing: this.isEditing, isEditing: this.isEditing,
nonSpecific: this.mixedStyles.indexOf('imageUrl') > -1 nonSpecific: this.mixedStyles.indexOf('imageUrl') > -1
}; };
}, },
encodedImageUrl() {
return encode_url(this.styleItem.style.imageUrl);
},
isStyleInvisibleOption() { isStyleInvisibleOption() {
return { return {
value: this.styleItem.style.isStyleInvisible, value: this.styleItem.style.isStyleInvisible,

View File

@ -151,9 +151,12 @@ export default class TelemetryCriterion extends EventEmitter {
} }
createNormalizedDatum(telemetryDatum, endpoint) { createNormalizedDatum(telemetryDatum, endpoint) {
if (!telemetryDatum) {
return;
}
const id = this.openmct.objects.makeKeyString(endpoint.identifier); const id = this.openmct.objects.makeKeyString(endpoint.identifier);
const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas; const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas;
const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => { const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => {
const formatter = this.openmct.telemetry.getValueFormatter(metadatum); const formatter = this.openmct.telemetry.getValueFormatter(metadatum);
datum[metadatum.key] = formatter.parse(telemetryDatum[metadatum.source]); datum[metadatum.key] = formatter.parse(telemetryDatum[metadatum.source]);

View File

@ -35,6 +35,7 @@
</template> </template>
<script> <script>
import { encode_url } from '../../../utils/encoding';
import conditionalStylesMixin from '../mixins/objectStyles-mixin.js'; import conditionalStylesMixin from '../mixins/objectStyles-mixin.js';
import LayoutFrame from './LayoutFrame.vue'; import LayoutFrame from './LayoutFrame.vue';
@ -80,12 +81,12 @@ export default {
return this.isEditing || !this.itemStyle?.isStyleInvisible; return this.isEditing || !this.itemStyle?.isStyleInvisible;
}, },
style() { style() {
let backgroundImage = 'url(' + this.item.url + ')'; let backgroundImage = `url('${encode_url(this.item.url)}')`;
let border = '1px solid ' + this.item.stroke; let border = '1px solid ' + this.item.stroke;
if (this.itemStyle) { if (this.itemStyle) {
if (this.itemStyle.imageUrl !== undefined) { if (this.itemStyle.imageUrl !== undefined) {
backgroundImage = 'url(' + this.itemStyle.imageUrl + ')'; backgroundImage = `url('${encode_url(this.itemStyle.imageUrl)}')`;
} }
border = this.itemStyle.border; border = this.itemStyle.border;

View File

@ -109,8 +109,9 @@ class DuplicateAction {
let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier); let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier);
let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier); let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier);
let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier); let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier);
const isLocked = parentCandidate.locked === true;
if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) { if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) {
return false; return false;
} }
@ -139,10 +140,9 @@ class DuplicateAction {
const parentType = parent && this.openmct.types.get(parent.type); const parentType = parent && this.openmct.types.get(parent.type);
const child = objectPath[0]; const child = objectPath[0];
const childType = child && this.openmct.types.get(child.type); const childType = child && this.openmct.types.get(child.type);
const locked = child.locked ? child.locked : parent && parent.locked;
const isPersistable = this.openmct.objects.isPersistable(child.identifier); const isPersistable = this.openmct.objects.isPersistable(child.identifier);
if (locked || !isPersistable) { if (!isPersistable) {
return false; return false;
} }

View File

@ -330,7 +330,8 @@ export default {
} }
shelveData.comment = data.comment || ''; shelveData.comment = data.comment || '';
shelveData.shelveDuration = data.shelveDuration ?? this.shelveDurations[0].value; shelveData.shelveDuration =
data.shelveDuration === undefined ? this.shelveDurations[0].value : data.shelveDuration;
} else { } else {
shelveData = { shelveData = {
shelved: false shelved: false

View File

@ -42,24 +42,6 @@ export const FAULT_MANAGEMENT_TYPE = 'faultManagement';
export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector'; export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector';
export const FAULT_MANAGEMENT_ALARMS = 'alarms'; export const FAULT_MANAGEMENT_ALARMS = 'alarms';
export const FAULT_MANAGEMENT_GLOBAL_ALARMS = 'global-alarm-status'; export const FAULT_MANAGEMENT_GLOBAL_ALARMS = 'global-alarm-status';
export const FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS = [
{
name: '5 Minutes',
value: 300000
},
{
name: '10 Minutes',
value: 600000
},
{
name: '15 Minutes',
value: 900000
},
{
name: 'Indefinite',
value: 0
}
];
export const FAULT_MANAGEMENT_VIEW = 'faultManagement.view'; export const FAULT_MANAGEMENT_VIEW = 'faultManagement.view';
export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy'; export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy';
export const FILTER_ITEMS = ['Standard View', 'Acknowledged', 'Unacknowledged', 'Shelved']; export const FILTER_ITEMS = ['Standard View', 'Acknowledged', 'Unacknowledged', 'Shelved'];

View File

@ -27,9 +27,9 @@ To define a filter, you'll need to add a new `filter` property to the domain obj
singleSelectionThreshold: true, singleSelectionThreshold: true,
comparator: 'equals', comparator: 'equals',
possibleValues: [ possibleValues: [
{ name: 'Apple', value: 'apple' }, { label: 'Apple', value: 'apple' },
{ name: 'Banana', value: 'banana' }, { label: 'Banana', value: 'banana' },
{ name: 'Orange', value: 'orange' } { label: 'Orange', value: 'orange' }
] ]
}] }]
} }

View File

@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction {
const definition = this._getTypeDefinition(object.type); const definition = this._getTypeDefinition(object.type);
const persistable = this.openmct.objects.isPersistable(object.identifier); const persistable = this.openmct.objects.isPersistable(object.identifier);
return persistable && definition && definition.creatable; return persistable && definition && definition.creatable && !object.locked;
} }
invoke(objectPath) { invoke(objectPath) {

View File

@ -649,6 +649,11 @@ export default {
}, },
request(domainObject = this.telemetryObject) { request(domainObject = this.telemetryObject) {
this.metadata = this.openmct.telemetry.getMetadata(domainObject); this.metadata = this.openmct.telemetry.getMetadata(domainObject);
if (!this.metadata) {
return;
}
this.formats = this.openmct.telemetry.getFormatMap(this.metadata); this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
const LimitEvaluator = this.openmct.telemetry.getLimits(domainObject); const LimitEvaluator = this.openmct.telemetry.getLimits(domainObject);
LimitEvaluator.limits().then(this.updateLimits); LimitEvaluator.limits().then(this.updateLimits);

View File

@ -38,7 +38,7 @@
<img <img
ref="img" ref="img"
class="c-thumb__image" class="c-thumb__image"
:src="`${image.thumbnailUrl || image.url}`" :src="imageSrc"
fetchpriority="low" fetchpriority="low"
@load="imageLoadCompleted" @load="imageLoadCompleted"
/> />
@ -54,6 +54,8 @@
</template> </template>
<script> <script>
import { encode_url } from '../../../utils/encoding';
const THUMB_PADDING = 4; const THUMB_PADDING = 4;
const BORDER_WIDTH = 2; const BORDER_WIDTH = 2;
@ -96,6 +98,9 @@ export default {
}; };
}, },
computed: { computed: {
imageSrc() {
return `${encode_url(this.image.thumbnailUrl) || encode_url(this.image.url)}`;
},
ariaLabel() { ariaLabel() {
return `Image thumbnail from ${this.image.formattedTime}${this.showAnnotationIndicator ? ', has annotations' : ''}`; return `Image thumbnail from ${this.image.formattedTime}${this.showAnnotationIndicator ? ', has annotations' : ''}`;
}, },

View File

@ -222,6 +222,7 @@ import { TIME_CONTEXT_EVENTS } from '@/api/time/constants.js';
import imageryData from '@/plugins/imagery/mixins/imageryData.js'; import imageryData from '@/plugins/imagery/mixins/imageryData.js';
import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js'; import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js';
import { encode_url } from '../../../utils/encoding';
import eventHelpers from '../lib/eventHelpers.js'; import eventHelpers from '../lib/eventHelpers.js';
import AnnotationsCanvas from './AnnotationsCanvas.vue'; import AnnotationsCanvas from './AnnotationsCanvas.vue';
import Compass from './Compass/CompassComponent.vue'; import Compass from './Compass/CompassComponent.vue';
@ -364,7 +365,7 @@ export default {
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`, filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
backgroundImage: `${ backgroundImage: `${
this.imageUrl this.imageUrl
? `url(${this.imageUrl}), ? `url(${encode_url(this.imageUrl)}),
repeating-linear-gradient( repeating-linear-gradient(
45deg, 45deg,
transparent, transparent,
@ -789,7 +790,7 @@ export default {
}, },
getVisibleLayerStyles(layer) { getVisibleLayerStyles(layer) {
return { return {
backgroundImage: `url(${layer.source})`, backgroundImage: `url(${encode_url(layer.source)})`,
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${ transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${
this.imageTranslateY / 2 this.imageTranslateY / 2
}px)`, }px)`,

View File

@ -96,6 +96,8 @@ export default {
const createdTimestamp = this.domainObject.created; const createdTimestamp = this.domainObject.created;
const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER; const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER; const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
const locked = this.domainObject.locked;
const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER;
const modifiedTimestamp = this.domainObject.modified const modifiedTimestamp = this.domainObject.modified
? this.domainObject.modified ? this.domainObject.modified
: this.domainObject.created; : this.domainObject.created;
@ -148,6 +150,13 @@ export default {
}); });
} }
if (locked === true) {
details.push({
name: 'Locked By',
value: lockedBy
});
}
if (version) { if (version) {
details.push({ details.push({
name: 'Version', name: 'Version',

View File

@ -344,12 +344,19 @@ export default {
}, },
beforeMount() { beforeMount() {
this.marked = new Marked(); this.marked = new Marked();
this.renderer = new this.marked.Renderer(); this.marked.use({
breaks: true,
extensions: [
{
name: 'link',
renderer: (options) => {
return this.validateLink(options);
}
}
]
});
}, },
mounted() { mounted() {
const originalLinkRenderer = this.renderer.link;
this.renderer.link = this.validateLink.bind(this, originalLinkRenderer);
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400); this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
if (this.$refs.embedsWrapper) { if (this.$refs.embedsWrapper) {
@ -437,10 +444,7 @@ export default {
} }
}, },
convertMarkDownToHtml(text = '') { convertMarkDownToHtml(text = '') {
let markDownHtml = this.marked.parse(text, { let markDownHtml = this.marked.parse(text);
breaks: true,
renderer: this.renderer
});
markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA); markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA);
return markDownHtml; return markDownHtml;
}, },
@ -451,21 +455,19 @@ export default {
this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
}, },
validateLink(originalLinkRenderer, href, title, text) { validateLink(options) {
const { href, text } = options;
try { try {
const domain = new URL(href).hostname; const domain = new URL(href).hostname;
const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => { const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => {
return domain.endsWith(partialDomain); return domain.endsWith(partialDomain);
}); });
if (!urlIsWhitelisted) { if (!urlIsWhitelisted) {
return text; return text;
} }
const linkHtml = originalLinkRenderer.call(this.renderer, href, title, text);
const linkHtmlWithTarget = linkHtml.replace( return `<a class="c-hyperlink" target="_blank" href="${href}">${text}</a>`;
/^<a /,
'<a class="c-hyperlink" target="_blank"'
);
return linkHtmlWithTarget;
} catch (error) { } catch (error) {
// had error parsing this URL, just return the text // had error parsing this URL, just return the text
return text; return text;

View File

@ -434,16 +434,69 @@ class CouchObjectProvider {
return Promise.resolve([]); return Promise.resolve([]);
} }
async getObjectsByView({ designDoc, viewName, keysToSearch }, abortSignal) { async isViewDefined(designDoc, viewName) {
const stringifiedKeys = JSON.stringify(keysToSearch); const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const url = `${this.url}/_design/${designDoc}/_view/${viewName}?keys=${stringifiedKeys}&include_docs=true`; const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
}
/**
* @typedef GetObjectByViewOptions
* @property {String} designDoc the name of the design document that the view belongs to
* @property {String} viewName
* @property {Array.<String>} [keysToSearch] a list of discrete view keys to search for. View keys are not object identifiers.
* @property {String} [startKey] limit the search to a range of keys starting with the provided `startKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {String} [endKey] limit the search to a range of keys ending with the provided `endKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {Number} [limit] limit the number of results returned
* @property {String} [objectIdField] The field (either key or value) to treat as an object key. If provided, include_docs will be set to false in the request, and the field will be used as an object identifier. A bulk request will be used to resolve objects from identifiers
*/
/**
* Return objects based on a call to a view. See https://docs.couchdb.org/en/stable/api/ddoc/views.html.
* @param {GetObjectByViewOptions} options
* @param {AbortSignal} abortSignal
* @returns {Promise<Array.<import('openmct.js').DomainObject>>}
*/
async getObjectsByView(
{ designDoc, viewName, keysToSearch, startKey, endKey, limit, objectIdField },
abortSignal
) {
let stringifiedKeys = JSON.stringify(keysToSearch);
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const requestBody = {};
let requestBodyString;
if (objectIdField === undefined) {
requestBody.include_docs = true;
}
if (limit !== undefined) {
requestBody.limit = limit;
}
if (startKey !== undefined && endKey !== undefined) {
/* spell-checker: disable */
requestBody.startkey = startKey;
requestBody.endkey = endKey;
requestBodyString = JSON.stringify(requestBody);
requestBodyString = requestBodyString.replace('$START_KEY', startKey);
requestBodyString = requestBodyString.replace('$END_KEY', endKey);
/* spell-checker: enable */
} else {
requestBody.keys = stringifiedKeys;
requestBodyString = JSON.stringify(requestBody);
}
let objectModels = []; let objectModels = [];
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortSignal signal: abortSignal,
body: requestBodyString
}); });
if (!response.ok) { if (!response.ok) {
@ -454,6 +507,13 @@ class CouchObjectProvider {
const result = await response.json(); const result = await response.json();
const couchRows = result.rows; const couchRows = result.rows;
if (objectIdField !== undefined) {
const objectIdsToResolve = [];
couchRows.forEach((couchRow) => {
objectIdsToResolve.push(couchRow[objectIdField]);
});
objectModels = Object.values(await this.#bulkGet(objectIdsToResolve), abortSignal);
} else {
couchRows.forEach((couchRow) => { couchRows.forEach((couchRow) => {
const couchDoc = couchRow.doc; const couchDoc = couchRow.doc;
const objectModel = this.#getModel(couchDoc); const objectModel = this.#getModel(couchDoc);
@ -461,6 +521,7 @@ class CouchObjectProvider {
objectModels.push(objectModel); objectModels.push(objectModel);
} }
}); });
}
} catch (error) { } catch (error) {
// do nothing // do nothing
} }

View File

@ -33,7 +33,11 @@ class CouchSearchProvider {
#bulkPromise; #bulkPromise;
#batchIds; #batchIds;
#lastAbortSignal; #lastAbortSignal;
#isSearchByNameViewDefined;
/**
*
* @param {import('./CouchObjectProvider').default} couchObjectProvider
*/
constructor(couchObjectProvider) { constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider; this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
@ -67,7 +71,36 @@ class CouchSearchProvider {
} }
} }
searchForObjects(query, abortSignal) { #isOptimizedSearchByNameSupported() {
let isOptimizedSearchAvailable;
if (this.#isSearchByNameViewDefined === undefined) {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined =
this.couchObjectProvider.isViewDefined('object_names', 'object_names');
} else {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined;
}
return isOptimizedSearchAvailable;
}
async searchForObjects(query, abortSignal) {
const preparedQuery = query.toLowerCase().trim();
const supportsOptimizedSearchByName = await this.#isOptimizedSearchByNameSupported();
if (supportsOptimizedSearchByName) {
return this.couchObjectProvider.getObjectsByView(
{
designDoc: 'object_names',
viewName: 'object_names',
startKey: preparedQuery,
endKey: preparedQuery + `\ufff0`,
objectIdField: 'value',
limit: 1000
},
abortSignal
);
} else {
const filter = { const filter = {
selector: { selector: {
model: { model: {
@ -77,8 +110,8 @@ class CouchSearchProvider {
} }
} }
}; };
return this.couchObjectProvider.getObjectsByFilter(filter);
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); }
} }
async #deferBatchAnnotationSearch() { async #deferBatchAnnotationSearch() {

View File

@ -373,44 +373,6 @@ describe('the plugin', () => {
expect(requestMethod).toEqual('PUT'); expect(requestMethod).toEqual('PUT');
}); });
}); });
describe('implements server-side search', () => {
let mockPromise;
beforeEach(() => {
mockPromise = Promise.resolve({
body: {
getReader() {
return {
read() {
return Promise.resolve({
done: true,
value: undefined
});
}
};
}
}
});
fetch.and.returnValue(mockPromise);
});
it("using Couch's 'find' endpoint", async () => {
await Promise.all(openmct.objects.search('test'));
const requestUrl = fetch.calls.mostRecent().args[0];
// we only want one call to fetch, not 2!
// see https://github.com/nasa/openmct/issues/4667
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestUrl.endsWith('_find')).toBeTrue();
});
it('and supports search by object name', async () => {
await Promise.all(openmct.objects.search('test'));
const requestPayload = JSON.parse(fetch.calls.mostRecent().args[1].body);
expect(requestPayload).toBeDefined();
expect(requestPayload.selector.model.name.$regex).toEqual('(?i)test');
});
});
}); });
describe('the view', () => { describe('the view', () => {

View File

@ -0,0 +1,191 @@
import http from 'http';
import nano from 'nano';
import { parseArgs } from 'util';
const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984';
const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct';
const {
values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass }
} = parseArgs({
options: {
couchUrl: {
type: 'string',
default: COUCH_URL
},
database: {
type: 'string',
short: 'd',
default: COUCH_DB_NAME
},
lock: {
type: 'boolean',
short: 'l'
},
unlock: {
type: 'boolean',
short: 'u'
},
startObjectKeystring: {
type: 'string',
short: 'o',
default: 'mine'
},
user: {
type: 'string'
},
pass: {
type: 'string'
}
}
});
const BATCH_SIZE = 100;
const SOCKET_POOL_SIZE = 100;
const locked = lock === true;
console.info(`Connecting to ${couchUrl}/${database}`);
console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`);
const poolingAgent = new http.Agent({
keepAlive: true,
maxSockets: SOCKET_POOL_SIZE
});
const db = nano({
url: couchUrl,
requestDefaults: {
agent: poolingAgent
}
}).use(database);
db.auth(user, pass);
if (!unlock && !lock) {
throw new Error('Either -l or -u option is required');
}
const startObjectIdentifier = keystringToIdentifier(startObjectKeystring);
const documentBatch = [];
const alreadySeen = new Set();
let updatedDocumentCount = 0;
await processObjectTreeFrom(startObjectIdentifier);
//Persist final batch
await persistBatch();
console.log(`Processed ${updatedDocumentCount} documents`);
function processObjectTreeFrom(parentObjectIdentifier) {
//1. Fetch document for identifier;
return fetchDocument(parentObjectIdentifier)
.then(async (document) => {
if (document !== undefined) {
if (!alreadySeen.has(document._id)) {
alreadySeen.add(document._id);
//2. Lock or unlock object
document.model.locked = locked;
document.model.disallowUnlock = locked;
if (locked) {
document.model.lockedBy = 'script';
} else {
delete document.model.lockedBy;
}
//3. Push document to a batch
documentBatch.push(document);
//4. Persist batch if necessary, reporting failures
await persistBatchIfNeeded();
//5. Repeat for each composee
const composition = document.model.composition || [];
return Promise.all(
composition.map((composee) => {
return processObjectTreeFrom(composee);
})
);
}
}
})
.catch((error) => {
console.log(`Error ${error}`);
});
}
async function fetchDocument(identifierOrKeystring) {
let keystring;
if (typeof identifierOrKeystring === 'object') {
keystring = identifierToKeystring(identifierOrKeystring);
} else {
keystring = identifierOrKeystring;
}
try {
const document = await db.get(keystring);
return document;
} catch (error) {
return undefined;
}
}
function persistBatchIfNeeded() {
if (documentBatch.length >= BATCH_SIZE) {
return persistBatch();
} else {
//Noop - batch is not big enough yet
return;
}
}
async function persistBatch() {
try {
const localBatch = [].concat(documentBatch);
//Immediately clear the shared batch array. This asynchronous process is non-blocking, and
//we don't want to try and persist the same batch multiple times while we are waiting for
//the subsequent bulk operation to complete.
updatedDocumentCount += documentBatch.length;
documentBatch.splice(0, documentBatch.length);
const response = await db.bulk({ docs: localBatch });
if (response instanceof Array) {
response.forEach((r) => {
console.info(JSON.stringify(r));
});
} else {
console.info(JSON.stringify(response));
}
} catch (error) {
if (error instanceof Array) {
error.forEach((e) => console.error(JSON.stringify(e)));
} else {
console.error(`${error.statusCode} - ${error.reason}`);
}
}
}
function keystringToIdentifier(keystring) {
const tokens = keystring.split(':');
if (tokens.length === 2) {
return {
namespace: tokens[0],
key: tokens[1]
};
} else {
return {
namespace: '',
key: tokens[0]
};
}
}
function identifierToKeystring(identifier) {
if (typeof identifier === 'string') {
return identifier;
} else if (typeof identifier === 'object') {
if (identifier.namespace) {
return `${identifier.namespace}:${identifier.key}`;
} else {
return identifier.key;
}
}
}

View File

@ -99,6 +99,48 @@ create_replicator_table() {
add_index_and_views() { add_index_and_views() {
echo "Adding index and views to $OPENMCT_DATABASE_NAME database" echo "Adding index and views to $OPENMCT_DATABASE_NAME database"
# Add object names search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_names/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_names",
"views":{
"object_names":{
"map":"function(doc) { if (doc.model && doc.model.name) { const name = doc.model.name.toLowerCase().trim(); if (name.length > 0) { emit(name, doc._id); const tokens = name.split(/[^a-zA-Z0-9]/); tokens.forEach((token) => { if (token.length > 0) { emit(token, doc._id); } }); } } }"
}
}
}');
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_names"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_names already exists, skipping creation"
else
echo "Unable to create object_names"
echo $response
fi
# Add object types search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_types/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_types",
"views":{
"object_types":{
"map":"function(doc) { if (doc.model && doc.model.type) { const type = doc.model.type.toLowerCase().trim(); if (type.length > 0) { emit(type, null); } } }"
}
}
}')
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_types"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_types already exists, skipping creation"
else
echo "Unable to create object_types"
echo $response
fi
# Add type_tags_index # Add type_tags_index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\ response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
@ -160,6 +202,24 @@ add_index_and_views() {
echo "Unable to create annotation_keystring_index" echo "Unable to create annotation_keystring_index"
echo $response echo $response
fi fi
# Add auth database for locked objects
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \
--header 'Content-Type: application/json' \
--data '{
"_id": "_design/auth",
"language": "javascript",
"validate_doc_update": "function (newDoc, oldDoc, userCtx) { if (userCtx.roles.indexOf('\''_admin'\'') !== -1) { return; } else if (oldDoc === null) { return; } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') { if (oldDoc.model.name !== newDoc.model.name) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; } } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; }}"
}')
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created _design/auth design document for locked objects"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "_design/auth already exists, skipping creation"
else
echo "Unable to create _design/auth"
echo $response
fi
} }
# Main script execution # Main script execution

View File

@ -243,11 +243,12 @@ export default {
if (this.planObject) { if (this.planObject) {
this.showReplacePlanDialog(domainObject); this.showReplacePlanDialog(domainObject);
} else { } else {
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.setupPlan(domainObject); this.setupPlan(domainObject);
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
} }
}, },
handleConfigurationChange(newConfiguration) { handleConfigurationChange(newConfiguration) {
this.configuration = this.planViewConfiguration.getConfiguration();
Object.keys(newConfiguration).forEach((key) => { Object.keys(newConfiguration).forEach((key) => {
this[key] = newConfiguration[key]; this[key] = newConfiguration[key];
}); });
@ -423,7 +424,10 @@ export default {
return currentRow || SWIMLANE_PADDING; return currentRow || SWIMLANE_PADDING;
}, },
generateActivities() { generateActivities() {
const groupNames = getValidatedGroups(this.domainObject, this.planData); if (!this.planObject) {
return;
}
const groupNames = getValidatedGroups(this.planObject, this.planData);
if (!groupNames.length) { if (!groupNames.length) {
return; return;

View File

@ -164,11 +164,13 @@
<div <div
v-show="cursorGuide" v-show="cursorGuide"
ref="cursorGuideVertical" ref="cursorGuideVertical"
aria-label="Vertical cursor guide"
class="c-cursor-guide--v js-cursor-guide--v" class="c-cursor-guide--v js-cursor-guide--v"
></div> ></div>
<div <div
v-show="cursorGuide" v-show="cursorGuide"
ref="cursorGuideHorizontal" ref="cursorGuideHorizontal"
aria-label="Horizontal cursor guide"
class="c-cursor-guide--h js-cursor-guide--h" class="c-cursor-guide--h js-cursor-guide--h"
></div> ></div>
</div> </div>
@ -537,6 +539,7 @@ export default {
this.followTimeContext(); this.followTimeContext();
}, },
followTimeContext() { followTimeContext() {
this.updateMode();
this.updateDisplayBounds(this.timeContext.getBounds()); this.updateDisplayBounds(this.timeContext.getBounds());
this.timeContext.on('modeChanged', this.updateMode); this.timeContext.on('modeChanged', this.updateMode);
this.timeContext.on('boundsChanged', this.updateDisplayBounds); this.timeContext.on('boundsChanged', this.updateDisplayBounds);
@ -854,13 +857,11 @@ export default {
this.canvas = this.$refs.chartContainer.querySelectorAll('canvas')[1]; this.canvas = this.$refs.chartContainer.querySelectorAll('canvas')[1];
if (!this.options.compact) {
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this); this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this); this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this); this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this); this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this); this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
}
}, },
marqueeAnnotations(annotationsToSelect) { marqueeAnnotations(annotationsToSelect) {
@ -1115,6 +1116,7 @@ export default {
this.listenTo(window, 'mouseup', this.onMouseUp, this); this.listenTo(window, 'mouseup', this.onMouseUp, this);
this.listenTo(window, 'mousemove', this.trackMousePosition, this); this.listenTo(window, 'mousemove', this.trackMousePosition, this);
if (!this.options.compact) {
// track frozen state on mouseDown to be read on mouseUp // track frozen state on mouseDown to be read on mouseUp
const isFrozen = const isFrozen =
this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true; this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
@ -1129,6 +1131,7 @@ export default {
} else { } else {
return this.startMarquee(event, false); return this.startMarquee(event, false);
} }
}
}, },
onMouseUp(event) { onMouseUp(event) {
@ -1158,11 +1161,15 @@ export default {
}, },
isMouseClick() { isMouseClick() {
if (!this.marquee) { // We may not have a marquee if we've disabled pan/zoom, but we still need to know if it's a mouse click for highlights and lock points.
if (!this.marquee && !this.positionOverPlot) {
return false; return false;
} }
const { start, end } = this.marquee; const { start, end } = this.marquee ?? {
start: this.positionOverPlot,
end: this.positionOverPlot
};
const someYPositionOverPlot = start.y.some((y) => y); const someYPositionOverPlot = start.y.some((y) => y);
return start.x === end.x && someYPositionOverPlot; return start.x === end.x && someYPositionOverPlot;

View File

@ -162,14 +162,6 @@ export default {
} }
} }
}, },
watch: {
gridLines(newGridLines) {
this.gridLines = newGridLines;
},
cursorGuide(newCursorGuide) {
this.cursorGuide = newCursorGuide;
}
},
created() { created() {
eventHelpers.extend(this); eventHelpers.extend(this);
this.imageExporter = new ImageExporter(this.openmct); this.imageExporter = new ImageExporter(this.openmct);

View File

@ -38,11 +38,11 @@
v-for="(tab, index) in tabsList" v-for="(tab, index) in tabsList"
:ref="tab.keyString" :ref="tab.keyString"
:key="tab.keyString" :key="tab.keyString"
:aria-label="`${tab.domainObject.name} tab`" :aria-label="`${tab.domainObject.name} tab${tab.keyString === currentTab.keyString ? ' - selected' : ''}`"
class="c-tab c-tabs-view__tab js-tab" class="c-tab c-tabs-view__tab js-tab"
role="tab" role="tab"
:class="{ :class="{
'is-current': isCurrent(tab) 'is-current': tab.keyString === currentTab.keyString
}" }"
@click="showTab(tab, index)" @click="showTab(tab, index)"
@mouseover.ctrl="showToolTip(tab)" @mouseover.ctrl="showToolTip(tab)"
@ -74,7 +74,7 @@
:key="tab.keyString" :key="tab.keyString"
:style="getTabStyles(tab)" :style="getTabStyles(tab)"
class="c-tabs-view__object-holder" class="c-tabs-view__object-holder"
:class="{ 'c-tabs-view__object-holder--hidden': !isCurrent(tab) }" :class="{ 'c-tabs-view__object-holder--hidden': tab.keyString !== currentTab.keyString }"
> >
<ObjectView <ObjectView
v-if="shouldLoadTab(tab)" v-if="shouldLoadTab(tab)"
@ -353,7 +353,10 @@ export default {
this.internalDomainObject = domainObject; this.internalDomainObject = domainObject;
}, },
persistCurrentTabIndex(index) { persistCurrentTabIndex(index) {
//only persist if the domain object is not locked. The object mutate API will deal with whether the object is persistable or not.
if (!this.internalDomainObject.locked) {
this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index); this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
}
}, },
storeCurrentTabIndexInURL(index) { storeCurrentTabIndexInURL(index) {
let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey); let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey);

View File

@ -25,7 +25,7 @@ import _ from 'lodash';
import StalenessUtils from '../../utils/staleness.js'; import StalenessUtils from '../../utils/staleness.js';
import TableRowCollection from './collections/TableRowCollection.js'; import TableRowCollection from './collections/TableRowCollection.js';
import { MODE, ORDER } from './constants.js'; import { MODE } from './constants.js';
import TelemetryTableColumn from './TelemetryTableColumn.js'; import TelemetryTableColumn from './TelemetryTableColumn.js';
import TelemetryTableConfiguration from './TelemetryTableConfiguration.js'; import TelemetryTableConfiguration from './TelemetryTableConfiguration.js';
import TelemetryTableNameColumn from './TelemetryTableNameColumn.js'; import TelemetryTableNameColumn from './TelemetryTableNameColumn.js';
@ -130,14 +130,7 @@ export default class TelemetryTable extends EventEmitter {
createTableRowCollections() { createTableRowCollections() {
this.tableRows = new TableRowCollection(); this.tableRows = new TableRowCollection();
//Fetch any persisted default sort const sortOptions = this.configuration.getSortOptions();
let sortOptions = this.configuration.getConfiguration().sortOptions;
//If no persisted sort order, default to sorting by time system, descending.
sortOptions = sortOptions || {
key: this.openmct.time.getTimeSystem().key,
direction: ORDER.DESCENDING
};
this.updateRowLimit(); this.updateRowLimit();
@ -172,8 +165,8 @@ export default class TelemetryTable extends EventEmitter {
this.removeTelemetryCollection(keyString); this.removeTelemetryCollection(keyString);
let sortOptions = this.configuration.getConfiguration().sortOptions; let sortOptions = this.configuration.getSortOptions();
requestOptions.order = sortOptions?.direction ?? ORDER.DESCENDING; // default to descending requestOptions.order = sortOptions.direction;
if (this.telemetryMode === MODE.PERFORMANCE) { if (this.telemetryMode === MODE.PERFORMANCE) {
requestOptions.size = this.rowLimit; requestOptions.size = this.rowLimit;
@ -442,12 +435,13 @@ export default class TelemetryTable extends EventEmitter {
} }
sortBy(sortOptions) { sortBy(sortOptions) {
this.tableRows.sortBy(sortOptions); this.configuration.setSortOptions(sortOptions);
if (this.openmct.editor.isEditing()) { if (this.telemetryMode === MODE.PERFORMANCE) {
let configuration = this.configuration.getConfiguration(); this.tableRows.setSortOptions(sortOptions);
configuration.sortOptions = sortOptions; this.clearAndResubscribe();
this.configuration.updateConfiguration(configuration); } else {
this.tableRows.sortBy(sortOptions);
} }
} }

View File

@ -23,7 +23,11 @@
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import _ from 'lodash'; import _ from 'lodash';
import { ORDER } from './constants';
export default class TelemetryTableConfiguration extends EventEmitter { export default class TelemetryTableConfiguration extends EventEmitter {
#sortOptions;
constructor(domainObject, openmct, options) { constructor(domainObject, openmct, options) {
super(); super();
@ -44,6 +48,26 @@ export default class TelemetryTableConfiguration extends EventEmitter {
this.notPersistable = !this.openmct.objects.isPersistable(this.domainObject.identifier); this.notPersistable = !this.openmct.objects.isPersistable(this.domainObject.identifier);
} }
getSortOptions() {
return (
this.#sortOptions ||
this.getConfiguration().sortOptions || {
key: this.openmct.time.getTimeSystem().key,
direction: ORDER.DESCENDING
}
);
}
setSortOptions(sortOptions) {
this.#sortOptions = sortOptions;
if (this.openmct.editor.isEditing()) {
let configuration = this.getConfiguration();
configuration.sortOptions = sortOptions;
this.updateConfiguration(configuration);
}
}
getConfiguration() { getConfiguration() {
let configuration = this.domainObject.configuration || {}; let configuration = this.domainObject.configuration || {};
configuration.hiddenColumns = configuration.hiddenColumns || {}; configuration.hiddenColumns = configuration.hiddenColumns || {};

View File

@ -91,15 +91,19 @@ export default class TelemetryTableRow {
return [VIEW_DATUM_ACTION_KEY, VIEW_HISTORICAL_DATA_ACTION_KEY]; return [VIEW_DATUM_ACTION_KEY, VIEW_HISTORICAL_DATA_ACTION_KEY];
} }
updateWithDatum(updatesToDatum) { /**
const normalizedUpdatesToDatum = createNormalizedDatum(updatesToDatum, this.columns); * Merges the row parameter's datum with the current row datum
* @param {TelemetryTableRow} row
*/
updateWithDatum(row) {
this.datum = { this.datum = {
...this.datum, ...this.datum,
...normalizedUpdatesToDatum ...row.datum
}; };
this.fullDatum = { this.fullDatum = {
...this.fullDatum, ...this.fullDatum,
...updatesToDatum ...row.fullDatum
}; };
} }
} }

View File

@ -23,6 +23,11 @@ import { EventEmitter } from 'eventemitter3';
import _ from 'lodash'; import _ from 'lodash';
import { ORDER } from '../constants.js'; import { ORDER } from '../constants.js';
/**
* @typedef {import('.TelemetryTableRow.js').default} TelemetryTableRow
*/
/** /**
* @constructor * @constructor
*/ */
@ -124,10 +129,22 @@ export default class TableRowCollection extends EventEmitter {
return foundIndex; return foundIndex;
} }
updateRowInPlace(row, index) { /**
const foundRow = this.rows[index]; * `incomingRow` exists in the collection,
foundRow.updateWithDatum(row.datum); * so merge existing and incoming row properties
this.rows[index] = foundRow; *
* Do to reactivity of Vue, we want to replace the existing row with the updated row
* @param {TelemetryTableRow} incomingRow to update
* @param {number} index of the existing row in the collection to update
*/
updateRowInPlace(incomingRow, index) {
// Update the incoming row, not the existing row
const existingRow = this.rows[index];
incomingRow.updateWithDatum(existingRow);
// Replacing the existing row with the updated, incoming row will trigger Vue reactivity
// because the reference to the row has changed
this.rows.splice(index, 1, incomingRow);
} }
setLimit(rowLimit) { setLimit(rowLimit) {
@ -273,7 +290,7 @@ export default class TableRowCollection extends EventEmitter {
*/ */
sortBy(sortOptions) { sortBy(sortOptions) {
if (arguments.length > 0) { if (arguments.length > 0) {
this.sortOptions = sortOptions; this.setSortOptions(sortOptions);
this.rows = _.orderBy( this.rows = _.orderBy(
this.rows, this.rows,
(row) => row.getParsedValue(sortOptions.key), (row) => row.getParsedValue(sortOptions.key),
@ -286,6 +303,10 @@ export default class TableRowCollection extends EventEmitter {
return Object.assign({}, this.sortOptions); return Object.assign({}, this.sortOptions);
} }
setSortOptions(sortOptions) {
this.sortOptions = sortOptions;
}
setColumnFilter(columnKey, filter) { setColumnFilter(columnKey, filter) {
filter = filter.trim().toLowerCase(); filter = filter.trim().toLowerCase();
let wasBlank = this.columnFilters[columnKey] === undefined; let wasBlank = this.columnFilters[columnKey] === undefined;

View File

@ -373,7 +373,6 @@ export default {
configuredColumnWidths: configuration.columnWidths, configuredColumnWidths: configuration.columnWidths,
sizingRows: {}, sizingRows: {},
rowHeight: ROW_HEIGHT, rowHeight: ROW_HEIGHT,
scrollOffset: 0,
totalHeight: 0, totalHeight: 0,
totalWidth: 0, totalWidth: 0,
rowOffset: 0, rowOffset: 0,
@ -552,6 +551,7 @@ export default {
//Default sort //Default sort
this.sortOptions = this.table.tableRows.sortBy(); this.sortOptions = this.table.tableRows.sortBy();
this.scrollable = this.$refs.scrollable; this.scrollable = this.$refs.scrollable;
this.lastScrollLeft = this.scrollable.scrollLeft;
this.contentTable = this.$refs.contentTable; this.contentTable = this.$refs.contentTable;
this.sizingTable = this.$refs.sizingTable; this.sizingTable = this.$refs.sizingTable;
this.headersHolderEl = this.$refs.headersHolderEl; this.headersHolderEl = this.$refs.headersHolderEl;
@ -740,7 +740,9 @@ export default {
this.table.sortBy(this.sortOptions); this.table.sortBy(this.sortOptions);
}, },
scroll() { scroll() {
if (this.lastScrollLeft === this.scrollable.scrollLeft) {
this.throttledUpdateVisibleRows(); this.throttledUpdateVisibleRows();
}
this.synchronizeScrollX(); this.synchronizeScrollX();
if (this.shouldAutoScroll()) { if (this.shouldAutoScroll()) {
@ -765,6 +767,8 @@ export default {
this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER; this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER;
}, },
synchronizeScrollX() { synchronizeScrollX() {
this.lastScrollLeft = this.scrollable.scrollLeft;
if (this.$refs.headersHolderEl && this.scrollable) { if (this.$refs.headersHolderEl && this.scrollable) {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft; this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
} }

View File

@ -11,18 +11,19 @@
> >
<input <input
ref="startDate" ref="startDate"
v-model="formattedBounds.start" v-model="formattedBounds.startDate"
class="c-input--datetime" class="c-input--datetime"
type="text" type="text"
autocorrect="off" autocorrect="off"
spellcheck="false" spellcheck="false"
aria-label="Start date" aria-label="Start date"
@input="validateAllBounds('startDate')" @input="validateInput('startDate')"
@change="reportValidity('startDate')"
/> />
<DatePicker <DatePicker
v-if="isUTCBased" v-if="isUTCBased"
class="c-ctrl-wrapper--menus-right" class="c-ctrl-wrapper--menus-right"
:default-date-time="formattedBounds.start" :default-date-time="formattedBounds.startDate"
:formatter="timeFormatter" :formatter="timeFormatter"
@date-selected="startDateSelected" @date-selected="startDateSelected"
/> />
@ -37,7 +38,8 @@
autocorrect="off" autocorrect="off"
spellcheck="false" spellcheck="false"
aria-label="Start time" aria-label="Start time"
@input="validateAllBounds('startDate')" @input="validateInput('startTime')"
@change="reportValidity('startTime')"
/> />
</div> </div>
@ -48,18 +50,19 @@
> >
<input <input
ref="endDate" ref="endDate"
v-model="formattedBounds.end" v-model="formattedBounds.endDate"
class="c-input--datetime" class="c-input--datetime"
type="text" type="text"
autocorrect="off" autocorrect="off"
spellcheck="false" spellcheck="false"
aria-label="End date" aria-label="End date"
@input="validateAllBounds('endDate')" @input="validateInput('endDate')"
@change="reportValidity('endDate')"
/> />
<DatePicker <DatePicker
v-if="isUTCBased" v-if="isUTCBased"
class="c-ctrl-wrapper--menus-left" class="c-ctrl-wrapper--menus-left"
:default-date-time="formattedBounds.end" :default-date-time="formattedBounds.endDate"
:formatter="timeFormatter" :formatter="timeFormatter"
@date-selected="endDateSelected" @date-selected="endDateSelected"
/> />
@ -74,14 +77,15 @@
autocorrect="off" autocorrect="off"
spellcheck="false" spellcheck="false"
aria-label="End time" aria-label="End time"
@input="validateAllBounds('endDate')" @input="validateInput('endTime')"
@change="reportValidity('endTime')"
/> />
</div> </div>
<div class="pr-time-input pr-time-input--buttons"> <div class="pr-time-input pr-time-input--buttons">
<button <button
class="c-button c-button--major icon-check" class="c-button c-button--major icon-check"
:disabled="isDisabled" :disabled="hasInputValidityError"
aria-label="Submit time bounds" aria-label="Submit time bounds"
@click.prevent="handleFormSubmission(true)" @click.prevent="handleFormSubmission(true)"
></button> ></button>
@ -125,6 +129,7 @@ export default {
return { return {
timeFormatter: this.getFormatter(timeSystem.timeFormat), timeFormatter: this.getFormatter(timeSystem.timeFormat),
durationFormatter: this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER), durationFormatter: this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER),
timeSystemKey: timeSystem.key,
bounds: { bounds: {
start: bounds.start, start: bounds.start,
end: bounds.end end: bounds.end
@ -136,9 +141,29 @@ export default {
endTime: '' endTime: ''
}, },
isUTCBased: timeSystem.isUTCBased, isUTCBased: timeSystem.isUTCBased,
isDisabled: false inputValidityMap: {
startDate: { valid: true },
startTime: { valid: true },
endDate: { valid: true },
endTime: { valid: true }
},
logicalValidityMap: {
limit: { valid: true },
bounds: { valid: true }
}
}; };
}, },
computed: {
hasInputValidityError() {
return Object.values(this.inputValidityMap).some((isValid) => !isValid.valid);
},
hasLogicalValidationErrors() {
return Object.values(this.logicalValidityMap).some((isValid) => !isValid.valid);
},
isValid() {
return !this.hasInputValidityError && !this.hasLogicalValidationErrors;
}
},
watch: { watch: {
inputBounds: { inputBounds: {
handler(newBounds) { handler(newBounds) {
@ -168,25 +193,17 @@ export default {
this.setBounds(bounds); this.setBounds(bounds);
this.setViewFromBounds(bounds); this.setViewFromBounds(bounds);
}, },
clearAllValidation() {
[this.$refs.startDate, this.$refs.endDate].forEach(this.clearValidationForInput);
},
clearValidationForInput(input) {
if (input) {
input.setCustomValidity('');
input.title = '';
}
},
setBounds(bounds) { setBounds(bounds) {
this.bounds = bounds; this.bounds = bounds;
}, },
setViewFromBounds(bounds) { setViewFromBounds(bounds) {
this.formattedBounds.start = this.timeFormatter.format(bounds.start).split(' ')[0]; this.formattedBounds.startDate = this.timeFormatter.format(bounds.start).split(' ')[0];
this.formattedBounds.end = this.timeFormatter.format(bounds.end).split(' ')[0]; this.formattedBounds.endDate = this.timeFormatter.format(bounds.end).split(' ')[0];
this.formattedBounds.startTime = this.durationFormatter.format(Math.abs(bounds.start)); this.formattedBounds.startTime = this.durationFormatter.format(Math.abs(bounds.start));
this.formattedBounds.endTime = this.durationFormatter.format(Math.abs(bounds.end)); this.formattedBounds.endTime = this.durationFormatter.format(Math.abs(bounds.end));
}, },
setTimeSystem(timeSystem) { setTimeSystem(timeSystem) {
this.timeSystemKey = timeSystem.key;
this.timeFormatter = this.getFormatter(timeSystem.timeFormat); this.timeFormatter = this.getFormatter(timeSystem.timeFormat);
this.durationFormatter = this.getFormatter( this.durationFormatter = this.getFormatter(
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER
@ -201,10 +218,10 @@ export default {
setBoundsFromView(dismiss) { setBoundsFromView(dismiss) {
if (this.$refs.fixedDeltaInput.checkValidity()) { if (this.$refs.fixedDeltaInput.checkValidity()) {
let start = this.timeFormatter.parse( let start = this.timeFormatter.parse(
`${this.formattedBounds.start} ${this.formattedBounds.startTime}` `${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
); );
let end = this.timeFormatter.parse( let end = this.timeFormatter.parse(
`${this.formattedBounds.end} ${this.formattedBounds.endTime}` `${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
); );
this.$emit('update', { start, end }); this.$emit('update', { start, end });
@ -215,96 +232,93 @@ export default {
return false; return false;
} }
}, },
handleFormSubmission(shouldDismiss) { clearAllValidation() {
this.validateAllBounds('startDate'); Object.keys(this.inputValidityMap).forEach(this.clearValidation);
this.validateAllBounds('endDate'); },
clearValidation(refName) {
const input = this.getInput(refName);
if (!this.isDisabled) { input.setCustomValidity('');
input.title = '';
},
handleFormSubmission(shouldDismiss) {
this.validateLimit();
this.reportValidity('limit');
this.validateBounds();
this.reportValidity('bounds');
if (this.isValid) {
this.setBoundsFromView(shouldDismiss); this.setBoundsFromView(shouldDismiss);
} }
}, },
validateAllBounds(ref) { validateInput(refName) {
this.isDisabled = false; this.clearAllValidation();
if (!this.areBoundsFormatsValid()) { const inputType = refName.includes('Date') ? 'Date' : 'Time';
this.isDisabled = true; const formatter = inputType === 'Date' ? this.timeFormatter : this.durationFormatter;
return false; const validationResult = formatter.validate(this.formattedBounds[refName])
} ? { valid: true }
: { valid: false, message: `Invalid ${inputType}` };
let validationResult = { valid: true }; this.inputValidityMap[refName] = validationResult;
const currentInput = this.$refs[ref]; },
validateBounds() {
return [this.$refs.startDate, this.$refs.endDate].every((input) => { const bounds = {
let boundsValues = {
start: this.timeFormatter.parse( start: this.timeFormatter.parse(
`${this.formattedBounds.start} ${this.formattedBounds.startTime}` `${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
), ),
end: this.timeFormatter.parse( end: this.timeFormatter.parse(
`${this.formattedBounds.end} ${this.formattedBounds.endTime}` `${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
) )
}; };
//TODO: Do we need limits here? We have conductor limits disabled right now
// const limit = this.getBoundsLimit();
const limit = false;
if (this.isUTCBased && limit && boundsValues.end - boundsValues.start > limit) { this.logicalValidityMap.bounds = this.openmct.time.validateBounds(bounds);
if (input === currentInput) { },
validationResult = { validateLimit(bounds) {
const limit = this.configuration?.menuOptions
?.filter((option) => option.timeSystem === this.timeSystemKey)
?.find((option) => option.limit)?.limit;
if (this.isUTCBased && limit && bounds.end - bounds.start > limit) {
this.logicalValidityMap.limit = {
valid: false, valid: false,
message: 'Start and end difference exceeds allowable limit' message: 'Start and end difference exceeds allowable limit'
}; };
} else {
this.logicalValidityMap.limit = { valid: true };
} }
} else if (input === currentInput) {
validationResult = this.openmct.time.validateBounds(boundsValues);
}
return this.handleValidationResults(input, validationResult);
});
}, },
areBoundsFormatsValid() { reportValidity(refName) {
return [this.$refs.startDate, this.$refs.endDate].every((input) => { const input = this.getInput(refName);
const formattedDate = const validationResult = this.inputValidityMap[refName] ?? this.logicalValidityMap[refName];
input === this.$refs.startDate
? `${this.formattedBounds.start} ${this.formattedBounds.startTime}`
: `${this.formattedBounds.end} ${this.formattedBounds.endTime}`;
const validationResult = this.timeFormatter.validate(formattedDate)
? { valid: true }
: { valid: false, message: 'Invalid date' };
return this.handleValidationResults(input, validationResult);
});
},
getBoundsLimit() {
const configuration = this.configuration.menuOptions
.filter((option) => option.timeSystem === this.timeSystem.key)
.find((option) => option.limit);
const limit = configuration ? configuration.limit : undefined;
return limit;
},
handleValidationResults(input, validationResult) {
if (validationResult.valid !== true) { if (validationResult.valid !== true) {
input.setCustomValidity(validationResult.message); input.setCustomValidity(validationResult.message);
input.title = validationResult.message; input.title = validationResult.message;
this.isDisabled = true; this.hasLogicalValidationErrors = true;
} else { } else {
input.setCustomValidity(''); input.setCustomValidity('');
input.title = ''; input.title = '';
} }
this.$refs.fixedDeltaInput.reportValidity(); this.$refs.fixedDeltaInput.reportValidity();
},
getInput(refName) {
if (Object.keys(this.inputValidityMap).includes(refName)) {
return this.$refs[refName];
}
return validationResult.valid; return this.$refs.startDate;
}, },
startDateSelected(date) { startDateSelected(date) {
this.formattedBounds.start = this.timeFormatter.format(date).split(' ')[0]; this.formattedBounds.startDate = this.timeFormatter.format(date).split(' ')[0];
this.validateAllBounds('startDate'); this.validateInput('startDate');
this.reportValidity('startDate');
}, },
endDateSelected(date) { endDateSelected(date) {
this.formattedBounds.end = this.timeFormatter.format(date).split(' ')[0]; this.formattedBounds.endDate = this.timeFormatter.format(date).split(' ')[0];
this.validateAllBounds('endDate'); this.validateInput('endDate');
this.reportValidity('endDate');
}, },
hide($event) { hide($event) {
if ($event.target.className.indexOf('c-button icon-x') > -1) { if ($event.target.className.indexOf('c-button icon-x') > -1) {

View File

@ -243,12 +243,20 @@ export default {
this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode); this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode);
}, },
setTimeOptionsClock(clock) { setTimeOptionsClock(clock) {
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
return;
}
this.setTimeOptionsOffsets(); this.setTimeOptionsOffsets();
this.timeOptions.clock = clock.key; this.timeOptions.clock = clock.key;
}, },
setTimeOptionsMode(mode) { setTimeOptionsMode(mode) {
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
this.setTimeOptionsOffsets(); this.setTimeOptionsOffsets();
this.timeOptions.mode = mode; this.timeOptions.mode = mode;
this.isFixed = this.timeOptions.mode === FIXED_MODE_KEY;
}
}, },
setTimeOptionsOffsets() { setTimeOptionsOffsets() {
this.timeOptions.clockOffsets = this.timeOptions.clockOffsets =

View File

@ -436,6 +436,9 @@ export default {
return startInBounds || endInBounds || middleInBounds; return startInBounds || endInBounds || middleInBounds;
}, },
isActivityInProgress(activity) {
return this.persistedActivityStates[activity.id] === 'in-progress';
},
filterActivities(activity) { filterActivities(activity) {
if (this.isEditing) { if (this.isEditing) {
return true; return true;
@ -460,7 +463,8 @@ export default {
return false; return false;
} }
if (!this.isActivityInBounds(activity)) { // An activity may be out of bounds, but if it is in-progress, we show it.
if (!this.isActivityInBounds(activity) && !this.isActivityInProgress(activity)) {
return false; return false;
} }
//current event or future start event or past end event //current event or future start event or past end event

View File

@ -21,7 +21,9 @@
--> -->
<template> <template>
<div ref="axisHolder" class="c-timesystem-axis"> <div ref="axisHolder" class="c-timesystem-axis">
<div class="nowMarker" :style="nowMarkerStyle"><span class="icon-arrow-down"></span></div> <div class="nowMarker" :style="nowMarkerStyle" aria-label="Now Marker">
<span class="icon-arrow-down"></span>
</div>
<svg :width="svgWidth" :height="svgHeight"> <svg :width="svgWidth" :height="svgHeight">
<g class="axis" font-size="1.3em" :transform="axisTransform"></g> <g class="axis" font-size="1.3em" :transform="axisTransform"></g>
</svg> </svg>
@ -79,6 +81,7 @@ export default {
const svgWidth = ref(0); const svgWidth = ref(0);
const svgHeight = ref(0); const svgHeight = ref(0);
const axisTransform = ref('translate(0,20)'); const axisTransform = ref('translate(0,20)');
const alignmentOffset = ref(0);
const nowMarkerStyle = reactive({ const nowMarkerStyle = reactive({
height: '0px', height: '0px',
left: '0px' left: '0px'
@ -100,6 +103,7 @@ export default {
svgWidth, svgWidth,
svgHeight, svgHeight,
axisTransform, axisTransform,
alignmentOffset,
nowMarkerStyle, nowMarkerStyle,
openmct openmct
}; };
@ -114,8 +118,10 @@ export default {
this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`; this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`;
const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0; const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0;
this.leftAlignmentOffset = this.alignmentData.leftWidth + leftOffset;
this.alignmentOffset = this.alignmentOffset =
this.alignmentData.leftWidth + leftOffset + this.alignmentData.rightWidth + rightOffset; this.leftAlignmentOffset + this.alignmentData.rightWidth + rightOffset;
this.refresh(); this.refresh();
}, },
deep: true deep: true
@ -173,8 +179,8 @@ export default {
this.nowMarkerStyle.height = this.contentHeight + 'px'; this.nowMarkerStyle.height = this.contentHeight + 'px';
const nowTimeStamp = this.openmct.time.now(); const nowTimeStamp = this.openmct.time.now();
const now = this.xScale(nowTimeStamp); const now = this.xScale(nowTimeStamp);
this.nowMarkerStyle.left = `${now + this.alignmentOffset}px`; this.nowMarkerStyle.left = `${now + this.leftAlignmentOffset}px`;
if (now > this.width) { if (now < 0 || now > this.width) {
nowMarker.classList.add('hidden'); nowMarker.classList.add('hidden');
} }
} }

View File

@ -32,6 +32,7 @@
<script> <script>
import mount from 'utils/mount'; import mount from 'utils/mount';
import { encode_url } from '../../utils/encoding';
import AboutDialog from './AboutDialog.vue'; import AboutDialog from './AboutDialog.vue';
export default { export default {
@ -39,7 +40,7 @@ export default {
mounted() { mounted() {
const branding = this.openmct.branding(); const branding = this.openmct.branding();
if (branding.smallLogoImage) { if (branding.smallLogoImage) {
this.$refs.aboutLogo.style.backgroundImage = `url('${branding.smallLogoImage}')`; this.$refs.aboutLogo.style.backgroundImage = `url('${encode_url(branding.smallLogoImage)}')`;
} }
}, },
methods: { methods: {

View File

@ -20,10 +20,11 @@
at runtime from the About dialog for additional information. at runtime from the About dialog for additional information.
--> -->
<template> <template>
<div class="l-browse-bar"> <div class="l-browse-bar" aria-label="Browse bar">
<div class="l-browse-bar__start"> <div class="l-browse-bar__start">
<button <button
v-if="hasParent" v-if="hasParent"
aria-label="Navigate up to parent"
class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-arrow-nav-to-parent" class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-arrow-nav-to-parent"
title="Navigate up to parent" title="Navigate up to parent"
@click="goToParent" @click="goToParent"
@ -34,9 +35,10 @@
</div> </div>
<span <span
ref="objectName" ref="objectName"
aria-label="Browse bar object name"
class="l-browse-bar__object-name c-object-label__name" class="l-browse-bar__object-name c-object-label__name"
:class="{ 'c-input-inline': isPersistable }" :class="{ 'c-input-inline': isPersistable }"
:contenteditable="isPersistable" :contenteditable="isNameEditable"
@blur="updateName" @blur="updateName"
@keydown.enter.prevent @keydown.enter.prevent
@keyup.enter.prevent="updateNameOnEnterKeyPress" @keyup.enter.prevent="updateNameOnEnterKeyPress"
@ -78,7 +80,7 @@
></button> ></button>
<button <button
v-if="isViewEditable & !isEditing" v-if="shouldShowLock"
:aria-label="lockedOrUnlockedTitle" :aria-label="lockedOrUnlockedTitle"
:title="lockedOrUnlockedTitle" :title="lockedOrUnlockedTitle"
:class="{ :class="{
@ -88,6 +90,13 @@
@click="toggleLock(!domainObject.locked)" @click="toggleLock(!domainObject.locked)"
></button> ></button>
<span
v-else-if="domainObject?.locked"
class="icon-lock"
aria-label="Locked for editing, cannot be unlocked."
title="Locked for editing, cannot be unlocked."
></span>
<button <button
v-if="isViewEditable && !isEditing && !domainObject.locked" v-if="isViewEditable && !isEditing && !domainObject.locked"
class="l-browse-bar__actions__edit c-button c-button--major icon-pencil" class="l-browse-bar__actions__edit c-button c-button--major icon-pencil"
@ -180,6 +189,18 @@ export default {
}; };
}, },
computed: { computed: {
isNameEditable() {
return this.isPersistable && !this.domainObject.locked;
},
shouldShowLock() {
if (this.domainObject === undefined) {
return false;
}
if (this.domainObject.disallowUnlock) {
return false;
}
return this.domainObject.locked || (this.isViewEditable && !this.isEditing);
},
statusClass() { statusClass() {
return this.status ? `is-status--${this.status}` : ''; return this.status ? `is-status--${this.status}` : '';
}, },
@ -253,11 +274,19 @@ export default {
return false; return false;
}, },
lockedOrUnlockedTitle() { lockedOrUnlockedTitle() {
let title;
if (this.domainObject.locked) { if (this.domainObject.locked) {
return 'Locked for editing - click to unlock.'; if (this.domainObject.lockedBy !== undefined) {
title = `Locked for editing by ${this.domainObject.lockedBy}. `;
} else { } else {
return 'Unlocked for editing - click to lock.'; title = 'Locked for editing. ';
} }
title += 'Click to unlock.';
} else {
title = 'Unlocked for editing, click to lock.';
}
return title;
}, },
domainObjectName() { domainObjectName() {
return this.domainObject?.name ?? ''; return this.domainObject?.name ?? '';
@ -288,7 +317,6 @@ export default {
document.addEventListener('click', this.closeViewAndSaveMenu); document.addEventListener('click', this.closeViewAndSaveMenu);
this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this); this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway); window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
this.openmct.editor.on('isEditing', (isEditing) => { this.openmct.editor.on('isEditing', (isEditing) => {
this.isEditing = isEditing; this.isEditing = isEditing;
}); });
@ -421,8 +449,27 @@ export default {
this.actionCollection.off('update', this.updateActionItems); this.actionCollection.off('update', this.updateActionItems);
delete this.actionCollection; delete this.actionCollection;
}, },
toggleLock(flag) { async toggleLock(flag) {
if (!this.domainObject.disallowUnlock) {
const wasTransactionActive = this.openmct.objects.isTransactionActive();
let transaction;
if (!wasTransactionActive) {
transaction = this.openmct.objects.startTransaction();
}
this.openmct.objects.mutate(this.domainObject, 'locked', flag); this.openmct.objects.mutate(this.domainObject, 'locked', flag);
const user = await this.openmct.user.getCurrentUser();
if (user !== undefined) {
this.openmct.objects.mutate(this.domainObject, 'lockedBy', user.id);
}
if (!wasTransactionActive) {
await transaction.commit();
this.openmct.objects.endTransaction();
}
}
}, },
setStatus(status) { setStatus(status) {
this.status = status; this.status = status;

View File

@ -111,9 +111,8 @@ export default {
return null; return null;
} }
const keyStringForObject = this.openmct.objects.makeKeyString(domainObject.identifier);
const originalPathObjects = await this.openmct.objects.getOriginalPath( const originalPathObjects = await this.openmct.objects.getOriginalPath(
keyStringForObject, domainObject,
[], [],
abortSignal abortSignal
); );

View File

@ -80,13 +80,11 @@ class Browse {
this.#openmct.layout.$refs.browseBar.viewKey = viewProvider.key; this.#openmct.layout.$refs.browseBar.viewKey = viewProvider.key;
} }
#updateDocumentTitleOnNameMutation(newName) { #handleBrowseObjectUpdate(newObject) {
if (typeof newName === 'string' && newName !== document.title) { this.#openmct.layout.$refs.browseBar.domainObject = newObject;
document.title = newName;
this.#openmct.layout.$refs.browseBar.domainObject = { if (typeof newObject.name === 'string' && newObject.name !== document.title) {
...this.#openmct.layout.$refs.browseBar.domainObject, document.title = newObject.name;
name: newName
};
} }
} }
@ -120,8 +118,8 @@ class Browse {
document.title = this.#browseObject.name; //change document title to current object in main view document.title = this.#browseObject.name; //change document title to current object in main view
this.#unobserve = this.#openmct.objects.observe( this.#unobserve = this.#openmct.objects.observe(
this.#browseObject, this.#browseObject,
'name', '*',
this.#updateDocumentTitleOnNameMutation.bind(this) this.#handleBrowseObjectUpdate.bind(this)
); );
const currentProvider = this.#openmct.objectViews.getByProviderKey(currentViewKey); const currentProvider = this.#openmct.objectViews.getByProviderKey(currentViewKey);
if (currentProvider && currentProvider.canView(this.#browseObject, this.#openmct.router.path)) { if (currentProvider && currentProvider.canView(this.#browseObject, this.#openmct.router.path)) {

25
src/utils/encoding.js Normal file
View File

@ -0,0 +1,25 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, 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 function encode_url(url) {
return url ? encodeURI(url) : url;
}

12
src/utils/random.js Normal file
View File

@ -0,0 +1,12 @@
/**
* Generates a pseudo-random number based on a seed.
*
* @param {number} seed - The seed value to generate the random number.
* @returns {number} A pseudo-random number between 0 (inclusive) and 1 (exclusive).
*/
function seededRandom(seed = Date.now()) {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
export { seededRandom };