Compare commits
	
		
			84 Commits
		
	
	
		
			v2.1.1
			...
			mct5867-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 3ba514e551 | ||
|   | 739d55b357 | ||
|   | d54335d21c | ||
|   | e0ed0bb6e2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ed3fd8f965 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e6d59c61d1 | ||
|   | b74b27c464 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d35e161701 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 653cb62f9c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 19b3232fa0 | ||
|   | 19892aab53 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a168ce25cf | ||
|   | 189c58f952 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0dfc028e1b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 77e93f1aee | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 394fbbe61b | ||
|   | 40afb04f0c | ||
|   | be73b0158a | ||
|   | 625205f24b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a706a8b73e | ||
|   | 1ddf5e5137 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a79646a915 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d5266e7ac7 | ||
|   | 581d4fb2d1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 05de7ee2e0 | ||
|   | 41a6b7582e | ||
|   | d92e00f2dc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | dad88112c4 | ||
|   | 71ef809207 | ||
|   | 202d6d8c5d | ||
|   | e70bcc414c | ||
|   | 7bb4a136d7 | ||
|   | 8af3b4309f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bed3d83fd7 | ||
|   | efda42cf6d | ||
|   | e8ee5b3fc9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 393cb9767f | ||
|   | 8b5daad65c | ||
|   | 7b01b955ac | ||
|   | eb36c95035 | ||
|   | 72812673d0 | ||
|   | 490c3ab8dd | ||
|   | 5aed0f3637 | ||
|   | fabfecdb3e | ||
|   | 302351685d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a2d8b13204 | ||
|   | 4b14d2d6d2 | ||
|   | d545124942 | ||
|   | 6abdbfdff0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 500e655476 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5e1f026db2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d9efae98c8 | ||
|   | 091f6406a8 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 42a0e503cc | ||
|   | 4697352f60 | ||
|   | 015c764ab3 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 8fe465d9fc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9c1368885a | ||
|   | 391c0b2e7c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2ae061dbcd | ||
|   | 41fc502564 | ||
|   | b4554d2fc1 | ||
|   | feba5f6d3b | ||
|   | 4357d35f4a | ||
|   | 5041f80e5b | ||
|   | 9e23f79bc8 | ||
|   | bd1e869f6a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e4a36532e7 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2bc2316613 | ||
|   | 2fa36b2176 | ||
|   | efa38d779e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 951cc6ec0d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ef4b8a9934 | ||
|   | c14b48917e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 26165d0a99 | ||
|   | f7cf3f72c2 | ||
|   | cb8e09c9f9 | ||
|   | 026eb86f5f | ||
|   | 866859a937 | ||
|   | afc54f41f6 | ||
|   | 72c980f991 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9bf39a9cd4 | ||
|   | 33fd95cb2b | ||
|   | 8c92178895 | 
							
								
								
									
										1
									
								
								.github/codeql/codeql-config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| name: 'Custom CodeQL config' | ||||
							
								
								
									
										12
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -13,14 +13,18 @@ updates: | ||||
|       - "pr:daveit" | ||||
|       - "pr:platform" | ||||
|     ignore: | ||||
|         #We have to source the container which is not detected by Dependabot | ||||
|       #We have to source the playwright container which is not detected by Dependabot | ||||
|       - dependency-name: "@playwright/test" | ||||
|         #Lots of noise in these type patch releases. | ||||
|       - dependency-name: "playwright-core" | ||||
|       #Lots of noise in these type patch releases. | ||||
|       - dependency-name: "@babel/eslint-parser" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|         update-types: ["version-update:semver-patch"]  | ||||
|       - dependency-name: "eslint-plugin-vue" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|  | ||||
|       - dependency-name: "babel-loader" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|       - dependency-name: "sinon" | ||||
|         update-types: ["version-update:semver-patch"] | ||||
|   - package-ecosystem: "github-actions" | ||||
|     directory: "/" | ||||
|     schedule: | ||||
|   | ||||
							
								
								
									
										31
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,11 +1,10 @@ | ||||
|  | ||||
| name: "CodeQL" | ||||
| name: 'CodeQL' | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [ master ] | ||||
|     branches: [master, 'release/*'] | ||||
|   pull_request: | ||||
|     branches: [ master ] | ||||
|     branches: [master, 'release/*'] | ||||
|     paths-ignore: | ||||
|       - '**/*Spec.js' | ||||
|       - '**/*.md' | ||||
| @@ -27,17 +26,19 @@ jobs: | ||||
|       security-events: write | ||||
|  | ||||
|     steps: | ||||
|     - name: Checkout repository | ||||
|       uses: actions/checkout@v3 | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v3 | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v2 | ||||
|       with: | ||||
|         languages: javascript | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v2 | ||||
|         with: | ||||
|           config-file: ./.github/codeql/codeql-config.yml | ||||
|           languages: javascript | ||||
|           queries: security-and-quality | ||||
|  | ||||
|     - name: Autobuild | ||||
|       uses: github/codeql-action/autobuild@v2 | ||||
|       - name: Autobuild | ||||
|         uses: github/codeql-action/autobuild@v2 | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v2 | ||||
|       - name: Perform CodeQL Analysis | ||||
|         uses: github/codeql-action/analyze@v2 | ||||
|   | ||||
							
								
								
									
										98
									
								
								.github/workflows/lighthouse.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,98 +0,0 @@ | ||||
| name: lighthouse | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|     inputs:  | ||||
|       version: | ||||
|         description: 'Which branch do you want to test?' # Limited to branch for now | ||||
|         required: false | ||||
|         default: 'master' | ||||
|   pull_request: | ||||
|     types:  | ||||
|       - labeled | ||||
| jobs: | ||||
|   lighthouse-pr: | ||||
|     if: ${{ github.event.label.name == 'pr:lighthouse' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout Master for Baseline | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: master #explicitly checkout master for baseline | ||||
|       - name: Install Node 16 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v2 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline and ignore exit codes | ||||
|         run: lhci autorun || true | ||||
|       - name: Perform clean checkout of PR | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           clean: true | ||||
|       - name: Install Node version which is compatible with PR | ||||
|         uses: actions/setup-node@v3 | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci with PR | ||||
|         run: lhci autorun | ||||
|         env: | ||||
|           LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | ||||
|   lighthouse-nightly: | ||||
|     if: ${{ github.event.schedule }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Install Node 16 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v2 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline | ||||
|         run: lhci autorun | ||||
|         env: | ||||
|           LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} | ||||
|   lighthouse-dispatch: | ||||
|     if: ${{ github.event.workflow_dispatch }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: ${{ github.event.inputs.version }} | ||||
|       - name: Install Node 14 | ||||
|         uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: '16' | ||||
|       - name: Cache node modules | ||||
|         uses: actions/cache@v3 | ||||
|         env: | ||||
|           cache-name: cache-node-modules | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} | ||||
|       - name: npm install with lighthouse cli  | ||||
|         run: npm install && npm install -g @lhci/cli | ||||
|       - name: Run lhci against master to generate baseline | ||||
|         run: lhci autorun | ||||
|          | ||||
							
								
								
									
										8
									
								
								.github/workflows/npm-prerelease.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -16,7 +16,11 @@ jobs: | ||||
|         with: | ||||
|           node-version: 16 | ||||
|       - run: npm install | ||||
|       - run: npm test | ||||
|       - run: | | ||||
|           echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc | ||||
|           npm whoami | ||||
|           npm publish --access=public --tag unstable openmct | ||||
|       # - run: npm test | ||||
|  | ||||
|   publish-npm-prerelease: | ||||
|     needs: build | ||||
| @@ -28,6 +32,6 @@ jobs: | ||||
|           node-version: 16 | ||||
|           registry-url: https://registry.npmjs.org/ | ||||
|       - run: npm install | ||||
|       - run: npm publish --access public --tag unstable | ||||
|       - run: npm publish --access=public --tag unstable | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||
|   | ||||
| @@ -21,4 +21,10 @@ | ||||
| !copyright-notice.html | ||||
| !index.html | ||||
| !openmct.js | ||||
| !SECURITY.md | ||||
| !SECURITY.md | ||||
|  | ||||
| # Add e2e tests to npm package | ||||
| !/e2e/**/* | ||||
|  | ||||
| # ... except our test-data folder files. | ||||
| /e2e/test-data/*.json | ||||
|   | ||||
| @@ -10,7 +10,7 @@ accept changes from external contributors. | ||||
|  | ||||
| The short version: | ||||
|  | ||||
| 1. Write your contribution or describe your idea in the form of an [GitHub issue](https://github.com/nasa/openmct/issues/new/choose) or [Starting a GitHub Discussion](https://github.com/nasa/openmct/discussions) | ||||
| 1. Write your contribution or describe your idea in the form of a [GitHub issue](https://github.com/nasa/openmct/issues/new/choose) or [start a GitHub discussion](https://github.com/nasa/openmct/discussions). | ||||
| 2. Make sure your contribution meets code, test, and commit message | ||||
|    standards as described below. | ||||
| 3. Submit a pull request from a topic branch back to `master`. Include a check | ||||
|   | ||||
| @@ -6,10 +6,8 @@ Please visit our [Official Site](https://nasa.github.io/openmct/) and [Getting S | ||||
|  | ||||
| Once you've created something amazing with Open MCT, showcase your work in our GitHub Discussions [Show and Tell](https://github.com/nasa/openmct/discussions/categories/show-and-tell) section. We love seeing unique and wonderful implementations of Open MCT! | ||||
|  | ||||
| ## See Open MCT in Action | ||||
|  | ||||
|  | ||||
| Try Open MCT now with our [live demo](https://openmct-demo.herokuapp.com/). | ||||
|  | ||||
|  | ||||
| ## Building and Running Open MCT Locally | ||||
|  | ||||
| @@ -100,7 +98,7 @@ To run the performance tests: | ||||
| The test suite is configured to all tests localed in `e2e/tests/` ending in `*.e2e.spec.js`. For more about the e2e test suite, please see the [README](./e2e/README.md) | ||||
|  | ||||
| ### Security Tests | ||||
| Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/) | ||||
| Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/). The list of CWE coverage items is avaiable in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml). | ||||
|  | ||||
| ### Test Reporting and Code Coverage | ||||
|  | ||||
|   | ||||
| @@ -46,6 +46,7 @@ | ||||
|  */ | ||||
|  | ||||
| const Buffer = require('buffer').Buffer; | ||||
| const genUuid = require('uuid').v4; | ||||
|  | ||||
| /** | ||||
|  * This common function creates a domain object with the default options. It is the preferred way of creating objects | ||||
| @@ -56,6 +57,10 @@ const Buffer = require('buffer').Buffer; | ||||
|  * @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object. | ||||
|  */ | ||||
| async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine' }) { | ||||
|     if (!name) { | ||||
|         name = `${type}:${genUuid()}`; | ||||
|     } | ||||
|  | ||||
|     const parentUrl = await getHashUrlToDomainObject(page, parent); | ||||
|  | ||||
|     // Navigate to the parent object. This is necessary to create the object | ||||
| @@ -67,13 +72,18 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine | ||||
|     await page.click('button:has-text("Create")'); | ||||
|  | ||||
|     // Click the object specified by 'type' | ||||
|     await page.click(`li:text("${type}")`); | ||||
|     await page.click(`li[role='menuitem']:text("${type}")`); | ||||
|  | ||||
|     // Modify the name input field of the domain object to accept 'name' | ||||
|     if (name) { | ||||
|         const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); | ||||
|         await nameInput.fill(""); | ||||
|         await nameInput.fill(name); | ||||
|     const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); | ||||
|     await nameInput.fill(""); | ||||
|     await nameInput.fill(name); | ||||
|  | ||||
|     if (page.testNotes) { | ||||
|         // Fill the "Notes" section with information about the | ||||
|         // currently running test and its project. | ||||
|         const notesInput = page.locator('form[name="mctForm"] #notes-textarea'); | ||||
|         await notesInput.fill(page.testNotes); | ||||
|     } | ||||
|  | ||||
|     // Click OK button and wait for Navigate event | ||||
| @@ -96,8 +106,8 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         name: name || `Unnamed ${type}`, | ||||
|         uuid: uuid, | ||||
|         name, | ||||
|         uuid, | ||||
|         url: objectUrl | ||||
|     }; | ||||
| } | ||||
| @@ -225,15 +235,14 @@ async function getHashUrlToDomainObject(page, uuid) { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Utilizes the OpenMCT API to detect if the given object has an active transaction (is in Edit mode). | ||||
|  * Utilizes the OpenMCT API to detect if the UI is in Edit mode. | ||||
|  * @private | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {string | import('../src/api/objects/ObjectAPI').Identifier} identifier | ||||
|  * @return {Promise<boolean>} true if the object has an active transaction, false otherwise | ||||
|  * @return {Promise<boolean>} true if the Open MCT is in Edit Mode | ||||
|  */ | ||||
| async function _isInEditMode(page, identifier) { | ||||
|     // eslint-disable-next-line no-return-await | ||||
|     return await page.evaluate((objectIdentifier) => window.openmct.objects.isTransactionActive(objectIdentifier), identifier); | ||||
|     return await page.evaluate(() => window.openmct.editor.isEditing()); | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -20,6 +20,8 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { createDomainObjectWithDefaults } = require('../appActions'); | ||||
|  | ||||
| const NOTEBOOK_DROP_AREA = '.c-notebook__drag-area'; | ||||
|  | ||||
| /** | ||||
| @@ -38,24 +40,17 @@ async function enterTextEntry(page, text) { | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function dragAndDropEmbed(page, myItemsFolderName) { | ||||
|     // Click button:has-text("Create") | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     // Click li:has-text("Sine Wave Generator") | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     // Click form[name="mctForm"] >> text=My Items | ||||
|     await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|     // Click text=OK | ||||
|     await page.locator('text=OK').click(); | ||||
|     // Click text=Open MCT My Items >> span >> nth=3 | ||||
|     await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); | ||||
|     // Click text=Unnamed CUSTOM_NAME | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=Unnamed CUSTOM_NAME').click() | ||||
|     ]); | ||||
|  | ||||
|     await page.dragAndDrop('text=UNNAMED SINE WAVE GENERATOR', NOTEBOOK_DROP_AREA); | ||||
| async function dragAndDropEmbed(page, notebookObject) { | ||||
|     // Create example telemetry object | ||||
|     const swg = await createDomainObjectWithDefaults(page, { | ||||
|         type: "Sine Wave Generator" | ||||
|     }); | ||||
|     // Navigate to notebook | ||||
|     await page.goto(notebookObject.url); | ||||
|     // Expand the tree to reveal the notebook | ||||
|     await page.click('button[title="Show selected item in tree"]'); | ||||
|     // Drag and drop the SWG into the notebook | ||||
|     await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA); | ||||
| } | ||||
|  | ||||
| // eslint-disable-next-line no-undef | ||||
|   | ||||
| @@ -126,13 +126,21 @@ exports.test = test.extend({ | ||||
|     // This should follow in the Project's configuration. Can be set to 'snow' in playwright config.js | ||||
|     theme: [theme, { option: true }], | ||||
|     // eslint-disable-next-line no-shadow | ||||
|     page: async ({ page, theme }, use) => { | ||||
|     page: async ({ page, theme }, use, testInfo) => { | ||||
|         // eslint-disable-next-line playwright/no-conditional-in-test | ||||
|         if (theme === 'snow') { | ||||
|             //inject snow theme | ||||
|             await page.addInitScript({ path: path.join(__dirname, './helper', './useSnowTheme.js') }); | ||||
|         } | ||||
|  | ||||
|         // Attach info about the currently running test and its project. | ||||
|         // This will be used by appActions to fill in the created | ||||
|         // domain object's notes. | ||||
|         page.testNotes = [ | ||||
|             `${testInfo.titlePath.join('\n')}`, | ||||
|             `${testInfo.project.name}` | ||||
|         ].join('\n'); | ||||
|  | ||||
|         await use(page); | ||||
|     }, | ||||
|     myItemsFolderName: [myItemsFolderName, { option: true }], | ||||
| @@ -140,22 +148,5 @@ exports.test = test.extend({ | ||||
|     openmctConfig: async ({ myItemsFolderName }, use) => { | ||||
|         await use({ myItemsFolderName }); | ||||
|     } | ||||
|     // objectCreateOptions: [objectCreateOptions, {option: true}], | ||||
|     // eslint-disable-next-line no-shadow | ||||
|     // domainObject: [async ({ page, objectCreateOptions }, use) => { | ||||
|     //     // FIXME: This is a false-positive caused by a bug in the eslint-plugin-playwright rule. | ||||
|     //     // eslint-disable-next-line playwright/no-conditional-in-test | ||||
|     //     if (objectCreateOptions === null) { | ||||
|     //         await use(page); | ||||
|  | ||||
|     //         return; | ||||
|     //     } | ||||
|  | ||||
|     //     //Go to baseURL | ||||
|     //     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     //     const uuid = await getOrCreateDomainObject(page, objectCreateOptions); | ||||
|     //     await use({ uuid }); | ||||
|     // }, { auto: true }] | ||||
| }); | ||||
| exports.expect = expect; | ||||
|   | ||||
							
								
								
									
										2207
									
								
								e2e/test-data/ExampleLayouts.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -20,7 +20,7 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures.js'); | ||||
| const { test, expect } = require('../../pluginFixtures.js'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions.js'); | ||||
|  | ||||
| test.describe('AppActions', () => { | ||||
| @@ -50,11 +50,11 @@ test.describe('AppActions', () => { | ||||
|             }); | ||||
|  | ||||
|             await page.goto(timer1.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Foo'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer1.name); | ||||
|             await page.goto(timer2.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Bar'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer2.name); | ||||
|             await page.goto(timer3.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Timer Baz'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer3.name); | ||||
|         }); | ||||
|  | ||||
|         await test.step('Create multiple nested objects in a row', async () => { | ||||
| @@ -74,11 +74,11 @@ test.describe('AppActions', () => { | ||||
|                 parent: folder2.uuid | ||||
|             }); | ||||
|             await page.goto(folder1.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Foo'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder1.name); | ||||
|             await page.goto(folder2.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Bar'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder2.name); | ||||
|             await page.goto(folder3.url, { waitUntil: 'networkidle' }); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText('Folder Baz'); | ||||
|             await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder3.name); | ||||
|  | ||||
|             expect(folder1.url).toBe(`${e2eFolder.url}/${folder1.uuid}`); | ||||
|             expect(folder2.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}`); | ||||
|   | ||||
| @@ -45,7 +45,7 @@ | ||||
| */ | ||||
|  | ||||
| // Structure: Some standard Imports. Please update the required pathing. | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { test, expect } = require('../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
|  | ||||
| /** | ||||
| @@ -144,5 +144,5 @@ async function renameTimerFrom3DotMenu(page, timerUrl, newNameForTimer) { | ||||
|     await page.locator('text=Properties Title Notes >> input[type="text"]').fill(newNameForTimer); | ||||
|  | ||||
|     // Click Ok button to Save | ||||
|     await page.locator('text=OK').click(); | ||||
|     await page.locator('button:has-text("OK")').click(); | ||||
| } | ||||
|   | ||||
| @@ -43,14 +43,14 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add sine wave generator with defaults | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     //Add a 5000 ms Delay | ||||
|     await page.locator('[aria-label="Loading Delay \\(ms\\)"]').fill('5000'); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -58,7 +58,7 @@ test('Generate Visual Test Data @localStorage', async ({ page, context }) => { | ||||
|     // focus the overlay plot | ||||
|     await page.goto(overlayPlot.url); | ||||
|  | ||||
|     await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Overlay Plot'); | ||||
|     await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlot.name); | ||||
|     //Save localStorage for future test execution | ||||
|     await context.storageState({ path: './e2e/test-data/VisualTestData_storage.json' }); | ||||
| }); | ||||
|   | ||||
| @@ -25,7 +25,7 @@ | ||||
| * | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { test, expect } = require('../../pluginFixtures'); | ||||
|  | ||||
| test.describe("CouchDB Status Indicator @couchdb", () => { | ||||
|     test.use({ failOnConsoleError: false }); | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
| This test suite is dedicated to tests which verify the basic operations surrounding the example event generator. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../appActions'); | ||||
|  | ||||
| test.describe('Example Event Generator CRUD Operations', () => { | ||||
|   | ||||
| @@ -96,7 +96,7 @@ test.describe('Sine Wave Generator', () => { | ||||
|         //Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         // Verify that the Sine Wave Generator is displayed and correct | ||||
|   | ||||
| @@ -25,6 +25,7 @@ This test suite is dedicated to tests which verify form functionality in isolati | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../baseFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
| const path = require('path'); | ||||
|  | ||||
| const TEST_FOLDER = 'test folder'; | ||||
| @@ -43,7 +44,7 @@ test.describe('Form Validation Behavior', () => { | ||||
|         await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab'); | ||||
|  | ||||
|         //Required Field Form Validation | ||||
|         await expect(page.locator('text=OK')).toBeDisabled(); | ||||
|         await expect(page.locator('button:has-text("OK")')).toBeDisabled(); | ||||
|         await expect(page.locator('.c-form-row__state-indicator').first()).toHaveClass(/invalid/); | ||||
|  | ||||
|         //Correct Form Validation for missing title and trigger validation with 'Tab' | ||||
| @@ -52,13 +53,13 @@ test.describe('Form Validation Behavior', () => { | ||||
|         await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab'); | ||||
|  | ||||
|         //Required Field Form Validation is corrected | ||||
|         await expect(page.locator('text=OK')).toBeEnabled(); | ||||
|         await expect(page.locator('button:has-text("OK")')).toBeEnabled(); | ||||
|         await expect(page.locator('.c-form-row__state-indicator').first()).not.toHaveClass(/invalid/); | ||||
|  | ||||
|         //Finish Creating Domain Object | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         //Verify that the Domain Object has been created with the corrected title property | ||||
| @@ -91,6 +92,44 @@ test.describe('Persistence operations @addInit', () => { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| test.describe('Persistence operations @couchdb', () => { | ||||
|     test.use({ failOnConsoleError: false }); | ||||
|     test('Editing object properties should generate a single persistence operation', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/5616' | ||||
|         }); | ||||
|  | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create a new 'Clock' object with default settings | ||||
|         const clock = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock' | ||||
|         }); | ||||
|  | ||||
|         // Count all persistence operations (PUT requests) for this specific object | ||||
|         let putRequestCount = 0; | ||||
|         page.on('request', req => { | ||||
|             if (req.method() === 'PUT' && req.url().endsWith(clock.uuid)) { | ||||
|                 putRequestCount += 1; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Open the edit form for the clock object | ||||
|         await page.click('button[title="More options"]'); | ||||
|         await page.click('li[title="Edit properties of this object."]'); | ||||
|  | ||||
|         // Modify the display format from default 12hr -> 24hr and click 'Save' | ||||
|         await page.locator('select[aria-label="12 or 24 hour clock"]').selectOption({ value: 'clock24' }); | ||||
|         await page.click('button[aria-label="Save"]'); | ||||
|  | ||||
|         await expect.poll(() => putRequestCount, { | ||||
|             message: 'Verify a single PUT request was made to persist the object', | ||||
|             timeout: 1000 | ||||
|         }).toEqual(1); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| test.describe('Form Correctness by Object Type', () => { | ||||
|     test.fixme('Verify correct behavior of number object (SWG)', async ({page}) => {}); | ||||
|     test.fixme('Verify correct behavior of number object Timer', async ({page}) => {}); | ||||
|   | ||||
| @@ -81,7 +81,7 @@ test.describe('Move & link item tests', () => { | ||||
|         await page.locator('li.icon-move').click(); | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Expect that Child Folder is in My Items, the root folder | ||||
|         expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); | ||||
| @@ -95,11 +95,11 @@ test.describe('Move & link item tests', () => { | ||||
|         // Create Telemetry Table | ||||
|         let telemetryTable = 'Test Telemetry Table'; | ||||
|         await page.locator('button:has-text("Create")').click(); | ||||
|         await page.locator('li:has-text("Telemetry Table")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Telemetry Table")').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill(telemetryTable); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Finish editing and save Telemetry Table | ||||
|         await page.locator('.c-button--menu.c-button--major.icon-save').click(); | ||||
| @@ -108,7 +108,7 @@ test.describe('Move & link item tests', () => { | ||||
|         // Create New Folder Basic Domain Object | ||||
|         let folder = 'Test Folder'; | ||||
|         await page.locator('button:has-text("Create")').click(); | ||||
|         await page.locator('li:has-text("Folder")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Folder")').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').click(); | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folder); | ||||
|  | ||||
| @@ -120,7 +120,7 @@ test.describe('Move & link item tests', () => { | ||||
|  | ||||
|         // Continue test regardless of assertion and create it in My Items | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Open My Items | ||||
|         await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); | ||||
| @@ -196,7 +196,7 @@ test.describe('Move & link item tests', () => { | ||||
|         await page.locator('li.icon-link').click(); | ||||
|         await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); | ||||
|  | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Expect that Child Folder is in My Items, the root folder | ||||
|         expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); | ||||
|   | ||||
| @@ -40,11 +40,11 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         await page.locator('li:has-text("Condition Set")').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Condition Set")').click(); | ||||
|  | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.click('text=OK') | ||||
|             page.click('button:has-text("OK")') | ||||
|         ]); | ||||
|  | ||||
|         //Save localStorage for future test execution | ||||
| @@ -163,9 +163,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { | ||||
|         // Click hamburger button | ||||
|         await page.locator('[title="More options"]').click(); | ||||
|  | ||||
|         // Click text=Remove | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         // Click 'Remove' and press OK | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         //Expect Unnamed Condition Set to be removed in Main View | ||||
|         const numberOfConditionSetsAtEnd = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Testing Display Layout @unstable', () => { | ||||
| test.describe('Display Layout', () => { | ||||
|     let sineWaveObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
| @@ -55,12 +55,12 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|         // On getting data, check if the value found in the  Display Layout is the most recent value | ||||
|         // from the Sine Wave Generator | ||||
|         const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); | ||||
|         const formattedTelemetryValue = await getTelemValuePromise; | ||||
|         const formattedTelemetryValue = getTelemValuePromise; | ||||
|         const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); | ||||
|         const displayLayoutValue = await displayLayoutValuePromise.textContent(); | ||||
|         const trimmedDisplayValue = displayLayoutValue.trim(); | ||||
|  | ||||
|         await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|         expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|     }); | ||||
|     test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in fixed time', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
| @@ -86,12 +86,12 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|  | ||||
|         // On getting data, check if the value found in the Display Layout is the most recent value | ||||
|         // from the Sine Wave Generator | ||||
|         const formattedTelemetryValue = await getTelemValuePromise; | ||||
|         const formattedTelemetryValue = getTelemValuePromise; | ||||
|         const displayLayoutValuePromise = await page.waitForSelector(`text="${formattedTelemetryValue}"`); | ||||
|         const displayLayoutValue = await displayLayoutValuePromise.textContent(); | ||||
|         const trimmedDisplayValue = displayLayoutValue.trim(); | ||||
|  | ||||
|         await expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|         expect(trimmedDisplayValue).toBe(formattedTelemetryValue); | ||||
|     }); | ||||
|     test('items in a display layout can be removed with object tree context menu when viewing the display layout', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
| @@ -116,16 +116,20 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // delete | ||||
|  | ||||
|         expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|         expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|     }); | ||||
|     test('items in a display layout can be removed with object tree context menu when viewing another item', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/3117' | ||||
|         }); | ||||
|         // Create a Display Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const displayLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Display Layout', | ||||
|             name: "Test Display Layout" | ||||
|         }); | ||||
| @@ -144,18 +148,18 @@ test.describe('Testing Display Layout @unstable', () => { | ||||
|         // Expand the Display Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Click the original Sine Wave Generator to navigate away from the Display Layout | ||||
|         await page.locator('.c-tree__item .c-tree__item__name:text("Test Sine Wave Generator")').click(); | ||||
|         // Go to the original Sine Wave Generator to navigate away from the Display Layout | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // navigate back to the display layout to confirm it has been removed | ||||
|         await page.locator('.c-tree__item .c-tree__item__name:text("Test Display Layout")').click(); | ||||
|         await page.goto(displayLayout.url); | ||||
|  | ||||
|         expect.soft(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|         expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -23,12 +23,13 @@ | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Testing Flexible Layout @unstable', () => { | ||||
| test.describe('Flexible Layout', () => { | ||||
|     let sineWaveObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Sine Wave Generator | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         sineWaveObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: "Test Sine Wave Generator" | ||||
|         }); | ||||
| @@ -54,13 +55,81 @@ test.describe('Testing Flexible Layout @unstable', () => { | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.dragAndDrop('text=Test Clock', '.c-fl__container.is-empty'); | ||||
|         // Check that panes can be dragged while Flexible Layout is in Edit mode | ||||
|         let dragWrapper = await page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         let dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'true'); | ||||
|         // Save Flexible Layout | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|         // Check that panes are not draggable while Flexible Layout is in Browse mode | ||||
|         dragWrapper = await page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'false'); | ||||
|     }); | ||||
|     test('items in a flexible layout can be removed with object tree context menu when viewing the flexible layout', async ({ page }) => { | ||||
|         // Create a Display Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
|  | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
|         expect.soft(await page.locator('.c-fl-container__frame').count()).toEqual(1); | ||||
|  | ||||
|         // Expand the Flexible Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // Verify that the item has been removed from the layout | ||||
|         expect(await page.locator('.c-fl-container__frame').count()).toEqual(0); | ||||
|     }); | ||||
|     test('items in a flexible layout can be removed with object tree context menu when viewing another item', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/3117' | ||||
|         }); | ||||
|         // Create a Flexible Layout | ||||
|         const flexibleLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
|  | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
|         expect.soft(await page.locator('.c-fl-container__frame').count()).toEqual(1); | ||||
|  | ||||
|         // Expand the Flexible Layout so we can remove the sine wave generator | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Go to the original Sine Wave Generator to navigate away from the Flexible Layout | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         // navigate back to the display layout to confirm it has been removed | ||||
|         await page.goto(flexibleLayout.url); | ||||
|  | ||||
|         // Verify that the item has been removed from the layout | ||||
|         expect(await page.locator('.c-fl-container__frame').count()).toEqual(0); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										124
									
								
								e2e/tests/functional/plugins/gauge/gauge.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,124 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| * This test suite is dedicated to testing the Gauge component. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const uuid = require('uuid').v4; | ||||
|  | ||||
| test.describe('Gauge', () => { | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can add and remove telemetry sources @unstable', async ({ page }) => { | ||||
|         // Create the gauge with defaults | ||||
|         const gauge = await createDomainObjectWithDefaults(page, { type: 'Gauge' }); | ||||
|         const editButtonLocator = page.locator('button[title="Edit"]'); | ||||
|         const saveButtonLocator = page.locator('button[title="Save"]'); | ||||
|  | ||||
|         // Create a sine wave generator within the gauge | ||||
|         const swg1 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: gauge.uuid | ||||
|         }); | ||||
|  | ||||
|         // Navigate to the gauge and verify that | ||||
|         // the SWG appears in the elements pool | ||||
|         await page.goto(gauge.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|         await page.locator('li[title="Save and Finish Editing"]').click(); | ||||
|  | ||||
|         // Create another sine wave generator within the gauge | ||||
|         const swg2 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: gauge.uuid | ||||
|         }); | ||||
|  | ||||
|         // Verify that the 'Replace telemetry source' modal appears and accept it | ||||
|         await expect.soft(page.locator('text=This action will replace the current telemetry source. Do you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Navigate to the gauge and verify that the new SWG | ||||
|         // appears in the elements pool and the old one is gone | ||||
|         await page.goto(gauge.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeHidden(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg2.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|  | ||||
|         // Right click on the new SWG in the elements pool and delete it | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swg2.name}`).click({ | ||||
|             button: 'right' | ||||
|         }); | ||||
|         await page.locator('li[title="Remove this object from its containing object."]').click(); | ||||
|  | ||||
|         // Verify that the 'Remove object' confirmation modal appears and accept it | ||||
|         await expect.soft(page.locator('text=Warning! This action will remove this object. Are you sure you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Verify that the elements pool shows no elements | ||||
|         await expect(page.locator('text="No contained elements"')).toBeVisible(); | ||||
|     }); | ||||
|     test('Can create a non-default Gauge', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/5356' | ||||
|         }); | ||||
|         //Click the Create button | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click the object specified by 'type' | ||||
|         await page.click(`li[role='menuitem']:text("Gauge")`); | ||||
|         // FIXME: We need better selectors for these custom form controls | ||||
|         const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0'); | ||||
|         await displayCurrentValueSwitch.setChecked(false); | ||||
|         await page.click('button[aria-label="Save"]'); | ||||
|  | ||||
|         // TODO: Verify changes in the UI | ||||
|     }); | ||||
|     test('Can edit a single Gauge-specific property', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/5985' | ||||
|         }); | ||||
|  | ||||
|         // Create the gauge with defaults | ||||
|         await createDomainObjectWithDefaults(page, { type: 'Gauge' }); | ||||
|         await page.click('button[title="More options"]'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Edit Properties")'); | ||||
|         // FIXME: We need better selectors for these custom form controls | ||||
|         const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0'); | ||||
|         await displayCurrentValueSwitch.setChecked(false); | ||||
|         await page.click('button[aria-label="Save"]'); | ||||
|  | ||||
|         // TODO: Verify changes in the UI | ||||
|     }); | ||||
| }); | ||||
| @@ -40,10 +40,10 @@ test.describe('Example Imagery Object', () => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create a default 'Example Imagery' object | ||||
|         await createDomainObjectWithDefaults(page, { type: 'Example Imagery' }); | ||||
|         const exampleImagery = await createDomainObjectWithDefaults(page, { type: 'Example Imagery' }); | ||||
|  | ||||
|         // Verify that the created object is focused | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); | ||||
|         await page.locator(backgroundImageSelector).hover({trial: true}); | ||||
|     }); | ||||
|  | ||||
| @@ -188,7 +188,7 @@ test.describe('Example Imagery in Display Layout', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -197,7 +197,7 @@ test.describe('Example Imagery in Display Layout', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
| @@ -275,7 +275,7 @@ test.describe('Example Imagery in Flexible layout', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -284,7 +284,7 @@ test.describe('Example Imagery in Flexible layout', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
| @@ -317,7 +317,7 @@ test.describe('Example Imagery in Tabs View', () => { | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click text=Example Imagery | ||||
|         await page.click('text=Example Imagery'); | ||||
|         await page.click('li[role="menuitem"]:has-text("Example Imagery")'); | ||||
|  | ||||
|         // Clear and set Image load delay to minimum value | ||||
|         await page.locator('input[type="number"]').fill(''); | ||||
| @@ -326,7 +326,7 @@ test.describe('Example Imagery in Tabs View', () => { | ||||
|         // Click text=OK | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation({waitUntil: 'networkidle'}), | ||||
|             page.click('text=OK'), | ||||
|             page.click('button:has-text("OK")'), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|   | ||||
| @@ -26,7 +26,7 @@ This test suite is dedicated to tests which verify the basic operations surround | ||||
|  | ||||
| // FIXME: Remove this eslint exception once tests are implemented | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const nbUtils = require('../../../../helper/notebookUtils'); | ||||
|  | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
| This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Notebook Tests with CouchDB @couchdb', () => { | ||||
|   | ||||
| @@ -36,27 +36,27 @@ test.describe('Restricted Notebook', () => { | ||||
|     }); | ||||
|  | ||||
|     test('Can be renamed @addInit', async ({ page }) => { | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(`Unnamed ${CUSTOM_NAME}`); | ||||
|         await expect(page.locator('.l-browse-bar__object-name')).toContainText(`${notebook.name}`); | ||||
|     }); | ||||
|  | ||||
|     test('Can be deleted if there are no locked pages @addInit', async ({ page, openmctConfig }) => { | ||||
|     test('Can be deleted if there are no locked pages @addInit', async ({ page }) => { | ||||
|         await openObjectTreeContextMenu(page, notebook.url); | ||||
|  | ||||
|         const menuOptions = page.locator('.c-menu ul'); | ||||
|         await expect.soft(menuOptions).toContainText('Remove'); | ||||
|  | ||||
|         const restrictedNotebookTreeObject = page.locator(`a:has-text("Unnamed ${CUSTOM_NAME}")`); | ||||
|         const restrictedNotebookTreeObject = page.locator(`a:has-text("${notebook.name}")`); | ||||
|  | ||||
|         // notebook tree object exists | ||||
|         expect.soft(await restrictedNotebookTreeObject.count()).toEqual(1); | ||||
|  | ||||
|         // Click Remove Text | ||||
|         await page.locator('text=Remove').click(); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|  | ||||
|         // Click 'OK' on confirmation window and wait for save banner to appear | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.locator('text=OK').click(), | ||||
|             page.locator('button:has-text("OK")').click(), | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|  | ||||
| @@ -134,7 +134,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|         // Click text=Ok | ||||
|         await Promise.all([ | ||||
|             page.waitForNavigation(), | ||||
|             page.locator('text=Ok').click() | ||||
|             page.locator('button:has-text("OK")').click() | ||||
|         ]); | ||||
|  | ||||
|         // deleted page, should no longer exist | ||||
| @@ -145,10 +145,9 @@ test.describe('Restricted Notebook with at least one entry and with the page loc | ||||
|  | ||||
| test.describe('Restricted Notebook with a page locked and with an embed @addInit', () => { | ||||
|  | ||||
|     test.beforeEach(async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|         await startAndAddRestrictedNotebookObject(page); | ||||
|         await nbUtils.dragAndDropEmbed(page, myItemsFolderName); | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         const notebook = await startAndAddRestrictedNotebookObject(page); | ||||
|         await nbUtils.dragAndDropEmbed(page, notebook); | ||||
|     }); | ||||
|  | ||||
|     test('Allows embeds to be deleted if page unlocked @addInit', async ({ page }) => { | ||||
|   | ||||
| @@ -36,7 +36,7 @@ async function createNotebookAndEntry(page, iterations = 1) { | ||||
|     //Go to baseURL | ||||
|     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     createDomainObjectWithDefaults(page, { type: 'Notebook' }); | ||||
|     const notebook = createDomainObjectWithDefaults(page, { type: 'Notebook' }); | ||||
|  | ||||
|     for (let iteration = 0; iteration < iterations; iteration++) { | ||||
|         // Create an entry | ||||
| @@ -45,6 +45,8 @@ async function createNotebookAndEntry(page, iterations = 1) { | ||||
|         await page.locator(entryLocator).click(); | ||||
|         await page.locator(entryLocator).fill(`Entry ${iteration}`); | ||||
|     } | ||||
|  | ||||
|     return notebook; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -53,7 +55,7 @@ async function createNotebookAndEntry(page, iterations = 1) { | ||||
|   * @param {number} [iterations = 1] - the number of entries (and tags) to create | ||||
|   */ | ||||
| async function createNotebookEntryAndTags(page, iterations = 1) { | ||||
|     await createNotebookAndEntry(page, iterations); | ||||
|     const notebook = await createNotebookAndEntry(page, iterations); | ||||
|  | ||||
|     for (let iteration = 0; iteration < iterations; iteration++) { | ||||
|         // Hover and click "Add Tag" button | ||||
| @@ -75,6 +77,8 @@ async function createNotebookEntryAndTags(page, iterations = 1) { | ||||
|         // Select the "Science" tag | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|     } | ||||
|  | ||||
|     return notebook; | ||||
| } | ||||
|  | ||||
| test.describe('Tagging in Notebooks @addInit', () => { | ||||
| @@ -173,10 +177,10 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|         //Go to baseURL | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         await createDomainObjectWithDefaults(page, { type: 'Clock' }); | ||||
|         const clock = await createDomainObjectWithDefaults(page, { type: 'Clock' }); | ||||
|  | ||||
|         const ITERATIONS = 4; | ||||
|         await createNotebookEntryAndTags(page, ITERATIONS); | ||||
|         const notebook = await createNotebookEntryAndTags(page, ITERATIONS); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
| @@ -189,11 +193,11 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             page.goto('./#/browse/mine?hideTree=false'), | ||||
|             page.click('.c-disclosure-triangle') | ||||
|         ]); | ||||
|         // Click Unnamed Clock | ||||
|         await page.click('text="Unnamed Clock"'); | ||||
|         // Click Clock | ||||
|         await page.click(`text=${clock.name}`); | ||||
|  | ||||
|         // Click Unnamed Notebook | ||||
|         await page.click('text="Unnamed Notebook"'); | ||||
|         // Click Notebook | ||||
|         await page.click(`text=${notebook.name}`); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
| @@ -207,14 +211,13 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             page.waitForLoadState('networkidle') | ||||
|         ]); | ||||
|  | ||||
|         // Click Unnamed Notebook | ||||
|         await page.click('text="Unnamed Notebook"'); | ||||
|         // Click Notebook | ||||
|         await page.click(`text="${notebook.name}"`); | ||||
|  | ||||
|         for (let iteration = 0; iteration < ITERATIONS; iteration++) { | ||||
|             const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; | ||||
|             await expect(page.locator(entryLocator)).toContainText("Science"); | ||||
|             await expect(page.locator(entryLocator)).toContainText("Driving"); | ||||
|         } | ||||
|  | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -110,10 +110,10 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add overlay plot with defaults | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear1 | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -129,10 +129,10 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) { | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|  | ||||
|     // add sine wave generator with defaults | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear1 | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB | 
| @@ -88,10 +88,10 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|     // create overlay plot | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -106,7 +106,7 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|     // create a sinewave generator | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     // set amplitude to 6, offset 4, period 2 | ||||
|  | ||||
| @@ -123,7 +123,7 @@ async function makeOverlayPlot(page, myItemsFolderName) { | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| @@ -88,11 +88,11 @@ async function makeStackedPlot(page, myItemsFolderName) { | ||||
|  | ||||
|     // create stacked plot | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Stacked Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Stacked Plot")').click(); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -146,11 +146,11 @@ async function saveStackedPlot(page) { | ||||
| async function createSineWaveGenerator(page) { | ||||
|     //Create sine wave generator | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| @@ -68,10 +68,10 @@ async function makeOverlayPlot(page) { | ||||
|     // create overlay plot | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Overlay Plot")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Overlay Plot")').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
| @@ -86,13 +86,13 @@ async function makeOverlayPlot(page) { | ||||
|     // create a sinewave generator | ||||
|  | ||||
|     await page.locator('button.c-create-button').click(); | ||||
|     await page.locator('li:has-text("Sine Wave Generator")').click(); | ||||
|     await page.locator('li[role="menuitem"]:has-text("Sine Wave Generator")').click(); | ||||
|  | ||||
|     // Click OK to make generator | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation({ waitUntil: 'networkidle'}), | ||||
|         page.locator('text=OK').click(), | ||||
|         page.locator('button:has-text("OK")').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|   | ||||
| @@ -25,8 +25,8 @@ | ||||
| * | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults} = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Plot Integrity Testing @unstable', () => { | ||||
|     let sineWaveGeneratorObject; | ||||
| @@ -40,7 +40,6 @@ test.describe('Plot Integrity Testing @unstable', () => { | ||||
|     test('Plots do not re-request data when a plot is clicked', async ({ page }) => { | ||||
|         //Navigate to Sine Wave Generator | ||||
|         await page.goto(sineWaveGeneratorObject.url); | ||||
|         //Capture the number of plots points and store as const name numberOfPlotPoints | ||||
|         //Click on the plot canvas | ||||
|         await page.locator('canvas').nth(1).click(); | ||||
|         //No request was made to get historical data | ||||
| @@ -51,4 +50,90 @@ test.describe('Plot Integrity Testing @unstable', () => { | ||||
|         }); | ||||
|         expect(createMineFolderRequests.length).toEqual(0); | ||||
|     }); | ||||
|  | ||||
|     test('Plot is rendered when infinity values exist', async ({ page }) => { | ||||
|         // Edit Plot | ||||
|         await editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject); | ||||
|  | ||||
|         //Get pixel data from Canvas | ||||
|         const plotPixelSize = await getCanvasPixelsWithData(page); | ||||
|         expect(plotPixelSize).toBeGreaterThan(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * This function edits a sine wave generator with the default options and enables the infinity values option. | ||||
|  * | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {import('../../../../appActions').CreateObjectInfo} sineWaveGeneratorObject | ||||
|  * @returns {Promise<CreatedObjectInfo>} An object containing information about the edited domain object. | ||||
|  */ | ||||
| async function editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject) { | ||||
|     await page.goto(sineWaveGeneratorObject.url); | ||||
|     // Edit LAD table | ||||
|     await page.locator('[title="More options"]').click(); | ||||
|     await page.locator('[title="Edit properties of this object."]').click(); | ||||
|     // Modify the infinity option to true | ||||
|     const infinityInput = page.locator('[aria-label="Include Infinity Values"]'); | ||||
|     await infinityInput.click(); | ||||
|  | ||||
|     // Click OK button and wait for Navigate event | ||||
|     await Promise.all([ | ||||
|         page.waitForLoadState(), | ||||
|         page.click('[aria-label="Save"]'), | ||||
|         // Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|  | ||||
|     // FIXME: Changes to SWG properties should be reflected on save, but they're not? | ||||
|     // Thus, navigate away and back to the object. | ||||
|     await page.goto('./#/browse/mine'); | ||||
|     await page.goto(sineWaveGeneratorObject.url); | ||||
|  | ||||
|     await page.locator('c-progress-bar c-telemetry-table__progress-bar').waitFor({ | ||||
|         state: 'hidden' | ||||
|     }); | ||||
|  | ||||
|     // FIXME: The progress bar disappears on series data load, not on plot render, | ||||
|     // so wait for a half a second before evaluating the canvas. | ||||
|     // eslint-disable-next-line playwright/no-wait-for-timeout | ||||
|     await page.waitForTimeout(500); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function getCanvasPixelsWithData(page) { | ||||
|     const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve)); | ||||
|  | ||||
|     await page.evaluate(() => { | ||||
|         // The document canvas is where the plot points and lines are drawn. | ||||
|         // The only way to access the canvas is using document (using page.evaluate) | ||||
|         let data; | ||||
|         let canvas; | ||||
|         let ctx; | ||||
|         canvas = document.querySelector('canvas'); | ||||
|         ctx = canvas.getContext('2d'); | ||||
|         data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; | ||||
|         const imageDataValues = Object.values(data); | ||||
|         let plotPixels = []; | ||||
|         // Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four. | ||||
|         // The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order. | ||||
|         for (let i = 0; i < imageDataValues.length;) { | ||||
|             if (imageDataValues[i] > 0) { | ||||
|                 plotPixels.push({ | ||||
|                     startIndex: i, | ||||
|                     endIndex: i + 3, | ||||
|                     value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})` | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             i = i + 4; | ||||
|  | ||||
|         } | ||||
|  | ||||
|         window.getCanvasValue(plotPixels.length); | ||||
|     }); | ||||
|  | ||||
|     return getTelemValuePromise; | ||||
| } | ||||
|   | ||||
							
								
								
									
										93
									
								
								e2e/tests/functional/plugins/plot/scatterPlot.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,93 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| * This test suite is dedicated to testing the Scatter Plot component. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const uuid = require('uuid').v4; | ||||
|  | ||||
| test.describe('Scatter Plot', () => { | ||||
|     let scatterPlot; | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create the Scatter Plot | ||||
|         scatterPlot = await createDomainObjectWithDefaults(page, { type: 'Scatter Plot' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can add and remove telemetry sources', async ({ page }) => { | ||||
|         const editButtonLocator = page.locator('button[title="Edit"]'); | ||||
|         const saveButtonLocator = page.locator('button[title="Save"]'); | ||||
|  | ||||
|         // Create a sine wave generator within the scatter plot | ||||
|         const swg1 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: scatterPlot.uuid | ||||
|         }); | ||||
|  | ||||
|         // Navigate to the scatter plot and verify that | ||||
|         // the SWG appears in the elements pool | ||||
|         await page.goto(scatterPlot.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|         await page.locator('li[title="Save and Finish Editing"]').click(); | ||||
|  | ||||
|         // Create another sine wave generator within the scatter plot | ||||
|         const swg2 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: `swg-${uuid()}`, | ||||
|             parent: scatterPlot.uuid | ||||
|         }); | ||||
|  | ||||
|         // Verify that the 'Replace telemetry source' modal appears and accept it | ||||
|         await expect.soft(page.locator('text=This action will replace the current telemetry source. Do you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Navigate to the scatter plot and verify that the new SWG | ||||
|         // appears in the elements pool and the old one is gone | ||||
|         await page.goto(scatterPlot.url); | ||||
|         await editButtonLocator.click(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeHidden(); | ||||
|         await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg2.name}`)).toBeVisible(); | ||||
|         await saveButtonLocator.click(); | ||||
|  | ||||
|         // Right click on the new SWG in the elements pool and delete it | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swg2.name}`).click({ | ||||
|             button: 'right' | ||||
|         }); | ||||
|         await page.locator('li[title="Remove this object from its containing object."]').click(); | ||||
|  | ||||
|         // Verify that the 'Remove object' confirmation modal appears and accept it | ||||
|         await expect.soft(page.locator('text=Warning! This action will remove this object. Are you sure you want to continue?')).toBeVisible(); | ||||
|         await page.click('text=Ok'); | ||||
|  | ||||
|         // Verify that the elements pool shows no elements | ||||
|         await expect(page.locator('text="No contained elements"')).toBeVisible(); | ||||
|     }); | ||||
| }); | ||||
| @@ -20,7 +20,7 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { test, expect } = require('../../../../baseFixtures'); | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { setFixedTimeMode, setRealTimeMode, setStartOffset, setEndOffset } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Time conductor operations', () => { | ||||
|   | ||||
| @@ -30,7 +30,7 @@ test.describe('Timer', () => { | ||||
|         timer = await createDomainObjectWithDefaults(page, { type: 'timer' }); | ||||
|     }); | ||||
|  | ||||
|     test('Can perform actions on the Timer', async ({ page, openmctConfig }) => { | ||||
|     test('Can perform actions on the Timer', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/4313' | ||||
|   | ||||
| @@ -31,7 +31,7 @@ test.describe('Grand Search', () => { | ||||
|     test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|  | ||||
|         await createObjectsForSearch(page, myItemsFolderName); | ||||
|         const createdObjects = await createObjectsForSearch(page); | ||||
|  | ||||
|         // Click [aria-label="OpenMCT Search"] input[type="search"] | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); | ||||
| @@ -41,8 +41,8 @@ test.describe('Grand Search', () => { | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=1')).toContainText(`Clock B ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=2')).toContainText(`Clock C ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=3')).toContainText(`Clock D ${myItemsFolderName} Red Folder Blue Folder`); | ||||
|         // Click text=Elements >> nth=0 | ||||
|         await page.locator('text=Elements').first().click(); | ||||
|         // Click the Elements pool to dismiss the search menu | ||||
|         await page.locator('.l-pane__label:has-text("Elements")').click(); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toBeHidden(); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click(); | ||||
| @@ -77,7 +77,7 @@ test.describe('Grand Search', () => { | ||||
|         await expect(page.locator('.is-object-type-clock')).toBeVisible(); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Disp'); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toContainText('Unnamed Display Layout'); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).toContainText(createdObjects.displayLayout.name); | ||||
|         await expect(page.locator('[aria-label="Search Result"] >> nth=0')).not.toContainText('Folder'); | ||||
|  | ||||
|         await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Clock C'); | ||||
| @@ -185,7 +185,7 @@ async function createFolderObject(page, folderName) { | ||||
|     await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folderName); | ||||
|  | ||||
|     // Create folder object | ||||
|     await page.locator('text=OK').click(); | ||||
|     await page.locator('button:has-text("OK")').click(); | ||||
| } | ||||
|  | ||||
| async function waitForSearchCompletion(page) { | ||||
| @@ -197,75 +197,56 @@ async function waitForSearchCompletion(page) { | ||||
|   * Creates some domain objects for searching | ||||
|   * @param {import('@playwright/test').Page} page | ||||
|   */ | ||||
| async function createObjectsForSearch(page, myItemsFolderName) { | ||||
| async function createObjectsForSearch(page) { | ||||
|     //Go to baseURL | ||||
|     await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li:has-text("Folder") >> nth=1').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill('Red Folder'), | ||||
|         await page.locator(`text=Save In Open MCT ${myItemsFolderName} >> span`).nth(3).click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const redFolder = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Folder', | ||||
|         name: 'Red Folder' | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li:has-text("Folder") >> nth=2').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"]').fill('Blue Folder'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Red Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const blueFolder = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Folder', | ||||
|         name: 'Blue Folder', | ||||
|         parent: redFolder.uuid | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock A'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const clockA = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock A', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockB = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock B', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockC = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock C', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|     const clockD = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Clock', | ||||
|         name: 'Clock D', | ||||
|         parent: blueFolder.uuid | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock B'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     const displayLayout = await createDomainObjectWithDefaults(page, { | ||||
|         type: 'Display Layout' | ||||
|     }); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock C'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     // Go back into edit mode for the display layout | ||||
|     await page.locator('button[title="Edit"]').click(); | ||||
|  | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     await page.locator('li[title="A digital clock that uses system time and supports a variety of display formats and timezones."]').click(); | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         await page.locator('text=Properties Title Notes >> input[type="text"] >> nth=0').fill('Clock D'), | ||||
|         await page.locator('form[name="mctForm"] >> text=Blue Folder').click(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|  | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator(`a:has-text("${myItemsFolderName}") >> nth=0`).click() | ||||
|     ]); | ||||
|     // Click button:has-text("Create") | ||||
|     await page.locator('button:has-text("Create")').click(); | ||||
|     // Click li:has-text("Notebook") | ||||
|     await page.locator('li:has-text("Display Layout")').click(); | ||||
|     // Click button:has-text("OK") | ||||
|     await Promise.all([ | ||||
|         page.waitForNavigation(), | ||||
|         page.locator('button:has-text("OK")').click() | ||||
|     ]); | ||||
|     return { | ||||
|         redFolder, | ||||
|         blueFolder, | ||||
|         clockA, | ||||
|         clockB, | ||||
|         clockC, | ||||
|         clockD, | ||||
|         displayLayout | ||||
|     }; | ||||
| } | ||||
|   | ||||
| @@ -53,7 +53,7 @@ test.describe('Performance tests', () => { | ||||
|         await page.setInputFiles('#fileElem', filePath); | ||||
|  | ||||
|         // Click text=OK | ||||
|         await page.locator('text=OK').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|         await expect(page.locator('a:has-text("Performance Display Layout Display Layout")')).toBeVisible(); | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,8 @@ define([ | ||||
|         dataRateInHz: 1, | ||||
|         randomness: 0, | ||||
|         phase: 0, | ||||
|         loadDelay: 0 | ||||
|         loadDelay: 0, | ||||
|         infinityValues: false | ||||
|     }; | ||||
|  | ||||
|     function GeneratorProvider(openmct) { | ||||
| @@ -56,7 +57,8 @@ define([ | ||||
|             'dataRateInHz', | ||||
|             'randomness', | ||||
|             'phase', | ||||
|             'loadDelay' | ||||
|             'loadDelay', | ||||
|             'infinityValues' | ||||
|         ]; | ||||
|  | ||||
|         request = request || {}; | ||||
|   | ||||
| @@ -76,10 +76,10 @@ | ||||
|                             name: data.name, | ||||
|                             utc: nextStep, | ||||
|                             yesterday: nextStep - 60 * 60 * 24 * 1000, | ||||
|                             sin: sin(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness), | ||||
|                             sin: sin(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness, data.infinityValues), | ||||
|                             wavelengths: wavelengths(), | ||||
|                             intensities: intensities(), | ||||
|                             cos: cos(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness) | ||||
|                             cos: cos(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness, data.infinityValues) | ||||
|                         } | ||||
|                     }); | ||||
|                     nextStep += step; | ||||
| @@ -117,6 +117,7 @@ | ||||
|         var phase = request.phase; | ||||
|         var randomness = request.randomness; | ||||
|         var loadDelay = Math.max(request.loadDelay, 0); | ||||
|         var infinityValues = request.infinityValues; | ||||
|  | ||||
|         var step = 1000 / dataRateInHz; | ||||
|         var nextStep = start - (start % step) + step; | ||||
| @@ -127,10 +128,10 @@ | ||||
|             data.push({ | ||||
|                 utc: nextStep, | ||||
|                 yesterday: nextStep - 60 * 60 * 24 * 1000, | ||||
|                 sin: sin(nextStep, period, amplitude, offset, phase, randomness), | ||||
|                 sin: sin(nextStep, period, amplitude, offset, phase, randomness, infinityValues), | ||||
|                 wavelengths: wavelengths(), | ||||
|                 intensities: intensities(), | ||||
|                 cos: cos(nextStep, period, amplitude, offset, phase, randomness) | ||||
|                 cos: cos(nextStep, period, amplitude, offset, phase, randomness, infinityValues) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
| @@ -155,12 +156,20 @@ | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     function cos(timestamp, period, amplitude, offset, phase, randomness) { | ||||
|     function cos(timestamp, period, amplitude, offset, phase, randomness, infinityValues) { | ||||
|         if (infinityValues && Math.random() > 0.5) { | ||||
|             return Number.POSITIVE_INFINITY; | ||||
|         } | ||||
|  | ||||
|         return amplitude | ||||
|             * Math.cos(phase + (timestamp / period / 1000 * Math.PI * 2)) + (amplitude * Math.random() * randomness) + offset; | ||||
|     } | ||||
|  | ||||
|     function sin(timestamp, period, amplitude, offset, phase, randomness) { | ||||
|     function sin(timestamp, period, amplitude, offset, phase, randomness, infinityValues) { | ||||
|         if (infinityValues && Math.random() > 0.5) { | ||||
|             return Number.POSITIVE_INFINITY; | ||||
|         } | ||||
|  | ||||
|         return amplitude | ||||
|             * Math.sin(phase + (timestamp / period / 1000 * Math.PI * 2)) + (amplitude * Math.random() * randomness) + offset; | ||||
|     } | ||||
|   | ||||
| @@ -143,6 +143,16 @@ define([ | ||||
|                         "telemetry", | ||||
|                         "loadDelay" | ||||
|                     ] | ||||
|                 }, | ||||
|                 { | ||||
|                     name: "Include Infinity Values", | ||||
|                     control: "toggleSwitch", | ||||
|                     cssClass: "l-input", | ||||
|                     key: "infinityValues", | ||||
|                     property: [ | ||||
|                         "telemetry", | ||||
|                         "infinityValues" | ||||
|                     ] | ||||
|                 } | ||||
|             ], | ||||
|             initialize: function (object) { | ||||
| @@ -153,7 +163,8 @@ define([ | ||||
|                     dataRateInHz: 1, | ||||
|                     phase: 0, | ||||
|                     randomness: 0, | ||||
|                     loadDelay: 0 | ||||
|                     loadDelay: 0, | ||||
|                     infinityValues: false | ||||
|                 }; | ||||
|             } | ||||
|         }); | ||||
|   | ||||
							
								
								
									
										45
									
								
								openmct.js
									
									
									
									
									
								
							
							
						
						| @@ -30,8 +30,53 @@ if (document.currentScript) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} BuildInfo | ||||
|  * @property {string} version | ||||
|  * @property {string} buildDate | ||||
|  * @property {string} revision | ||||
|  * @property {string} branch | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} OpenMCT | ||||
|  * @property {BuildInfo} buildInfo | ||||
|  * @property {*} selection | ||||
|  * @property {import('./src/api/time/TimeAPI').default} time | ||||
|  * @property {import('./src/api/composition/CompositionAPI').default} composition | ||||
|  * @property {*} objectViews | ||||
|  * @property {*} inspectorViews | ||||
|  * @property {*} propertyEditors | ||||
|  * @property {*} toolbars | ||||
|  * @property {*} types | ||||
|  * @property {import('./src/api/objects/ObjectAPI').default} objects | ||||
|  * @property {import('./src/api/telemetry/TelemetryAPI').default} telemetry | ||||
|  * @property {import('./src/api/indicators/IndicatorAPI').default} indicators | ||||
|  * @property {import('./src/api/user/UserAPI').default} user | ||||
|  * @property {import('./src/api/notifications/NotificationAPI').default} notifications | ||||
|  * @property {import('./src/api/Editor').default} editor | ||||
|  * @property {import('./src/api/overlays/OverlayAPI')} overlays | ||||
|  * @property {import('./src/api/menu/MenuAPI').default} menus | ||||
|  * @property {import('./src/api/actions/ActionsAPI').default} actions | ||||
|  * @property {import('./src/api/status/StatusAPI').default} status | ||||
|  * @property {*} priority | ||||
|  * @property {import('./src/ui/router/ApplicationRouter')} router | ||||
|  * @property {import('./src/api/faultmanagement/FaultManagementAPI').default} faults | ||||
|  * @property {import('./src/api/forms/FormsAPI').default} forms | ||||
|  * @property {import('./src/api/Branding').default} branding | ||||
|  * @property {import('./src/api/annotation/AnnotationAPI').default} annotation | ||||
|  * @property {{(plugin: OpenMCTPlugin) => void}} install | ||||
|  * @property {{() => string}} getAssetPath | ||||
|  * @property {{(domElement: HTMLElement, isHeadlessMode: boolean) => void}} start | ||||
|  * @property {{() => void}} startHeadless | ||||
|  * @property {{() => void}} destroy | ||||
|  * @property {OpenMCTPlugin[]} plugins | ||||
|  * @property {OpenMCTComponent[]} components | ||||
|  */ | ||||
|  | ||||
| const MCT = require('./src/MCT'); | ||||
|  | ||||
| /** @type {OpenMCT} */ | ||||
| const openmct = new MCT(); | ||||
|  | ||||
| module.exports = openmct; | ||||
|   | ||||
							
								
								
									
										37
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,19 +1,16 @@ | ||||
| { | ||||
|   "name": "openmct", | ||||
|   "version": "2.1.1", | ||||
|   "version": "2.1.5-SNAPSHOT", | ||||
|   "description": "The Open MCT core platform", | ||||
|   "devDependencies": { | ||||
|     "@babel/eslint-parser": "7.18.9", | ||||
|     "@braintree/sanitize-url": "6.0.0", | ||||
|     "@percy/cli": "1.10.3", | ||||
|     "@braintree/sanitize-url": "6.0.2", | ||||
|     "@percy/cli": "1.16.0", | ||||
|     "@percy/playwright": "1.0.4", | ||||
|     "@playwright/test": "1.25.2", | ||||
|     "@types/eventemitter3": "^1.0.0", | ||||
|     "@types/jasmine": "^4.0.1", | ||||
|     "@types/karma": "^6.3.2", | ||||
|     "@types/lodash": "^4.14.178", | ||||
|     "@types/mocha": "^9.1.0", | ||||
|     "babel-loader": "8.2.5", | ||||
|     "@types/jasmine": "4.3.1", | ||||
|     "@types/lodash": "4.14.191", | ||||
|     "babel-loader": "9.1.0", | ||||
|     "babel-plugin-istanbul": "6.1.1", | ||||
|     "codecov": "3.8.3", | ||||
|     "comma-separated-values": "3.6.4", | ||||
| @@ -22,17 +19,17 @@ | ||||
|     "d3-axis": "3.0.0", | ||||
|     "d3-scale": "3.3.0", | ||||
|     "d3-selection": "3.0.0", | ||||
|     "eslint": "8.23.1", | ||||
|     "eslint": "8.29.0", | ||||
|     "eslint-plugin-compat": "4.0.2", | ||||
|     "eslint-plugin-playwright": "0.11.2", | ||||
|     "eslint-plugin-vue": "9.3.0", | ||||
|     "eslint-plugin-vue": "9.8.0", | ||||
|     "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", | ||||
|     "eventemitter3": "1.2.0", | ||||
|     "file-saver": "2.0.5", | ||||
|     "git-rev-sync": "3.0.2", | ||||
|     "html2canvas": "1.4.1", | ||||
|     "imports-loader": "4.0.1", | ||||
|     "jasmine-core": "4.4.0", | ||||
|     "jasmine-core": "4.5.0", | ||||
|     "karma": "6.3.20", | ||||
|     "karma-chrome-launcher": "3.1.1", | ||||
|     "karma-cli": "2.0.0", | ||||
| @@ -45,10 +42,10 @@ | ||||
|     "karma-webpack": "5.0.0", | ||||
|     "location-bar": "3.0.1", | ||||
|     "lodash": "4.17.21", | ||||
|     "mini-css-extract-plugin": "2.6.1", | ||||
|     "mini-css-extract-plugin": "2.7.2", | ||||
|     "moment": "2.29.4", | ||||
|     "moment-duration-format": "2.3.2", | ||||
|     "moment-timezone": "0.5.37", | ||||
|     "moment-timezone": "0.5.38", | ||||
|     "nyc": "15.1.0", | ||||
|     "painterro": "1.2.78", | ||||
|     "playwright-core": "1.25.2", | ||||
| @@ -56,17 +53,18 @@ | ||||
|     "plotly.js-gl2d-dist": "2.14.0", | ||||
|     "printj": "1.3.1", | ||||
|     "resolve-url-loader": "5.0.0", | ||||
|     "sass": "1.55.0", | ||||
|     "sass": "1.56.1", | ||||
|     "sass-loader": "13.0.2", | ||||
|     "sinon": "14.0.0", | ||||
|     "sinon": "14.0.1", | ||||
|     "style-loader": "^3.3.1", | ||||
|     "typescript": "4.9.4", | ||||
|     "uuid": "9.0.0", | ||||
|     "vue": "2.6.14", | ||||
|     "vue-eslint-parser": "9.1.0", | ||||
|     "vue-loader": "15.9.8", | ||||
|     "vue-template-compiler": "2.6.14", | ||||
|     "webpack": "5.74.0", | ||||
|     "webpack-cli": "4.10.0", | ||||
|     "webpack-cli": "5.0.0", | ||||
|     "webpack-dev-server": "4.11.1", | ||||
|     "webpack-merge": "5.8.0" | ||||
|   }, | ||||
| @@ -99,7 +97,7 @@ | ||||
|     "cov:e2e:full:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-full", | ||||
|     "cov:e2e:stable:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-stable", | ||||
|     "cov:unit:publish": "codecov --disable=gcov -f ./coverage/unit/lcov.info -F unit", | ||||
|     "prepare": "npm run build:prod" | ||||
|     "prepare": "npm run build:prod && npx tsc" | ||||
|   }, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
| @@ -116,6 +114,5 @@ | ||||
|     "ios_saf > 15" | ||||
|   ], | ||||
|   "author": "", | ||||
|   "license": "Apache-2.0", | ||||
|   "private": true | ||||
|   "license": "Apache-2.0" | ||||
| } | ||||
|   | ||||
							
								
								
									
										10
									
								
								src/MCT.js
									
									
									
									
									
								
							
							
						
						| @@ -19,7 +19,7 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* eslint-disable no-undef */ | ||||
| define([ | ||||
|     'EventEmitter', | ||||
|     './api/api', | ||||
| @@ -81,13 +81,11 @@ define([ | ||||
|     /** | ||||
|      * The Open MCT application. This may be configured by installing plugins | ||||
|      * or registering extensions before the application is started. | ||||
|      * @class MCT | ||||
|      * @constructor | ||||
|      * @memberof module:openmct | ||||
|      * @augments {EventEmitter} | ||||
|      */ | ||||
|     function MCT() { | ||||
|         EventEmitter.call(this); | ||||
|         /* eslint-disable no-undef */ | ||||
|         this.buildInfo = { | ||||
|             version: __OPENMCT_VERSION__, | ||||
|             buildDate: __OPENMCT_BUILD_DATE__, | ||||
| @@ -101,7 +99,7 @@ define([ | ||||
|             * Tracks current selection state of the application. | ||||
|             * @private | ||||
|             */ | ||||
|             ['selection', () => new Selection(this)], | ||||
|             ['selection', () => new Selection.default(this)], | ||||
|  | ||||
|             /** | ||||
|              * MCT's time conductor, which may be used to synchronize view contents | ||||
| @@ -125,7 +123,7 @@ define([ | ||||
|              * @memberof module:openmct.MCT# | ||||
|              * @name composition | ||||
|              */ | ||||
|             ['composition', () => new api.CompositionAPI(this)], | ||||
|             ['composition', () => new api.CompositionAPI.default(this)], | ||||
|  | ||||
|             /** | ||||
|              * Registry for views of domain objects which should appear in the | ||||
|   | ||||
| @@ -23,8 +23,7 @@ | ||||
| let brandingOptions = {}; | ||||
|  | ||||
| /** | ||||
|  * @typedef {Object} BrandingOptions | ||||
|  * @memberOf openmct/branding | ||||
|  * @typedef {object} BrandingOptions | ||||
|  * @property {string} smallLogoImage URL to the image to use as the applications logo. | ||||
|  * This logo will appear on every screen and when clicked will launch the about dialog. | ||||
|  * @property {string} aboutHtml Custom content for the about screen. When defined the | ||||
|   | ||||
| @@ -56,17 +56,12 @@ export default class Editor extends EventEmitter { | ||||
|      * Save any unsaved changes from this editing session. This will | ||||
|      * end the current transaction. | ||||
|      */ | ||||
|     save() { | ||||
|     async save() { | ||||
|         const transaction = this.openmct.objects.getActiveTransaction(); | ||||
|  | ||||
|         return transaction.commit() | ||||
|             .then(() => { | ||||
|                 this.editing = false; | ||||
|                 this.emit('isEditing', false); | ||||
|                 this.openmct.objects.endTransaction(); | ||||
|             }).catch(error => { | ||||
|                 throw error; | ||||
|             }); | ||||
|         await transaction.commit(); | ||||
|         this.editing = false; | ||||
|         this.emit('isEditing', false); | ||||
|         this.openmct.objects.endTransaction(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -94,7 +94,6 @@ describe("The Annotation API", () => { | ||||
|         openmct.startHeadless(); | ||||
|     }); | ||||
|     afterEach(async () => { | ||||
|         openmct.objects.providers = {}; | ||||
|         await resetApplicationState(openmct); | ||||
|     }); | ||||
|     it("is defined", () => { | ||||
|   | ||||
| @@ -37,7 +37,9 @@ define([ | ||||
|     './types/TypeRegistry', | ||||
|     './user/UserAPI', | ||||
|     './annotation/AnnotationAPI' | ||||
| ], function ( | ||||
| ], | ||||
|  | ||||
| function ( | ||||
|     ActionsAPI, | ||||
|     CompositionAPI, | ||||
|     EditorAPI, | ||||
|   | ||||
| @@ -20,34 +20,41 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| define([ | ||||
|     'lodash', | ||||
|     'EventEmitter', | ||||
|     './DefaultCompositionProvider', | ||||
|     './CompositionCollection' | ||||
| ], function ( | ||||
|     _, | ||||
|     EventEmitter, | ||||
|     DefaultCompositionProvider, | ||||
|     CompositionCollection | ||||
| ) { | ||||
| import DefaultCompositionProvider from './DefaultCompositionProvider'; | ||||
| import CompositionCollection from './CompositionCollection'; | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('./CompositionProvider').default} CompositionProvider | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * An interface for interacting with the composition of domain objects. | ||||
|  * The composition of a domain object is the list of other domain objects | ||||
|  * it "contains" (for instance, that should be displayed beneath it | ||||
|  * in the tree.) | ||||
|  * @constructor | ||||
|  */ | ||||
| export default class CompositionAPI { | ||||
|     /** | ||||
|      * An interface for interacting with the composition of domain objects. | ||||
|      * The composition of a domain object is the list of other domain objects | ||||
|      * it "contains" (for instance, that should be displayed beneath it | ||||
|      * in the tree.) | ||||
|      * | ||||
|      * @interface CompositionAPI | ||||
|      * @returns {module:openmct.CompositionCollection} | ||||
|      * @memberof module:openmct | ||||
|      * @param {OpenMCT} publicAPI | ||||
|      */ | ||||
|     function CompositionAPI(publicAPI) { | ||||
|     constructor(publicAPI) { | ||||
|         /** @type {CompositionProvider[]} */ | ||||
|         this.registry = []; | ||||
|         /** @type {CompositionPolicy[]} */ | ||||
|         this.policies = []; | ||||
|         this.addProvider(new DefaultCompositionProvider(publicAPI, this)); | ||||
|         /** @type {OpenMCT} */ | ||||
|         this.publicAPI = publicAPI; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add a composition provider. | ||||
|      * | ||||
| @@ -55,21 +62,19 @@ define([ | ||||
|      * behavior for certain domain objects. | ||||
|      * | ||||
|      * @method addProvider | ||||
|      * @param {module:openmct.CompositionProvider} provider the provider to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      * @param {CompositionProvider} provider the provider to add | ||||
|      */ | ||||
|     CompositionAPI.prototype.addProvider = function (provider) { | ||||
|     addProvider(provider) { | ||||
|         this.registry.unshift(provider); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Retrieve the composition (if any) of this domain object. | ||||
|      * | ||||
|      * @method get | ||||
|      * @returns {module:openmct.CompositionCollection} | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @returns {CompositionCollection} | ||||
|      */ | ||||
|     CompositionAPI.prototype.get = function (domainObject) { | ||||
|     get(domainObject) { | ||||
|         const provider = this.registry.find(p => { | ||||
|             return p.appliesTo(domainObject); | ||||
|         }); | ||||
| @@ -79,8 +84,7 @@ define([ | ||||
|         } | ||||
|  | ||||
|         return new CompositionCollection(domainObject, provider, this.publicAPI); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * A composition policy is a function which either allows or disallows | ||||
|      * placing one object in another's composition. | ||||
| @@ -90,52 +94,51 @@ define([ | ||||
|      * generally be written to return true in the default case. | ||||
|      * | ||||
|      * @callback CompositionPolicy | ||||
|      * @memberof module:openmct.CompositionAPI~ | ||||
|      * @param {module:openmct.DomainObject} containingObject the object which | ||||
|      * @param {DomainObject} containingObject the object which | ||||
|      *        would act as a container | ||||
|      * @param {module:openmct.DomainObject} containedObject the object which | ||||
|      * @param {DomainObject} containedObject the object which | ||||
|      *        would be contained | ||||
|      * @returns {boolean} false if this composition should be disallowed | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Add a composition policy. Composition policies may disallow domain | ||||
|      * objects from containing other domain objects. | ||||
|      * | ||||
|      * @method addPolicy | ||||
|      * @param {module:openmct.CompositionAPI~CompositionPolicy} policy | ||||
|      * @param {CompositionPolicy} policy | ||||
|      *        the policy to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      */ | ||||
|     CompositionAPI.prototype.addPolicy = function (policy) { | ||||
|     addPolicy(policy) { | ||||
|         this.policies.push(policy); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Check whether or not a domain object is allowed to contain another | ||||
|      * domain object. | ||||
|      * | ||||
|      * @private | ||||
|      * @method checkPolicy | ||||
|      * @param {module:openmct.DomainObject} containingObject the object which | ||||
|      * @param {DomainObject} container the object which | ||||
|      *        would act as a container | ||||
|      * @param {module:openmct.DomainObject} containedObject the object which | ||||
|      * @param {DomainObject} containee the object which | ||||
|      *        would be contained | ||||
|      * @returns {boolean} false if this composition should be disallowed | ||||
|  | ||||
|      * @param {module:openmct.CompositionAPI~CompositionPolicy} policy | ||||
|      * @param {CompositionPolicy} policy | ||||
|      *        the policy to add | ||||
|      * @memberof module:openmct.CompositionAPI# | ||||
|      */ | ||||
|     CompositionAPI.prototype.checkPolicy = function (container, containee) { | ||||
|     checkPolicy(container, containee) { | ||||
|         return this.policies.every(function (policy) { | ||||
|             return policy(container, containee); | ||||
|         }); | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     CompositionAPI.prototype.supportsComposition = function (domainObject) { | ||||
|     /** | ||||
|      * Check whether or not a domainObject supports composition | ||||
|      * | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @returns {boolean} true if the domainObject supports composition | ||||
|      */ | ||||
|     supportsComposition(domainObject) { | ||||
|         return this.get(domainObject) !== undefined; | ||||
|     }; | ||||
|     } | ||||
| } | ||||
|  | ||||
|     return CompositionAPI; | ||||
| }); | ||||
|   | ||||
| @@ -1,325 +1,319 @@ | ||||
| define([ | ||||
|     './CompositionAPI', | ||||
|     './CompositionCollection' | ||||
| ], function ( | ||||
|     CompositionAPI, | ||||
|     CompositionCollection | ||||
| ) { | ||||
| import CompositionAPI from './CompositionAPI'; | ||||
| import CompositionCollection from './CompositionCollection'; | ||||
|  | ||||
|     describe('The Composition API', function () { | ||||
|         let publicAPI; | ||||
|         let compositionAPI; | ||||
|         let topicService; | ||||
|         let mutationTopic; | ||||
| describe('The Composition API', function () { | ||||
|     let publicAPI; | ||||
|     let compositionAPI; | ||||
|     let topicService; | ||||
|     let mutationTopic; | ||||
|  | ||||
|     beforeEach(function () { | ||||
|  | ||||
|         mutationTopic = jasmine.createSpyObj('mutationTopic', [ | ||||
|             'listen' | ||||
|         ]); | ||||
|         topicService = jasmine.createSpy('topicService'); | ||||
|         topicService.and.returnValue(mutationTopic); | ||||
|         publicAPI = {}; | ||||
|         publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [ | ||||
|             'get', | ||||
|             'mutate', | ||||
|             'observe', | ||||
|             'areIdsEqual' | ||||
|         ]); | ||||
|  | ||||
|         publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { | ||||
|             return id1.namespace === id2.namespace && id1.key === id2.key; | ||||
|         }); | ||||
|  | ||||
|         publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ | ||||
|             'checkPolicy' | ||||
|         ]); | ||||
|         publicAPI.composition.checkPolicy.and.returnValue(true); | ||||
|  | ||||
|         publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ | ||||
|             'on' | ||||
|         ]); | ||||
|         publicAPI.objects.get.and.callFake(function (identifier) { | ||||
|             return Promise.resolve({identifier: identifier}); | ||||
|         }); | ||||
|         publicAPI.$injector = jasmine.createSpyObj('$injector', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         publicAPI.$injector.get.and.returnValue(topicService); | ||||
|         compositionAPI = new CompositionAPI(publicAPI); | ||||
|     }); | ||||
|  | ||||
|     it('returns falsy if an object does not support composition', function () { | ||||
|         expect(compositionAPI.get({})).toBeFalsy(); | ||||
|     }); | ||||
|  | ||||
|     describe('default composition', function () { | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|  | ||||
|             mutationTopic = jasmine.createSpyObj('mutationTopic', [ | ||||
|                 'listen' | ||||
|             ]); | ||||
|             topicService = jasmine.createSpy('topicService'); | ||||
|             topicService.and.returnValue(mutationTopic); | ||||
|             publicAPI = {}; | ||||
|             publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [ | ||||
|                 'get', | ||||
|                 'mutate', | ||||
|                 'observe', | ||||
|                 'areIdsEqual' | ||||
|             ]); | ||||
|  | ||||
|             publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { | ||||
|                 return id1.namespace === id2.namespace && id1.key === id2.key; | ||||
|             }); | ||||
|  | ||||
|             publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ | ||||
|                 'checkPolicy' | ||||
|             ]); | ||||
|             publicAPI.composition.checkPolicy.and.returnValue(true); | ||||
|  | ||||
|             publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ | ||||
|                 'on' | ||||
|             ]); | ||||
|             publicAPI.objects.get.and.callFake(function (identifier) { | ||||
|                 return Promise.resolve({identifier: identifier}); | ||||
|             }); | ||||
|             publicAPI.$injector = jasmine.createSpyObj('$injector', [ | ||||
|                 'get' | ||||
|             ]); | ||||
|             publicAPI.$injector.get.and.returnValue(topicService); | ||||
|             compositionAPI = new CompositionAPI(publicAPI); | ||||
|             domainObject = { | ||||
|                 name: 'test folder', | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 composition: [ | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'a' | ||||
|                     }, | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'b' | ||||
|                     }, | ||||
|                     { | ||||
|                         namespace: 'test', | ||||
|                         key: 'c' | ||||
|                     } | ||||
|                 ] | ||||
|             }; | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('returns falsy if an object does not support composition', function () { | ||||
|             expect(compositionAPI.get({})).toBeFalsy(); | ||||
|         it('returns composition collection', function () { | ||||
|             expect(composition).toBeDefined(); | ||||
|             expect(composition).toEqual(jasmine.any(CompositionCollection)); | ||||
|         }); | ||||
|  | ||||
|         describe('default composition', function () { | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|         it('correctly reflects composability', function () { | ||||
|             expect(compositionAPI.supportsComposition(domainObject)).toBe(true); | ||||
|             delete domainObject.composition; | ||||
|             expect(compositionAPI.supportsComposition(domainObject)).toBe(false); | ||||
|         }); | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 domainObject = { | ||||
|                     name: 'test folder', | ||||
|         it('loads composition from domain object', function () { | ||||
|             const listener = jasmine.createSpy('addListener'); | ||||
|             composition.on('add', listener); | ||||
|  | ||||
|             return composition.load().then(function () { | ||||
|                 expect(listener.calls.count()).toBe(3); | ||||
|                 expect(listener).toHaveBeenCalledWith({ | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     composition: [ | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'a' | ||||
|                         }, | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'b' | ||||
|                         }, | ||||
|                         { | ||||
|                             namespace: 'test', | ||||
|                             key: 'c' | ||||
|                         } | ||||
|                     ] | ||||
|                 }; | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             }); | ||||
|  | ||||
|             it('returns composition collection', function () { | ||||
|                 expect(composition).toBeDefined(); | ||||
|                 expect(composition).toEqual(jasmine.any(CompositionCollection)); | ||||
|             }); | ||||
|  | ||||
|             it('correctly reflects composability', function () { | ||||
|                 expect(compositionAPI.supportsComposition(domainObject)).toBe(true); | ||||
|                 delete domainObject.composition; | ||||
|                 expect(compositionAPI.supportsComposition(domainObject)).toBe(false); | ||||
|             }); | ||||
|  | ||||
|             it('loads composition from domain object', function () { | ||||
|                 const listener = jasmine.createSpy('addListener'); | ||||
|                 composition.on('add', listener); | ||||
|  | ||||
|                 return composition.load().then(function () { | ||||
|                     expect(listener.calls.count()).toBe(3); | ||||
|                     expect(listener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'test', | ||||
|                             key: 'a' | ||||
|                         } | ||||
|                     }); | ||||
|                         key: 'a' | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|             describe('supports reordering of composition', function () { | ||||
|                 let listener; | ||||
|                 beforeEach(function () { | ||||
|                     listener = jasmine.createSpy('reorderListener'); | ||||
|                     composition.on('reorder', listener); | ||||
|         }); | ||||
|         describe('supports reordering of composition', function () { | ||||
|             let listener; | ||||
|             beforeEach(function () { | ||||
|                 listener = jasmine.createSpy('reorderListener'); | ||||
|                 composition.on('reorder', listener); | ||||
|  | ||||
|                     return composition.load(); | ||||
|                 }); | ||||
|                 it('', function () { | ||||
|                     composition.reorder(1, 0); | ||||
|                     let newComposition = | ||||
|                 return composition.load(); | ||||
|             }); | ||||
|             it('', function () { | ||||
|                 composition.reorder(1, 0); | ||||
|                 let newComposition = | ||||
|                         publicAPI.objects.mutate.calls.mostRecent().args[2]; | ||||
|                     let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|                 let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|  | ||||
|                     expect(reorderPlan.oldIndex).toBe(1); | ||||
|                     expect(reorderPlan.newIndex).toBe(0); | ||||
|                     expect(newComposition[0].key).toEqual('b'); | ||||
|                     expect(newComposition[1].key).toEqual('a'); | ||||
|                     expect(newComposition[2].key).toEqual('c'); | ||||
|                 }); | ||||
|                 it('', function () { | ||||
|                     composition.reorder(0, 2); | ||||
|                     let newComposition = | ||||
|                 expect(reorderPlan.oldIndex).toBe(1); | ||||
|                 expect(reorderPlan.newIndex).toBe(0); | ||||
|                 expect(newComposition[0].key).toEqual('b'); | ||||
|                 expect(newComposition[1].key).toEqual('a'); | ||||
|                 expect(newComposition[2].key).toEqual('c'); | ||||
|             }); | ||||
|             it('', function () { | ||||
|                 composition.reorder(0, 2); | ||||
|                 let newComposition = | ||||
|                         publicAPI.objects.mutate.calls.mostRecent().args[2]; | ||||
|                     let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|                 let reorderPlan = listener.calls.mostRecent().args[0][0]; | ||||
|  | ||||
|                     expect(reorderPlan.oldIndex).toBe(0); | ||||
|                     expect(reorderPlan.newIndex).toBe(2); | ||||
|                     expect(newComposition[0].key).toEqual('b'); | ||||
|                     expect(newComposition[1].key).toEqual('c'); | ||||
|                     expect(newComposition[2].key).toEqual('a'); | ||||
|                 expect(reorderPlan.oldIndex).toBe(0); | ||||
|                 expect(reorderPlan.newIndex).toBe(2); | ||||
|                 expect(newComposition[0].key).toEqual('b'); | ||||
|                 expect(newComposition[1].key).toEqual('c'); | ||||
|                 expect(newComposition[2].key).toEqual('a'); | ||||
|             }); | ||||
|         }); | ||||
|         it('supports adding an object to composition', function () { | ||||
|             let addListener = jasmine.createSpy('addListener'); | ||||
|             let mockChildObject = { | ||||
|                 identifier: { | ||||
|                     key: 'mock-key', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             }; | ||||
|             composition.on('add', addListener); | ||||
|             composition.add(mockChildObject); | ||||
|  | ||||
|             expect(domainObject.composition.length).toBe(4); | ||||
|             expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('static custom composition', function () { | ||||
|         let customProvider; | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|             // A simple custom provider, returns the same composition for | ||||
|             // all objects of a given type. | ||||
|             customProvider = { | ||||
|                 appliesTo: function (object) { | ||||
|                     return object.type === 'custom-object-type'; | ||||
|                 }, | ||||
|                 load: function (object) { | ||||
|                     return Promise.resolve([ | ||||
|                         { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     ]); | ||||
|                 }, | ||||
|                 add: jasmine.createSpy('add'), | ||||
|                 remove: jasmine.createSpy('remove') | ||||
|             }; | ||||
|             domainObject = { | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 type: 'custom-object-type' | ||||
|             }; | ||||
|             compositionAPI.addProvider(customProvider); | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('supports listening and loading', function () { | ||||
|             const addListener = jasmine.createSpy('addListener'); | ||||
|             composition.on('add', addListener); | ||||
|  | ||||
|             return composition.load().then(function (children) { | ||||
|                 let listenObject; | ||||
|                 const loadedObject = children[0]; | ||||
|  | ||||
|                 expect(addListener).toHaveBeenCalled(); | ||||
|  | ||||
|                 listenObject = addListener.calls.mostRecent().args[0]; | ||||
|                 expect(listenObject).toEqual(loadedObject); | ||||
|                 expect(loadedObject).toEqual({ | ||||
|                     identifier: { | ||||
|                         namespace: 'custom', | ||||
|                         key: 'thing' | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|             it('supports adding an object to composition', function () { | ||||
|                 let addListener = jasmine.createSpy('addListener'); | ||||
|                 let mockChildObject = { | ||||
|         }); | ||||
|         describe('Calling add or remove', function () { | ||||
|             let mockChildObject; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 mockChildObject = { | ||||
|                     identifier: { | ||||
|                         key: 'mock-key', | ||||
|                         namespace: '' | ||||
|                     } | ||||
|                 }; | ||||
|                 composition.on('add', addListener); | ||||
|                 composition.add(mockChildObject); | ||||
|  | ||||
|                 expect(domainObject.composition.length).toBe(4); | ||||
|                 expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('static custom composition', function () { | ||||
|             let customProvider; | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 // A simple custom provider, returns the same composition for | ||||
|                 // all objects of a given type. | ||||
|                 customProvider = { | ||||
|                     appliesTo: function (object) { | ||||
|                         return object.type === 'custom-object-type'; | ||||
|                     }, | ||||
|                     load: function (object) { | ||||
|                         return Promise.resolve([ | ||||
|                             { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         ]); | ||||
|                     }, | ||||
|                     add: jasmine.createSpy('add'), | ||||
|                     remove: jasmine.createSpy('remove') | ||||
|                 }; | ||||
|                 domainObject = { | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     type: 'custom-object-type' | ||||
|                 }; | ||||
|                 compositionAPI.addProvider(customProvider); | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             }); | ||||
|  | ||||
|             it('supports listening and loading', function () { | ||||
|                 const addListener = jasmine.createSpy('addListener'); | ||||
|                 composition.on('add', addListener); | ||||
|  | ||||
|                 return composition.load().then(function (children) { | ||||
|                     let listenObject; | ||||
|                     const loadedObject = children[0]; | ||||
|  | ||||
|                     expect(addListener).toHaveBeenCalled(); | ||||
|  | ||||
|                     listenObject = addListener.calls.mostRecent().args[0]; | ||||
|                     expect(listenObject).toEqual(loadedObject); | ||||
|                     expect(loadedObject).toEqual({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|             describe('Calling add or remove', function () { | ||||
|                 let mockChildObject; | ||||
|  | ||||
|                 beforeEach(function () { | ||||
|                     mockChildObject = { | ||||
|                         identifier: { | ||||
|                             key: 'mock-key', | ||||
|                             namespace: '' | ||||
|                         } | ||||
|                     }; | ||||
|                     composition.add(mockChildObject); | ||||
|                 }); | ||||
|  | ||||
|                 it('calls add on the provider', function () { | ||||
|                     expect(customProvider.add).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|                 }); | ||||
|  | ||||
|                 it('calls remove on the provider', function () { | ||||
|                     composition.remove(mockChildObject); | ||||
|                     expect(customProvider.remove).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('dynamic custom composition', function () { | ||||
|             let customProvider; | ||||
|             let domainObject; | ||||
|             let composition; | ||||
|  | ||||
|             beforeEach(function () { | ||||
|                 // A dynamic provider, loads an empty composition and exposes | ||||
|                 // listener functions. | ||||
|                 customProvider = jasmine.createSpyObj('dynamicProvider', [ | ||||
|                     'appliesTo', | ||||
|                     'load', | ||||
|                     'on', | ||||
|                     'off' | ||||
|                 ]); | ||||
|  | ||||
|                 customProvider.appliesTo.and.returnValue('true'); | ||||
|                 customProvider.load.and.returnValue(Promise.resolve([])); | ||||
|  | ||||
|                 domainObject = { | ||||
|                     identifier: { | ||||
|                         namespace: 'test', | ||||
|                         key: '1' | ||||
|                     }, | ||||
|                     type: 'custom-object-type' | ||||
|                 }; | ||||
|                 compositionAPI.addProvider(customProvider); | ||||
|                 composition = compositionAPI.get(domainObject); | ||||
|             it('calls add on the provider', function () { | ||||
|                 expect(customProvider.add).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|             }); | ||||
|  | ||||
|             it('supports listening and loading', function () { | ||||
|                 const addListener = jasmine.createSpy('addListener'); | ||||
|                 const removeListener = jasmine.createSpy('removeListener'); | ||||
|                 const addPromise = new Promise(function (resolve) { | ||||
|                     addListener.and.callFake(resolve); | ||||
|                 }); | ||||
|                 const removePromise = new Promise(function (resolve) { | ||||
|                     removeListener.and.callFake(resolve); | ||||
|                 }); | ||||
|  | ||||
|                 composition.on('add', addListener); | ||||
|                 composition.on('remove', removeListener); | ||||
|  | ||||
|                 expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     'add', | ||||
|                     jasmine.any(Function), | ||||
|                     jasmine.any(CompositionCollection) | ||||
|                 ); | ||||
|                 expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     'remove', | ||||
|                     jasmine.any(Function), | ||||
|                     jasmine.any(CompositionCollection) | ||||
|                 ); | ||||
|                 const add = customProvider.on.calls.all()[0].args[2]; | ||||
|                 const remove = customProvider.on.calls.all()[1].args[2]; | ||||
|  | ||||
|                 return composition.load() | ||||
|                     .then(function () { | ||||
|                         expect(addListener).not.toHaveBeenCalled(); | ||||
|                         expect(removeListener).not.toHaveBeenCalled(); | ||||
|                         add({ | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         }); | ||||
|  | ||||
|                         return addPromise; | ||||
|                     }).then(function () { | ||||
|                         expect(addListener).toHaveBeenCalledWith({ | ||||
|                             identifier: { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         }); | ||||
|                         remove(addListener.calls.mostRecent().args[0]); | ||||
|  | ||||
|                         return removePromise; | ||||
|                     }).then(function () { | ||||
|                         expect(removeListener).toHaveBeenCalledWith({ | ||||
|                             identifier: { | ||||
|                                 namespace: 'custom', | ||||
|                                 key: 'thing' | ||||
|                             } | ||||
|                         }); | ||||
|                     }); | ||||
|             it('calls remove on the provider', function () { | ||||
|                 composition.remove(mockChildObject); | ||||
|                 expect(customProvider.remove).toHaveBeenCalledWith(domainObject, mockChildObject.identifier); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('dynamic custom composition', function () { | ||||
|         let customProvider; | ||||
|         let domainObject; | ||||
|         let composition; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|             // A dynamic provider, loads an empty composition and exposes | ||||
|             // listener functions. | ||||
|             customProvider = jasmine.createSpyObj('dynamicProvider', [ | ||||
|                 'appliesTo', | ||||
|                 'load', | ||||
|                 'on', | ||||
|                 'off' | ||||
|             ]); | ||||
|  | ||||
|             customProvider.appliesTo.and.returnValue('true'); | ||||
|             customProvider.load.and.returnValue(Promise.resolve([])); | ||||
|  | ||||
|             domainObject = { | ||||
|                 identifier: { | ||||
|                     namespace: 'test', | ||||
|                     key: '1' | ||||
|                 }, | ||||
|                 type: 'custom-object-type' | ||||
|             }; | ||||
|             compositionAPI.addProvider(customProvider); | ||||
|             composition = compositionAPI.get(domainObject); | ||||
|         }); | ||||
|  | ||||
|         it('supports listening and loading', function () { | ||||
|             const addListener = jasmine.createSpy('addListener'); | ||||
|             const removeListener = jasmine.createSpy('removeListener'); | ||||
|             const addPromise = new Promise(function (resolve) { | ||||
|                 addListener.and.callFake(resolve); | ||||
|             }); | ||||
|             const removePromise = new Promise(function (resolve) { | ||||
|                 removeListener.and.callFake(resolve); | ||||
|             }); | ||||
|  | ||||
|             composition.on('add', addListener); | ||||
|             composition.on('remove', removeListener); | ||||
|  | ||||
|             expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 'add', | ||||
|                 jasmine.any(Function), | ||||
|                 jasmine.any(CompositionCollection) | ||||
|             ); | ||||
|             expect(customProvider.on).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 'remove', | ||||
|                 jasmine.any(Function), | ||||
|                 jasmine.any(CompositionCollection) | ||||
|             ); | ||||
|             const add = customProvider.on.calls.all()[0].args[2]; | ||||
|             const remove = customProvider.on.calls.all()[1].args[2]; | ||||
|  | ||||
|             return composition.load() | ||||
|                 .then(function () { | ||||
|                     expect(addListener).not.toHaveBeenCalled(); | ||||
|                     expect(removeListener).not.toHaveBeenCalled(); | ||||
|                     add({ | ||||
|                         namespace: 'custom', | ||||
|                         key: 'thing' | ||||
|                     }); | ||||
|  | ||||
|                     return addPromise; | ||||
|                 }).then(function () { | ||||
|                     expect(addListener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                     remove(addListener.calls.mostRecent().args[0]); | ||||
|  | ||||
|                     return removePromise; | ||||
|                 }).then(function () { | ||||
|                     expect(removeListener).toHaveBeenCalledWith({ | ||||
|                         identifier: { | ||||
|                             namespace: 'custom', | ||||
|                             key: 'thing' | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -20,75 +20,98 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| define([ | ||||
|     'lodash' | ||||
| ], function ( | ||||
|     _ | ||||
| ) { | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {object} ListenerMap | ||||
|  * @property {Array.<any>} add | ||||
|  * @property {Array.<any>} remove | ||||
|  * @property {Array.<any>} load | ||||
|  * @property {Array.<any>} reorder | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * A CompositionCollection represents the list of domain objects contained | ||||
|  * by another domain object. It provides methods for loading this | ||||
|  * list asynchronously, modifying this list, and listening for changes to | ||||
|  * this list. | ||||
|  * | ||||
|  * Usage: | ||||
|  * ```javascript | ||||
|  *  var myViewComposition = MCT.composition.get(myViewObject); | ||||
|  *  myViewComposition.on('add', addObjectToView); | ||||
|  *  myViewComposition.on('remove', removeObjectFromView); | ||||
|  *  myViewComposition.load(); // will trigger `add` for all loaded objects. | ||||
|  *  ``` | ||||
|  */ | ||||
| export default class CompositionCollection { | ||||
|     domainObject; | ||||
|     #provider; | ||||
|     #publicAPI; | ||||
|     #listeners; | ||||
|     #mutables; | ||||
|     /** | ||||
|      * A CompositionCollection represents the list of domain objects contained | ||||
|      * by another domain object. It provides methods for loading this | ||||
|      * list asynchronously, modifying this list, and listening for changes to | ||||
|      * this list. | ||||
|      * | ||||
|      * Usage: | ||||
|      * ```javascript | ||||
|      *  var myViewComposition = MCT.composition.get(myViewObject); | ||||
|      *  myViewComposition.on('add', addObjectToView); | ||||
|      *  myViewComposition.on('remove', removeObjectFromView); | ||||
|      *  myViewComposition.load(); // will trigger `add` for all loaded objects. | ||||
|      *  ``` | ||||
|      * | ||||
|      * @interface CompositionCollection | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @constructor | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        whose composition will be contained | ||||
|      * @param {module:openmct.CompositionProvider} provider the provider | ||||
|      * @param {import('./CompositionProvider').default} provider the provider | ||||
|      *        to use to retrieve other domain objects | ||||
|      * @param {module:openmct.CompositionAPI} api the composition API, for | ||||
|      * @param {OpenMCT} publicAPI the composition API, for | ||||
|      *        policy checks | ||||
|      * @memberof module:openmct | ||||
|      */ | ||||
|     function CompositionCollection(domainObject, provider, publicAPI) { | ||||
|     constructor(domainObject, provider, publicAPI) { | ||||
|         this.domainObject = domainObject; | ||||
|         this.provider = provider; | ||||
|         this.publicAPI = publicAPI; | ||||
|         this.listeners = { | ||||
|         /** @type {import('./CompositionProvider').default} */ | ||||
|         this.#provider = provider; | ||||
|         /** @type {OpenMCT} */ | ||||
|         this.#publicAPI = publicAPI; | ||||
|         /** @type {ListenerMap} */ | ||||
|         this.#listeners = { | ||||
|             add: [], | ||||
|             remove: [], | ||||
|             load: [], | ||||
|             reorder: [] | ||||
|         }; | ||||
|         this.onProviderAdd = this.onProviderAdd.bind(this); | ||||
|         this.onProviderRemove = this.onProviderRemove.bind(this); | ||||
|         this.mutables = {}; | ||||
|         this.onProviderAdd = this.#onProviderAdd.bind(this); | ||||
|         this.onProviderRemove = this.#onProviderRemove.bind(this); | ||||
|         this.#mutables = {}; | ||||
|  | ||||
|         if (this.domainObject.isMutable) { | ||||
|             this.returnMutables = true; | ||||
|             let unobserve = this.domainObject.$on('$_destroy', () => { | ||||
|                 Object.values(this.mutables).forEach(mutable => { | ||||
|                     this.publicAPI.objects.destroyMutable(mutable); | ||||
|                 Object.values(this.#mutables).forEach(mutable => { | ||||
|                     this.#publicAPI.objects.destroyMutable(mutable); | ||||
|                 }); | ||||
|                 unobserve(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Listen for changes to this composition.  Supports 'add', 'remove', and | ||||
|      * 'load' events. | ||||
|      * | ||||
|      * @param event event to listen for, either 'add', 'remove' or 'load'. | ||||
|      * @param callback to trigger when event occurs. | ||||
|      * @param [context] context to use when invoking callback, optional. | ||||
|      * @param {string} event event to listen for, either 'add', 'remove' or 'load'. | ||||
|      * @param {(...args: any[]) => void} callback to trigger when event occurs. | ||||
|      * @param {any} [context] to use when invoking callback, optional. | ||||
|      */ | ||||
|     CompositionCollection.prototype.on = function (event, callback, context) { | ||||
|         if (!this.listeners[event]) { | ||||
|     on(event, callback, context) { | ||||
|         if (!this.#listeners[event]) { | ||||
|             throw new Error('Event not supported by composition: ' + event); | ||||
|         } | ||||
|  | ||||
|         if (this.provider.on && this.provider.off) { | ||||
|         if (this.#provider.on && this.#provider.off) { | ||||
|             if (event === 'add') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'add', | ||||
|                     this.onProviderAdd, | ||||
| @@ -97,7 +120,7 @@ define([ | ||||
|             } | ||||
|  | ||||
|             if (event === 'remove') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'remove', | ||||
|                     this.onProviderRemove, | ||||
| @@ -106,36 +129,34 @@ define([ | ||||
|             } | ||||
|  | ||||
|             if (event === 'reorder') { | ||||
|                 this.provider.on( | ||||
|                 this.#provider.on( | ||||
|                     this.domainObject, | ||||
|                     'reorder', | ||||
|                     this.onProviderReorder, | ||||
|                     this.#onProviderReorder, | ||||
|                     this | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         this.listeners[event].push({ | ||||
|         this.#listeners[event].push({ | ||||
|             callback: callback, | ||||
|             context: context | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a listener.  Must be called with same exact parameters as | ||||
|      * `off`. | ||||
|      * | ||||
|      * @param event | ||||
|      * @param callback | ||||
|      * @param [context] | ||||
|      * @param {string} event | ||||
|      * @param {(...args: any[]) => void} callback | ||||
|      * @param {any} [context] | ||||
|      */ | ||||
|  | ||||
|     CompositionCollection.prototype.off = function (event, callback, context) { | ||||
|         if (!this.listeners[event]) { | ||||
|     off(event, callback, context) { | ||||
|         if (!this.#listeners[event]) { | ||||
|             throw new Error('Event not supported by composition: ' + event); | ||||
|         } | ||||
|  | ||||
|         const index = this.listeners[event].findIndex(l => { | ||||
|         const index = this.#listeners[event].findIndex(l => { | ||||
|             return l.callback === callback && l.context === context; | ||||
|         }); | ||||
|  | ||||
| @@ -143,125 +164,116 @@ define([ | ||||
|             throw new Error('Tried to remove a listener that does not exist'); | ||||
|         } | ||||
|  | ||||
|         this.listeners[event].splice(index, 1); | ||||
|         if (this.listeners[event].length === 0) { | ||||
|         this.#listeners[event].splice(index, 1); | ||||
|         if (this.#listeners[event].length === 0) { | ||||
|             this._destroy(); | ||||
|  | ||||
|             // Remove provider listener if this is the last callback to | ||||
|             // be removed. | ||||
|             if (this.provider.off && this.provider.on) { | ||||
|             if (this.#provider.off && this.#provider.on) { | ||||
|                 if (event === 'add') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'add', | ||||
|                         this.onProviderAdd, | ||||
|                         this | ||||
|                     ); | ||||
|                 } else if (event === 'remove') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'remove', | ||||
|                         this.onProviderRemove, | ||||
|                         this | ||||
|                     ); | ||||
|                 } else if (event === 'reorder') { | ||||
|                     this.provider.off( | ||||
|                     this.#provider.off( | ||||
|                         this.domainObject, | ||||
|                         'reorder', | ||||
|                         this.onProviderReorder, | ||||
|                         this.#onProviderReorder, | ||||
|                         this | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Add a domain object to this composition. | ||||
|      * | ||||
|      * A call to [load]{@link module:openmct.CompositionCollection#load} | ||||
|      * must have resolved before using this method. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} child the domain object to add | ||||
|      * @param {boolean} skipMutate true if the underlying provider should | ||||
|      *        not be updated | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * @name add | ||||
|      * **TODO:** Remove `skipMutate` parameter. | ||||
|      * | ||||
|      * @param {DomainObject} child the domain object to add | ||||
|      * @param {boolean} skipMutate | ||||
|      * **Intended for internal use ONLY.** | ||||
|      * true if the underlying provider should not be updated. | ||||
|      */ | ||||
|     CompositionCollection.prototype.add = function (child, skipMutate) { | ||||
|     add(child, skipMutate) { | ||||
|         if (!skipMutate) { | ||||
|             if (!this.publicAPI.composition.checkPolicy(this.domainObject, child)) { | ||||
|             if (!this.#publicAPI.composition.checkPolicy(this.domainObject, child)) { | ||||
|                 throw `Object of type ${child.type} cannot be added to object of type ${this.domainObject.type}`; | ||||
|             } | ||||
|  | ||||
|             this.provider.add(this.domainObject, child.identifier); | ||||
|             this.#provider.add(this.domainObject, child.identifier); | ||||
|         } else { | ||||
|             if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) { | ||||
|                 let keyString = this.publicAPI.objects.makeKeyString(child.identifier); | ||||
|             if (this.returnMutables && this.#publicAPI.objects.supportsMutation(child.identifier)) { | ||||
|                 let keyString = this.#publicAPI.objects.makeKeyString(child.identifier); | ||||
|  | ||||
|                 child = this.publicAPI.objects.toMutable(child); | ||||
|                 this.mutables[keyString] = child; | ||||
|                 child = this.#publicAPI.objects.toMutable(child); | ||||
|                 this.#mutables[keyString] = child; | ||||
|             } | ||||
|  | ||||
|             this.emit('add', child); | ||||
|             this.#emit('add', child); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Load the domain objects in this composition. | ||||
|      * | ||||
|      * @returns {Promise.<Array.<module:openmct.DomainObject>>} a promise for | ||||
|      * @param {AbortSignal} abortSignal | ||||
|      * @returns {Promise.<Array.<DomainObject>>} a promise for | ||||
|      *          the domain objects in this composition | ||||
|      * @memberof {module:openmct.CompositionCollection#} | ||||
|      * @name load | ||||
|      */ | ||||
|     CompositionCollection.prototype.load = function (abortSignal) { | ||||
|         this.cleanUpMutables(); | ||||
|  | ||||
|         return this.provider.load(this.domainObject) | ||||
|             .then(function (children) { | ||||
|                 return Promise.all(children.map((c) => this.publicAPI.objects.get(c, abortSignal))); | ||||
|             }.bind(this)) | ||||
|             .then(function (childObjects) { | ||||
|                 childObjects.forEach(c => this.add(c, true)); | ||||
|  | ||||
|                 return childObjects; | ||||
|             }.bind(this)) | ||||
|             .then(function (children) { | ||||
|                 this.emit('load'); | ||||
|  | ||||
|                 return children; | ||||
|             }.bind(this)); | ||||
|     }; | ||||
|     async load(abortSignal) { | ||||
|         this.#cleanUpMutables(); | ||||
|         const children = await this.#provider.load(this.domainObject); | ||||
|         const childObjects = await Promise.all(children.map((c) => this.#publicAPI.objects.get(c, abortSignal))); | ||||
|         childObjects.forEach(c => this.add(c, true)); | ||||
|         this.#emit('load'); | ||||
|  | ||||
|         return childObjects; | ||||
|     } | ||||
|     /** | ||||
|      * Remove a domain object from this composition. | ||||
|      * | ||||
|      * A call to [load]{@link module:openmct.CompositionCollection#load} | ||||
|      * must have resolved before using this method. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} child the domain object to remove | ||||
|      * @param {boolean} skipMutate true if the underlying provider should | ||||
|      *        not be updated | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * **TODO:** Remove `skipMutate` parameter. | ||||
|      * | ||||
|      * @param {DomainObject} child the domain object to remove | ||||
|      * @param {boolean} skipMutate | ||||
|      * **Intended for internal use ONLY.** | ||||
|      * true if the underlying provider should not be updated. | ||||
|      * @name remove | ||||
|      */ | ||||
|     CompositionCollection.prototype.remove = function (child, skipMutate) { | ||||
|     remove(child, skipMutate) { | ||||
|         if (!skipMutate) { | ||||
|             this.provider.remove(this.domainObject, child.identifier); | ||||
|             this.#provider.remove(this.domainObject, child.identifier); | ||||
|         } else { | ||||
|             if (this.returnMutables) { | ||||
|                 let keyString = this.publicAPI.objects.makeKeyString(child); | ||||
|                 if (this.mutables[keyString] !== undefined && this.mutables[keyString].isMutable) { | ||||
|                     this.publicAPI.objects.destroyMutable(this.mutables[keyString]); | ||||
|                     delete this.mutables[keyString]; | ||||
|                 let keyString = this.#publicAPI.objects.makeKeyString(child); | ||||
|                 if (this.#mutables[keyString] !== undefined && this.#mutables[keyString].isMutable) { | ||||
|                     this.#publicAPI.objects.destroyMutable(this.#mutables[keyString]); | ||||
|                     delete this.#mutables[keyString]; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             this.emit('remove', child); | ||||
|             this.#emit('remove', child); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Reorder the domain objects in this composition. | ||||
|      * | ||||
| @@ -270,67 +282,75 @@ define([ | ||||
|      * | ||||
|      * @param {number} oldIndex | ||||
|      * @param {number} newIndex | ||||
|      * @memberof module:openmct.CompositionCollection# | ||||
|      * @name remove | ||||
|      */ | ||||
|     CompositionCollection.prototype.reorder = function (oldIndex, newIndex, skipMutate) { | ||||
|         this.provider.reorder(this.domainObject, oldIndex, newIndex); | ||||
|     }; | ||||
|  | ||||
|     reorder(oldIndex, newIndex, _skipMutate) { | ||||
|         this.#provider.reorder(this.domainObject, oldIndex, newIndex); | ||||
|     } | ||||
|     /** | ||||
|      * Handle reorder from provider. | ||||
|      * @private | ||||
|      * Destroy mutationListener | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderReorder = function (reorderMap) { | ||||
|         this.emit('reorder', reorderMap); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handle adds from provider. | ||||
|      * @private | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderAdd = function (childId) { | ||||
|         return this.publicAPI.objects.get(childId).then(function (child) { | ||||
|             this.add(child, true); | ||||
|  | ||||
|             return child; | ||||
|         }.bind(this)); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handle removal from provider. | ||||
|      * @private | ||||
|      */ | ||||
|     CompositionCollection.prototype.onProviderRemove = function (child) { | ||||
|         this.remove(child, true); | ||||
|     }; | ||||
|  | ||||
|     CompositionCollection.prototype._destroy = function () { | ||||
|     _destroy() { | ||||
|         if (this.mutationListener) { | ||||
|             this.mutationListener(); | ||||
|             delete this.mutationListener; | ||||
|         } | ||||
|     }; | ||||
|     } | ||||
|     /** | ||||
|      * Handle reorder from provider. | ||||
|      * @private | ||||
|      * @param {object} reorderMap | ||||
|      */ | ||||
|     #onProviderReorder(reorderMap) { | ||||
|         this.#emit('reorder', reorderMap); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle adds from provider. | ||||
|      * @private | ||||
|      * @param {import('../objects/ObjectAPI').Identifier} childId | ||||
|      * @returns {DomainObject} | ||||
|      */ | ||||
|     #onProviderAdd(childId) { | ||||
|         return this.#publicAPI.objects.get(childId).then(function (child) { | ||||
|             this.add(child, true); | ||||
|  | ||||
|             return child; | ||||
|         }.bind(this)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle removal from provider. | ||||
|      * @param {DomainObject} child | ||||
|      */ | ||||
|     #onProviderRemove(child) { | ||||
|         this.remove(child, true); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Emit events. | ||||
|      * | ||||
|      * @private | ||||
|      * @param {string} event | ||||
|      * @param {...args.<any>} payload | ||||
|      */ | ||||
|     CompositionCollection.prototype.emit = function (event, ...payload) { | ||||
|         this.listeners[event].forEach(function (l) { | ||||
|     #emit(event, ...payload) { | ||||
|         this.#listeners[event].forEach(function (l) { | ||||
|             if (l.context) { | ||||
|                 l.callback.apply(l.context, payload); | ||||
|             } else { | ||||
|                 l.callback(...payload); | ||||
|             } | ||||
|         }); | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     CompositionCollection.prototype.cleanUpMutables = function () { | ||||
|         Object.values(this.mutables).forEach(mutable => { | ||||
|             this.publicAPI.objects.destroyMutable(mutable); | ||||
|     /** | ||||
|      * Destroy all mutables. | ||||
|      * @private | ||||
|      */ | ||||
|     #cleanUpMutables() { | ||||
|         Object.values(this.#mutables).forEach(mutable => { | ||||
|             this.#publicAPI.objects.destroyMutable(mutable); | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     return CompositionCollection; | ||||
| }); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										262
									
								
								src/api/composition/CompositionProvider.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,262 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| import _ from 'lodash'; | ||||
| import objectUtils from "../objects/object-utils"; | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../objects/ObjectAPI').Identifier} Identifier | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|   * A CompositionProvider provides the underlying implementation of | ||||
|   * composition-related behavior for certain types of domain object. | ||||
|   * | ||||
|   * By default, a composition provider will not support composition | ||||
|   * modification.  You can add support for mutation of composition by | ||||
|   * defining `add` and/or `remove` methods. | ||||
|   * | ||||
|   * If the composition of an object can change over time-- perhaps via | ||||
|   * server updates or mutation via the add/remove methods, then one must | ||||
|   * trigger events as necessary. | ||||
|   * | ||||
|   */ | ||||
| export default class CompositionProvider { | ||||
|     #publicAPI; | ||||
|     #listeningTo; | ||||
|  | ||||
|     /** | ||||
|       * @param {OpenMCT} publicAPI | ||||
|       * @param {CompositionAPI} compositionAPI | ||||
|       */ | ||||
|     constructor(publicAPI, compositionAPI) { | ||||
|         this.#publicAPI = publicAPI; | ||||
|         this.#listeningTo = {}; | ||||
|  | ||||
|         compositionAPI.addPolicy(this.#cannotContainItself.bind(this)); | ||||
|         compositionAPI.addPolicy(this.#supportsComposition.bind(this)); | ||||
|     } | ||||
|  | ||||
|     get listeningTo() { | ||||
|         return this.#listeningTo; | ||||
|     } | ||||
|  | ||||
|     get establishTopicListener() { | ||||
|         return this.#establishTopicListener.bind(this); | ||||
|     } | ||||
|  | ||||
|     get publicAPI() { | ||||
|         return this.#publicAPI; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Check if this provider should be used to load composition for a | ||||
|       * particular domain object. | ||||
|       * @method appliesTo | ||||
|       * @param {import('../objects/ObjectAPI').DomainObject} domainObject the domain object | ||||
|       *        to check | ||||
|       * @returns {boolean} true if this provider can provide composition for a given domain object | ||||
|       */ | ||||
|     appliesTo(domainObject) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Load any domain objects contained in the composition of this domain | ||||
|       * object. | ||||
|       * @param {DomainObject} domainObject the domain object | ||||
|       *        for which to load composition | ||||
|       * @returns {Promise<Identifier[]>} a promise for | ||||
|       *          the Identifiers in this composition | ||||
|       * @method load | ||||
|       */ | ||||
|     load(domainObject) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Attach listeners for changes to the composition of a given domain object. | ||||
|       * Supports `add` and `remove` events. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject to listen to | ||||
|       * @param {string} event the event to bind to, either `add` or `remove`. | ||||
|       * @param {Function} callback callback to invoke when event is triggered. | ||||
|       * @param {any} [context] to use when invoking callback. | ||||
|       */ | ||||
|     on(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Remove a listener that was previously added for a given domain object. | ||||
|       * event name, callback, and context must be the same as when the listener | ||||
|       * was originally attached. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject to remove listener for | ||||
|       * @param {string} event event to stop listening to: `add` or `remove`. | ||||
|       * @param {Function} callback callback to remove. | ||||
|       * @param {any} context of callback to remove. | ||||
|       */ | ||||
|     off(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Remove a domain object from another domain object's composition. | ||||
|       * | ||||
|       * This method is optional; if not present, adding to a domain object's | ||||
|       * composition using this provider will be disallowed. | ||||
|       * | ||||
|       * @param {DomainObject} domainObject the domain object | ||||
|       *        which should have its composition modified | ||||
|       * @param {Identifier} childId the domain object to remove | ||||
|       * @method remove | ||||
|       */ | ||||
|     remove(domainObject, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|     /** | ||||
|       * Add a domain object to another domain object's composition. | ||||
|       * | ||||
|       * This method is optional; if not present, adding to a domain object's | ||||
|       * composition using this provider will be disallowed. | ||||
|       * | ||||
|       * @param {DomainObject} parent the domain object | ||||
|       *        which should have its composition modified | ||||
|       * @param {Identifier} childId the domain object to add | ||||
|       * @method add | ||||
|       */ | ||||
|     add(parent, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @param {DomainObject} parent | ||||
|       * @param {Identifier} childId | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     includes(parent, childId) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @param {DomainObject} domainObject | ||||
|       * @param {number} oldIndex | ||||
|       * @param {number} newIndex | ||||
|       * @returns | ||||
|       */ | ||||
|     reorder(domainObject, oldIndex, newIndex) { | ||||
|         throw new Error("This method must be implemented by a subclass."); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Listens on general mutation topic, using injector to fetch to avoid | ||||
|       * circular dependencies. | ||||
|       * @private | ||||
|       */ | ||||
|     #establishTopicListener() { | ||||
|         if (this.topicListener) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.#publicAPI.objects.eventEmitter.on('mutation', this.#onMutation.bind(this)); | ||||
|         this.topicListener = () => { | ||||
|             this.#publicAPI.objects.eventEmitter.off('mutation', this.#onMutation.bind(this)); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @private | ||||
|       * @param {DomainObject} parent | ||||
|       * @param {DomainObject} child | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     #cannotContainItself(parent, child) { | ||||
|         return !(parent.identifier.namespace === child.identifier.namespace | ||||
|                && parent.identifier.key === child.identifier.key); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * @private | ||||
|       * @param {DomainObject} parent | ||||
|       * @returns {boolean} | ||||
|       */ | ||||
|     #supportsComposition(parent, _child) { | ||||
|         return this.#publicAPI.composition.supportsComposition(parent); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|       * Handles mutation events.  If there are active listeners for the mutated | ||||
|       * object, detects changes to composition and triggers necessary events. | ||||
|       * | ||||
|       * @private | ||||
|       * @param {DomainObject} oldDomainObject | ||||
|       */ | ||||
|     #onMutation(oldDomainObject) { | ||||
|         const id = objectUtils.makeKeyString(oldDomainObject.identifier); | ||||
|         const listeners = this.#listeningTo[id]; | ||||
|  | ||||
|         if (!listeners) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const oldComposition = listeners.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|  | ||||
|         const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); | ||||
|         const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); | ||||
|  | ||||
|         function notify(value) { | ||||
|             return function (listener) { | ||||
|                 if (listener.context) { | ||||
|                     listener.callback.call(listener.context, value); | ||||
|                 } else { | ||||
|                     listener.callback(value); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         listeners.composition = newComposition.map(objectUtils.parseKeyString); | ||||
|  | ||||
|         added.forEach(function (addedChild) { | ||||
|             listeners.add.forEach(notify(addedChild)); | ||||
|         }); | ||||
|  | ||||
|         removed.forEach(function (removedChild) { | ||||
|             listeners.remove.forEach(notify(removedChild)); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -19,102 +19,79 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| import objectUtils from "../objects/object-utils"; | ||||
| import CompositionProvider from './CompositionProvider'; | ||||
|  | ||||
| define([ | ||||
|     'lodash', | ||||
|     'objectUtils' | ||||
| ], function ( | ||||
|     _, | ||||
|     objectUtils | ||||
| ) { | ||||
|     /** | ||||
|      * A CompositionProvider provides the underlying implementation of | ||||
|      * composition-related behavior for certain types of domain object. | ||||
|      * | ||||
|      * By default, a composition provider will not support composition | ||||
|      * modification.  You can add support for mutation of composition by | ||||
|      * defining `add` and/or `remove` methods. | ||||
|      * | ||||
|      * If the composition of an object can change over time-- perhaps via | ||||
|      * server updates or mutation via the add/remove methods, then one must | ||||
|      * trigger events as necessary. | ||||
|      * | ||||
|      * @interface CompositionProvider | ||||
|      * @memberof module:openmct | ||||
|      */ | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject | ||||
|  */ | ||||
|  | ||||
|     function DefaultCompositionProvider(publicAPI, compositionAPI) { | ||||
|         this.publicAPI = publicAPI; | ||||
|         this.listeningTo = {}; | ||||
|         this.onMutation = this.onMutation.bind(this); | ||||
| /** | ||||
|  * @typedef {import('../objects/ObjectAPI').Identifier} Identifier | ||||
|  */ | ||||
|  | ||||
|         this.cannotContainItself = this.cannotContainItself.bind(this); | ||||
|         this.supportsComposition = this.supportsComposition.bind(this); | ||||
| /** | ||||
|  * @typedef {import('./CompositionAPI').default} CompositionAPI | ||||
|  */ | ||||
|  | ||||
|         compositionAPI.addPolicy(this.cannotContainItself); | ||||
|         compositionAPI.addPolicy(this.supportsComposition); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.cannotContainItself = function (parent, child) { | ||||
|         return !(parent.identifier.namespace === child.identifier.namespace | ||||
|             && parent.identifier.key === child.identifier.key); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.supportsComposition = function (parent, child) { | ||||
|         return this.publicAPI.composition.supportsComposition(parent); | ||||
|     }; | ||||
| /** | ||||
|  * @typedef {import('../../../openmct').OpenMCT} OpenMCT | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * A CompositionProvider provides the underlying implementation of | ||||
|  * composition-related behavior for certain types of domain object. | ||||
|  * | ||||
|  * By default, a composition provider will not support composition | ||||
|  * modification.  You can add support for mutation of composition by | ||||
|  * defining `add` and/or `remove` methods. | ||||
|  * | ||||
|  * If the composition of an object can change over time-- perhaps via | ||||
|  * server updates or mutation via the add/remove methods, then one must | ||||
|  * trigger events as necessary. | ||||
|  * @extends CompositionProvider | ||||
|  */ | ||||
| export default class DefaultCompositionProvider extends CompositionProvider { | ||||
|     /** | ||||
|      * Check if this provider should be used to load composition for a | ||||
|      * particular domain object. | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        to check | ||||
|      * @returns {boolean} true if this provider can provide | ||||
|      *          composition for a given domain object | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @method appliesTo | ||||
|      * @returns {boolean} true if this provider can provide composition for a given domain object | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.appliesTo = function (domainObject) { | ||||
|     appliesTo(domainObject) { | ||||
|         return Boolean(domainObject.composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Load any domain objects contained in the composition of this domain | ||||
|      * object. | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        for which to load composition | ||||
|      * @returns {Promise.<Array.<module:openmct.Identifier>>} a promise for | ||||
|      * @returns {Promise<Identifier[]>} a promise for | ||||
|      *          the Identifiers in this composition | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @method load | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.load = function (domainObject) { | ||||
|     load(domainObject) { | ||||
|         return Promise.all(domainObject.composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Attach listeners for changes to the composition of a given domain object. | ||||
|      * Supports `add` and `remove` events. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject to listen to | ||||
|      * @param String event the event to bind to, either `add` or `remove`. | ||||
|      * @param Function callback callback to invoke when event is triggered. | ||||
|      * @param [context] context to use when invoking callback. | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject to listen to | ||||
|      * @param {string} event the event to bind to, either `add` or `remove`. | ||||
|      * @param {Function} callback callback to invoke when event is triggered. | ||||
|      * @param {any} [context] to use when invoking callback. | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.on = function ( | ||||
|         domainObject, | ||||
|     on(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context | ||||
|     ) { | ||||
|         context) { | ||||
|         this.establishTopicListener(); | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         const keyString = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         let objectListeners = this.listeningTo[keyString]; | ||||
|  | ||||
| @@ -131,24 +108,24 @@ define([ | ||||
|             callback: callback, | ||||
|             context: context | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a listener that was previously added for a given domain object. | ||||
|      * event name, callback, and context must be the same as when the listener | ||||
|      * was originally attached. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject to remove listener for | ||||
|      * @param String event event to stop listening to: `add` or `remove`. | ||||
|      * @param Function callback callback to remove. | ||||
|      * @param [context] context of callback to remove. | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject to remove listener for | ||||
|      * @param {string} event event to stop listening to: `add` or `remove`. | ||||
|      * @param {Function} callback callback to remove. | ||||
|      * @param {any} context of callback to remove. | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.off = function ( | ||||
|         domainObject, | ||||
|     off(domainObject, | ||||
|         event, | ||||
|         callback, | ||||
|         context | ||||
|     ) { | ||||
|         context) { | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         const keyString = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         const objectListeners = this.listeningTo[keyString]; | ||||
|  | ||||
| @@ -160,57 +137,64 @@ define([ | ||||
|         if (!objectListeners.add.length && !objectListeners.remove.length && !objectListeners.reorder.length) { | ||||
|             delete this.listeningTo[keyString]; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Remove a domain object from another domain object's composition. | ||||
|      * | ||||
|      * This method is optional; if not present, adding to a domain object's | ||||
|      * composition using this provider will be disallowed. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject the domain object | ||||
|      *        which should have its composition modified | ||||
|      * @param {module:openmct.DomainObject} child the domain object to remove | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @param {Identifier} childId the domain object to remove | ||||
|      * @method remove | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.remove = function (domainObject, childId) { | ||||
|     remove(domainObject, childId) { | ||||
|         let composition = domainObject.composition.filter(function (child) { | ||||
|             return !(childId.namespace === child.namespace | ||||
|                 && childId.key === child.key); | ||||
|                       && childId.key === child.key); | ||||
|         }); | ||||
|  | ||||
|         this.publicAPI.objects.mutate(domainObject, 'composition', composition); | ||||
|     }; | ||||
|  | ||||
|     } | ||||
|     /** | ||||
|      * Add a domain object to another domain object's composition. | ||||
|      * | ||||
|      * This method is optional; if not present, adding to a domain object's | ||||
|      * composition using this provider will be disallowed. | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} domainObject the domain object | ||||
|      * @override | ||||
|      * @param {DomainObject} parent the domain object | ||||
|      *        which should have its composition modified | ||||
|      * @param {module:openmct.DomainObject} child the domain object to add | ||||
|      * @memberof module:openmct.CompositionProvider# | ||||
|      * @param {Identifier} childId the domain object to add | ||||
|      * @method add | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.add = function (parent, childId) { | ||||
|     add(parent, childId) { | ||||
|         if (!this.includes(parent, childId)) { | ||||
|             parent.composition.push(childId); | ||||
|             this.publicAPI.objects.mutate(parent, 'composition', parent.composition); | ||||
|         } | ||||
|     }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * @override | ||||
|      * @param {DomainObject} parent | ||||
|      * @param {Identifier} childId | ||||
|      * @returns {boolean} | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.includes = function (parent, childId) { | ||||
|         return parent.composition.some(composee => | ||||
|             this.publicAPI.objects.areIdsEqual(composee, childId)); | ||||
|     }; | ||||
|     includes(parent, childId) { | ||||
|         return parent.composition.some(composee => this.publicAPI.objects.areIdsEqual(composee, childId)); | ||||
|     } | ||||
|  | ||||
|     DefaultCompositionProvider.prototype.reorder = function (domainObject, oldIndex, newIndex) { | ||||
|     /** | ||||
|      * @override | ||||
|      * @param {DomainObject} domainObject | ||||
|      * @param {number} oldIndex | ||||
|      * @param {number} newIndex | ||||
|      * @returns | ||||
|      */ | ||||
|     reorder(domainObject, oldIndex, newIndex) { | ||||
|         let newComposition = domainObject.composition.slice(); | ||||
|         let removeId = oldIndex > newIndex ? oldIndex + 1 : oldIndex; | ||||
|         let insertPosition = oldIndex < newIndex ? newIndex + 1 : newIndex; | ||||
| @@ -241,6 +225,7 @@ define([ | ||||
|  | ||||
|         this.publicAPI.objects.mutate(domainObject, 'composition', newComposition); | ||||
|  | ||||
|         /** @type {string} */ | ||||
|         let id = objectUtils.makeKeyString(domainObject.identifier); | ||||
|         const listeners = this.listeningTo[id]; | ||||
|  | ||||
| @@ -257,66 +242,5 @@ define([ | ||||
|                 listener.callback(reorderPlan); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Listens on general mutation topic, using injector to fetch to avoid | ||||
|      * circular dependencies. | ||||
|      * | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.establishTopicListener = function () { | ||||
|         if (this.topicListener) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.publicAPI.objects.eventEmitter.on('mutation', this.onMutation); | ||||
|         this.topicListener = () => { | ||||
|             this.publicAPI.objects.eventEmitter.off('mutation', this.onMutation); | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Handles mutation events.  If there are active listeners for the mutated | ||||
|      * object, detects changes to composition and triggers necessary events. | ||||
|      * | ||||
|      * @private | ||||
|      */ | ||||
|     DefaultCompositionProvider.prototype.onMutation = function (oldDomainObject) { | ||||
|         const id = objectUtils.makeKeyString(oldDomainObject.identifier); | ||||
|         const listeners = this.listeningTo[id]; | ||||
|  | ||||
|         if (!listeners) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const oldComposition = listeners.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|  | ||||
|         const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); | ||||
|         const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); | ||||
|  | ||||
|         function notify(value) { | ||||
|             return function (listener) { | ||||
|                 if (listener.context) { | ||||
|                     listener.callback.call(listener.context, value); | ||||
|                 } else { | ||||
|                     listener.callback(value); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         listeners.composition = newComposition.map(objectUtils.parseKeyString); | ||||
|  | ||||
|         added.forEach(function (addedChild) { | ||||
|             listeners.add.forEach(notify(addedChild)); | ||||
|         }); | ||||
|  | ||||
|         removed.forEach(function (removedChild) { | ||||
|             listeners.remove.forEach(notify(removedChild)); | ||||
|         }); | ||||
|  | ||||
|     }; | ||||
|  | ||||
|     return DefaultCompositionProvider; | ||||
| }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -23,13 +23,11 @@ | ||||
| import FormController from './FormController'; | ||||
| import FormProperties from './components/FormProperties.vue'; | ||||
|  | ||||
| import EventEmitter from 'EventEmitter'; | ||||
| import Vue from 'vue'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class FormsAPI extends EventEmitter { | ||||
| export default class FormsAPI { | ||||
|     constructor(openmct) { | ||||
|         super(); | ||||
|  | ||||
|         this.openmct = openmct; | ||||
|         this.formController = new FormController(openmct); | ||||
|     } | ||||
| @@ -92,29 +90,75 @@ export default class FormsAPI extends EventEmitter { | ||||
|  | ||||
|     /** | ||||
|      * Show form inside an Overlay dialog with given form structure | ||||
|      * @public | ||||
|      * @param {Array<Section>} formStructure a form structure, array of section | ||||
|      * @param {Object} options | ||||
|      *      @property {function} onChange a callback function when any changes detected | ||||
|      */ | ||||
|     showForm(formStructure, { | ||||
|         onChange | ||||
|     } = {}) { | ||||
|         let overlay; | ||||
|  | ||||
|         const self = this; | ||||
|  | ||||
|         const overlayEl = document.createElement('div'); | ||||
|         overlayEl.classList.add('u-contents'); | ||||
|  | ||||
|         overlay = self.openmct.overlays.overlay({ | ||||
|             element: overlayEl, | ||||
|             size: 'dialog' | ||||
|         }); | ||||
|  | ||||
|         let formSave; | ||||
|         let formCancel; | ||||
|         const promise = new Promise((resolve, reject) => { | ||||
|             formSave = resolve; | ||||
|             formCancel = reject; | ||||
|         }); | ||||
|  | ||||
|         this.showCustomForm(formStructure, { | ||||
|             element: overlayEl, | ||||
|             onChange | ||||
|         }) | ||||
|             .then((response) => { | ||||
|                 overlay.dismiss(); | ||||
|                 formSave(response); | ||||
|             }) | ||||
|             .catch((response) => { | ||||
|                 overlay.dismiss(); | ||||
|                 formCancel(response); | ||||
|             }); | ||||
|  | ||||
|         return promise; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show form as a child of the element provided with given form structure | ||||
|      * | ||||
|      * @public | ||||
|      * @param {Array<Section>} formStructure a form structure, array of section | ||||
|      * @param {Object} options | ||||
|      *      @property {HTMLElement} element Parent Element to render a Form | ||||
|      *      @property {function} onChange a callback function when any changes detected | ||||
|      *      @property {function} onSave a callback function when form is submitted | ||||
|      *      @property {function} onDismiss a callback function when form is dismissed | ||||
|      */ | ||||
|     showForm(formStructure, { | ||||
|     showCustomForm(formStructure, { | ||||
|         element, | ||||
|         onChange | ||||
|     } = {}) { | ||||
|         const changes = {}; | ||||
|         let overlay; | ||||
|         let onDismiss; | ||||
|         let onSave; | ||||
|         if (element === undefined) { | ||||
|             throw Error('Required element parameter not provided'); | ||||
|         } | ||||
|  | ||||
|         const self = this; | ||||
|  | ||||
|         const changes = {}; | ||||
|         let formSave; | ||||
|         let formCancel; | ||||
|  | ||||
|         const promise = new Promise((resolve, reject) => { | ||||
|             onSave = onFormAction(resolve); | ||||
|             onDismiss = onFormAction(reject); | ||||
|             formSave = onFormAction(resolve); | ||||
|             formCancel = onFormAction(reject); | ||||
|         }); | ||||
|  | ||||
|         const vm = new Vue({ | ||||
| @@ -126,26 +170,17 @@ export default class FormsAPI extends EventEmitter { | ||||
|                 return { | ||||
|                     formStructure, | ||||
|                     onChange: onFormPropertyChange, | ||||
|                     onDismiss, | ||||
|                     onSave | ||||
|                     onCancel: formCancel, | ||||
|                     onSave: formSave | ||||
|                 }; | ||||
|             }, | ||||
|             template: '<FormProperties :model="formStructure" @onChange="onChange" @onDismiss="onDismiss" @onSave="onSave"></FormProperties>' | ||||
|             template: '<FormProperties :model="formStructure" @onChange="onChange" @onCancel="onCancel" @onSave="onSave"></FormProperties>' | ||||
|         }).$mount(); | ||||
|  | ||||
|         const formElement = vm.$el; | ||||
|         if (element) { | ||||
|             element.append(formElement); | ||||
|         } else { | ||||
|             overlay = self.openmct.overlays.overlay({ | ||||
|                 element: vm.$el, | ||||
|                 size: 'dialog', | ||||
|                 onDestroy: () => vm.$destroy() | ||||
|             }); | ||||
|         } | ||||
|         element.append(formElement); | ||||
|  | ||||
|         function onFormPropertyChange(data) { | ||||
|             self.emit('onFormPropertyChange', data); | ||||
|             if (onChange) { | ||||
|                 onChange(data); | ||||
|             } | ||||
| @@ -158,17 +193,14 @@ export default class FormsAPI extends EventEmitter { | ||||
|                     key = property.join('.'); | ||||
|                 } | ||||
|  | ||||
|                 changes[key] = data.value; | ||||
|                 _.set(changes, key, data.value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         function onFormAction(callback) { | ||||
|             return () => { | ||||
|                 if (element) { | ||||
|                     formElement.remove(); | ||||
|                 } else { | ||||
|                     overlay.dismiss(); | ||||
|                 } | ||||
|                 formElement.remove(); | ||||
|                 vm.$destroy(); | ||||
|  | ||||
|                 if (callback) { | ||||
|                     callback(changes); | ||||
|   | ||||
| @@ -133,7 +133,7 @@ describe('The Forms API', () => { | ||||
|         }); | ||||
|  | ||||
|         it('when container element is provided', (done) => { | ||||
|             openmct.forms.showForm(formStructure, { element }).catch(() => { | ||||
|             openmct.forms.showCustomForm(formStructure, { element }).catch(() => { | ||||
|                 done(); | ||||
|             }); | ||||
|             const titleElement = element.querySelector('.c-overlay__dialog-title'); | ||||
|   | ||||
| @@ -73,7 +73,7 @@ | ||||
|             tabindex="0" | ||||
|             class="c-button js-cancel-button" | ||||
|             aria-label="Cancel" | ||||
|             @click="onDismiss" | ||||
|             @click="onCancel" | ||||
|         > | ||||
|             {{ cancelLabel }} | ||||
|         </button> | ||||
| @@ -164,8 +164,8 @@ export default { | ||||
|  | ||||
|             this.$emit('onChange', data); | ||||
|         }, | ||||
|         onDismiss() { | ||||
|             this.$emit('onDismiss'); | ||||
|         onCancel() { | ||||
|             this.$emit('onCancel'); | ||||
|         }, | ||||
|         onSave() { | ||||
|             this.$emit('onSave'); | ||||
|   | ||||
| @@ -26,6 +26,7 @@ | ||||
|         v-model="selected" | ||||
|         required="model.required" | ||||
|         name="mctControl" | ||||
|         :aria-label="model.ariaLabel || model.name" | ||||
|         @change="onChange($event)" | ||||
|     > | ||||
|         <option | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|         :class="model.cssClass" | ||||
|     > | ||||
|         <textarea | ||||
|             :id="`${model.key}-textarea`" | ||||
|             v-model="field" | ||||
|             type="text" | ||||
|             :size="model.size" | ||||
|   | ||||
| @@ -29,6 +29,7 @@ | ||||
|         <ToggleSwitch | ||||
|             id="switchId" | ||||
|             :checked="isChecked" | ||||
|             :name="model.name" | ||||
|             @change="toggleCheckBox" | ||||
|         /> | ||||
|     </span> | ||||
|   | ||||
| @@ -3,39 +3,52 @@ | ||||
|     class="c-menu" | ||||
|     :class="options.menuClass" | ||||
| > | ||||
|     <ul v-if="options.actions.length && options.actions[0].length"> | ||||
|     <ul | ||||
|         v-if="options.actions.length && options.actions[0].length" | ||||
|         role="menu" | ||||
|     > | ||||
|         <template | ||||
|             v-for="(actionGroups, index) in options.actions" | ||||
|         > | ||||
|             <li | ||||
|                 v-for="action in actionGroups" | ||||
|                 :key="action.name" | ||||
|                 :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                 :title="action.description" | ||||
|                 :data-testid="action.testId || false" | ||||
|                 @click="action.onItemClicked" | ||||
|             > | ||||
|                 {{ action.name }} | ||||
|             </li> | ||||
|             <div | ||||
|                 v-if="index !== options.actions.length - 1" | ||||
|                 :key="index" | ||||
|                 class="c-menu__section-separator" | ||||
|                 role="group" | ||||
|             > | ||||
|             </div> | ||||
|             <li | ||||
|                 v-if="actionGroups.length === 0" | ||||
|                 :key="index" | ||||
|             > | ||||
|                 No actions defined. | ||||
|             </li> | ||||
|         </template> | ||||
|                 <li | ||||
|                     v-for="action in actionGroups" | ||||
|                     :key="action.name" | ||||
|                     role="menuitem" | ||||
|                     :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                     :title="action.description" | ||||
|                     :data-testid="action.testId || false" | ||||
|                     @click="action.onItemClicked" | ||||
|                 > | ||||
|                     {{ action.name }} | ||||
|                 </li> | ||||
|                 <div | ||||
|                     v-if="index !== options.actions.length - 1" | ||||
|                     :key="index" | ||||
|                     role="separator" | ||||
|                     class="c-menu__section-separator" | ||||
|                 > | ||||
|                 </div> | ||||
|                 <li | ||||
|                     v-if="actionGroups.length === 0" | ||||
|                     :key="index" | ||||
|                 > | ||||
|                     No actions defined. | ||||
|                 </li> | ||||
|             </div></template> | ||||
|     </ul> | ||||
|  | ||||
|     <ul v-else> | ||||
|     <ul | ||||
|         v-else | ||||
|         role="menu" | ||||
|     > | ||||
|         <li | ||||
|             v-for="action in options.actions" | ||||
|             :key="action.name" | ||||
|             role="menuitem" | ||||
|             :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|             :title="action.description" | ||||
|             :data-testid="action.testId || false" | ||||
|   | ||||
| @@ -5,45 +5,54 @@ | ||||
| > | ||||
|     <ul | ||||
|         v-if="options.actions.length && options.actions[0].length" | ||||
|         role="menu" | ||||
|         class="c-super-menu__menu" | ||||
|     > | ||||
|         <template | ||||
|             v-for="(actionGroups, index) in options.actions" | ||||
|         > | ||||
|             <li | ||||
|                 v-for="action in actionGroups" | ||||
|                 :key="action.name" | ||||
|                 :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                 :title="action.description" | ||||
|                 :data-testid="action.testId || false" | ||||
|                 @click="action.onItemClicked" | ||||
|                 @mouseover="toggleItemDescription(action)" | ||||
|                 @mouseleave="toggleItemDescription()" | ||||
|             > | ||||
|                 {{ action.name }} | ||||
|             </li> | ||||
|             <div | ||||
|                 v-if="index !== options.actions.length - 1" | ||||
|                 :key="index" | ||||
|                 class="c-menu__section-separator" | ||||
|                 role="group" | ||||
|             > | ||||
|             </div> | ||||
|             <li | ||||
|                 v-if="actionGroups.length === 0" | ||||
|                 :key="index" | ||||
|             > | ||||
|                 No actions defined. | ||||
|             </li> | ||||
|         </template> | ||||
|                 <li | ||||
|                     v-for="action in actionGroups" | ||||
|                     :key="action.name" | ||||
|                     role="menuitem" | ||||
|                     :class="[action.cssClass, action.isDisabled ? 'disabled' : '']" | ||||
|                     :title="action.description" | ||||
|                     :data-testid="action.testId || false" | ||||
|                     @click="action.onItemClicked" | ||||
|                     @mouseover="toggleItemDescription(action)" | ||||
|                     @mouseleave="toggleItemDescription()" | ||||
|                 > | ||||
|                     {{ action.name }} | ||||
|                 </li> | ||||
|                 <div | ||||
|                     v-if="index !== options.actions.length - 1" | ||||
|                     :key="index" | ||||
|                     role="separator" | ||||
|                     class="c-menu__section-separator" | ||||
|                 > | ||||
|                 </div> | ||||
|                 <li | ||||
|                     v-if="actionGroups.length === 0" | ||||
|                     :key="index" | ||||
|                 > | ||||
|                     No actions defined. | ||||
|                 </li> | ||||
|             </div></template> | ||||
|     </ul> | ||||
|  | ||||
|     <ul | ||||
|         v-else | ||||
|         class="c-super-menu__menu" | ||||
|         role="menu" | ||||
|     > | ||||
|         <li | ||||
|             v-for="action in options.actions" | ||||
|             :key="action.name" | ||||
|             role="menuitem" | ||||
|             :class="action.cssClass" | ||||
|             :title="action.description" | ||||
|             :data-testid="action.testId || false" | ||||
|   | ||||
| @@ -19,6 +19,7 @@ | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| const DEFAULT_INTERCEPTOR_PRIORITY = 0; | ||||
| export default class InterceptorRegistry { | ||||
|     /** | ||||
|      * A InterceptorRegistry maintains the definitions for different interceptors that may be invoked on domain objects. | ||||
| @@ -45,7 +46,6 @@ export default class InterceptorRegistry { | ||||
|      * @memberof module:openmct.InterceptorRegistry# | ||||
|      */ | ||||
|     addInterceptor(interceptorDef) { | ||||
|         //TODO: sort by priority | ||||
|         this.interceptors.push(interceptorDef); | ||||
|     } | ||||
|  | ||||
| @@ -56,10 +56,18 @@ export default class InterceptorRegistry { | ||||
|      * @memberof module:openmct.InterceptorRegistry# | ||||
|      */ | ||||
|     getInterceptors(identifier, object) { | ||||
|  | ||||
|         function byPriority(interceptorA, interceptorB) { | ||||
|             let priorityA = interceptorA.priority ?? DEFAULT_INTERCEPTOR_PRIORITY; | ||||
|             let priorityB = interceptorB.priority ?? DEFAULT_INTERCEPTOR_PRIORITY; | ||||
|  | ||||
|             return priorityB - priorityA; | ||||
|         } | ||||
|  | ||||
|         return this.interceptors.filter(interceptor => { | ||||
|             return typeof interceptor.appliesTo === 'function' | ||||
|                 && interceptor.appliesTo(identifier, object); | ||||
|         }); | ||||
|         }).sort(byPriority); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -75,11 +75,7 @@ class MutableDomainObject { | ||||
|         return eventOff; | ||||
|     } | ||||
|     $set(path, value) { | ||||
|         _.set(this, path, value); | ||||
|  | ||||
|         if (path !== 'persisted' && path !== 'modified') { | ||||
|             _.set(this, 'modified', Date.now()); | ||||
|         } | ||||
|         MutableDomainObject.mutateObject(this, path, value); | ||||
|  | ||||
|         //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired. | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this); | ||||
| @@ -136,8 +132,11 @@ class MutableDomainObject { | ||||
|     } | ||||
|  | ||||
|     static mutateObject(object, path, value) { | ||||
|         if (path !== 'persisted') { | ||||
|             _.set(object, 'modified', Date.now()); | ||||
|         } | ||||
|  | ||||
|         _.set(object, path, value); | ||||
|         _.set(object, 'modified', Date.now()); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,7 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
| /** | ||||
|  * Uniquely identifies a domain object. | ||||
|  * | ||||
|  * @typedef Identifier | ||||
|  * @typedef {object} Identifier | ||||
|  * @property {string} namespace the namespace to/from which this domain | ||||
|  *           object should be loaded/stored. | ||||
|  * @property {string} key a unique identifier for the domain object | ||||
| @@ -50,8 +50,8 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
|  * A few common properties are defined for domain objects. Beyond these, | ||||
|  * individual types of domain objects may add more as they see fit. | ||||
|  * | ||||
|  * @typedef DomainObject | ||||
|  * @property {module:openmct.ObjectAPI~Identifier} identifier a key/namespace pair which | ||||
|  * @typedef {object} DomainObject | ||||
|  * @property {Identifier} identifier a key/namespace pair which | ||||
|  *           uniquely identifies this domain object | ||||
|  * @property {string} type the type of domain object | ||||
|  * @property {string} name the human-readable name for this domain object | ||||
| @@ -59,19 +59,19 @@ import InMemorySearchProvider from './InMemorySearchProvider'; | ||||
|  *           object | ||||
|  * @property {number} [modified] the time, in milliseconds since the UNIX | ||||
|  *           epoch, at which this domain object was last modified | ||||
|  * @property {module:openmct.ObjectAPI~Identifier[]} [composition] if | ||||
|  * @property {Identifier[]} [composition] if | ||||
|  *           present, this will be used by the default composition provider | ||||
|  *           to load domain objects | ||||
|  * @memberof module:openmct | ||||
|  * @memberof module:openmct.ObjectAPI~ | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|     * @readonly | ||||
|     * @enum {String} SEARCH_TYPES | ||||
|     * @property {String} OBJECTS Search for objects | ||||
|     * @property {String} ANNOTATIONS Search for annotations | ||||
|     * @property {String} TAGS Search for tags | ||||
| */ | ||||
|   * @readonly | ||||
|   * @enum {string} SEARCH_TYPES | ||||
|   * @property {string} OBJECTS Search for objects | ||||
|   * @property {string} ANNOTATIONS Search for annotations | ||||
|   * @property {string} TAGS Search for tags | ||||
|   */ | ||||
|  | ||||
| /** | ||||
|  * Utilities for loading, saving, and manipulating domain objects. | ||||
| @@ -96,7 +96,7 @@ export default class ObjectAPI { | ||||
|         this.cache = {}; | ||||
|         this.interceptorRegistry = new InterceptorRegistry(); | ||||
|  | ||||
|         this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'plan', 'annotation']; | ||||
|         this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'restricted-notebook', 'plan', 'annotation']; | ||||
|  | ||||
|         this.errors = { | ||||
|             Conflict: ConflictError | ||||
| @@ -204,13 +204,13 @@ export default class ObjectAPI { | ||||
|         } | ||||
|  | ||||
|         identifier = utils.parseKeyString(identifier); | ||||
|         let dirtyObject; | ||||
|         if (this.isTransactionActive()) { | ||||
|             dirtyObject = this.transaction.getDirtyObject(identifier); | ||||
|         } | ||||
|  | ||||
|         if (dirtyObject) { | ||||
|             return Promise.resolve(dirtyObject); | ||||
|         if (this.isTransactionActive()) { | ||||
|             let dirtyObject = this.transaction.getDirtyObject(identifier); | ||||
|  | ||||
|             if (dirtyObject) { | ||||
|                 return Promise.resolve(dirtyObject); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         const provider = this.getProvider(identifier); | ||||
| @@ -354,39 +354,59 @@ export default class ObjectAPI { | ||||
|      * @returns {Promise} a promise which will resolve when the domain object | ||||
|      *          has been saved, or be rejected if it cannot be saved | ||||
|      */ | ||||
|     save(domainObject) { | ||||
|         let provider = this.getProvider(domainObject.identifier); | ||||
|         let savedResolve; | ||||
|         let savedReject; | ||||
|     async save(domainObject) { | ||||
|         const provider = this.getProvider(domainObject.identifier); | ||||
|         let result; | ||||
|         let lastPersistedTime; | ||||
|  | ||||
|         if (!this.isPersistable(domainObject.identifier)) { | ||||
|             result = Promise.reject('Object provider does not support saving'); | ||||
|         } else if (this.#hasAlreadyBeenPersisted(domainObject)) { | ||||
|             result = Promise.resolve(true); | ||||
|         } else { | ||||
|             const persistedTime = Date.now(); | ||||
|             if (domainObject.persisted === undefined) { | ||||
|                 result = new Promise((resolve, reject) => { | ||||
|                     savedResolve = resolve; | ||||
|                     savedReject = reject; | ||||
|                 }); | ||||
|                 domainObject.persisted = persistedTime; | ||||
|                 const newObjectPromise = provider.create(domainObject); | ||||
|                 if (newObjectPromise) { | ||||
|                     newObjectPromise.then(response => { | ||||
|                         this.mutate(domainObject, 'persisted', persistedTime); | ||||
|                         savedResolve(response); | ||||
|                     }).catch((error) => { | ||||
|                         savedReject(error); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     result = Promise.reject(`[ObjectAPI][save] Object provider returned ${newObjectPromise} when creating new object.`); | ||||
|                 } | ||||
|             const username = await this.#getCurrentUsername(); | ||||
|             const isNewObject = domainObject.persisted === undefined; | ||||
|             let savedResolve; | ||||
|             let savedReject; | ||||
|             let savedObjectPromise; | ||||
|  | ||||
|             result = new Promise((resolve, reject) => { | ||||
|                 savedResolve = resolve; | ||||
|                 savedReject = reject; | ||||
|             }); | ||||
|  | ||||
|             this.#mutate(domainObject, 'modifiedBy', username); | ||||
|  | ||||
|             if (isNewObject) { | ||||
|                 this.#mutate(domainObject, 'createdBy', username); | ||||
|  | ||||
|                 const createdTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'created', createdTime); | ||||
|  | ||||
|                 const persistedTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'persisted', persistedTime); | ||||
|  | ||||
|                 savedObjectPromise = provider.create(domainObject); | ||||
|             } else { | ||||
|                 domainObject.persisted = persistedTime; | ||||
|                 this.mutate(domainObject, 'persisted', persistedTime); | ||||
|                 result = provider.update(domainObject); | ||||
|                 lastPersistedTime = domainObject.persisted; | ||||
|                 const persistedTime = Date.now(); | ||||
|                 this.#mutate(domainObject, 'persisted', persistedTime); | ||||
|  | ||||
|                 savedObjectPromise = provider.update(domainObject); | ||||
|             } | ||||
|  | ||||
|             if (savedObjectPromise) { | ||||
|                 savedObjectPromise.then(response => { | ||||
|                     savedResolve(response); | ||||
|                 }).catch((error) => { | ||||
|                     if (lastPersistedTime !== undefined) { | ||||
|                         this.#mutate(domainObject, 'persisted', lastPersistedTime); | ||||
|                     } | ||||
|  | ||||
|                     savedReject(error); | ||||
|                 }); | ||||
|             } else { | ||||
|                 result = Promise.reject(`[ObjectAPI][save] Object provider returned ${savedObjectPromise} when ${isNewObject ? 'creating new' : 'updating'} object.`); | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -399,8 +419,21 @@ export default class ObjectAPI { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async #getCurrentUsername() { | ||||
|         const user = await this.openmct.user.getCurrentUser(); | ||||
|         let username; | ||||
|  | ||||
|         if (user !== undefined) { | ||||
|             username = user.getName(); | ||||
|         } | ||||
|  | ||||
|         return username; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * After entering into edit mode, creates a new instance of Transaction to keep track of changes in Objects | ||||
|      * | ||||
|      * @returns {Transaction} a new Transaction that was just created | ||||
|      */ | ||||
|     startTransaction() { | ||||
|         if (this.isTransactionActive()) { | ||||
| @@ -408,6 +441,8 @@ export default class ObjectAPI { | ||||
|         } | ||||
|  | ||||
|         this.transaction = new Transaction(this); | ||||
|  | ||||
|         return this.transaction; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -480,14 +515,16 @@ export default class ObjectAPI { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Modify a domain object. | ||||
|      * Modify a domain object. Internal to ObjectAPI, won't call save after. | ||||
|      * @private | ||||
|      * | ||||
|      * @param {module:openmct.DomainObject} object the object to mutate | ||||
|      * @param {string} path the property to modify | ||||
|      * @param {*} value the new value for this property | ||||
|      * @method mutate | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|     mutate(domainObject, path, value) { | ||||
|     #mutate(domainObject, path, value) { | ||||
|         if (!this.supportsMutation(domainObject.identifier)) { | ||||
|             throw `Error: Attempted to mutate immutable object ${domainObject.name}`; | ||||
|         } | ||||
| @@ -508,6 +545,18 @@ export default class ObjectAPI { | ||||
|             //Destroy temporary mutable object | ||||
|             this.destroyMutable(mutableDomainObject); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Modify a domain object and save. | ||||
|      * @param {module:openmct.DomainObject} object the object to mutate | ||||
|      * @param {string} path the property to modify | ||||
|      * @param {*} value the new value for this property | ||||
|      * @method mutate | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|     mutate(domainObject, path, value) { | ||||
|         this.#mutate(domainObject, path, value); | ||||
|  | ||||
|         if (this.isTransactionActive()) { | ||||
|             this.transaction.add(domainObject); | ||||
| @@ -684,7 +733,7 @@ export default class ObjectAPI { | ||||
|     } | ||||
|  | ||||
|     isTransactionActive() { | ||||
|         return Boolean(this.transaction && this.openmct.editor.isEditing()); | ||||
|         return this.transaction !== undefined && this.transaction !== null; | ||||
|     } | ||||
|  | ||||
|     #hasAlreadyBeenPersisted(domainObject) { | ||||
|   | ||||
| @@ -8,13 +8,27 @@ describe("The Object API", () => { | ||||
|     let mockDomainObject; | ||||
|     const TEST_NAMESPACE = "test-namespace"; | ||||
|     const TEST_KEY = "test-key"; | ||||
|     const USERNAME = 'Joan Q Public'; | ||||
|     const FIFTEEN_MINUTES = 15 * 60 * 1000; | ||||
|  | ||||
|     beforeEach((done) => { | ||||
|         typeRegistry = jasmine.createSpyObj('typeRegistry', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         const userProvider = { | ||||
|             isLoggedIn() { | ||||
|                 return true; | ||||
|             }, | ||||
|             getCurrentUser() { | ||||
|                 return Promise.resolve({ | ||||
|                     getName() { | ||||
|                         return USERNAME; | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         }; | ||||
|         openmct = createOpenMct(); | ||||
|         openmct.user.setProvider(userProvider); | ||||
|         objectAPI = openmct.objects; | ||||
|  | ||||
|         openmct.editor = {}; | ||||
| @@ -63,19 +77,63 @@ describe("The Object API", () => { | ||||
|                 mockProvider.update.and.returnValue(Promise.resolve(true)); | ||||
|                 objectAPI.addProvider(TEST_NAMESPACE, mockProvider); | ||||
|             }); | ||||
|             it("Calls 'create' on provider if object is new", () => { | ||||
|                 objectAPI.save(mockDomainObject); | ||||
|             it("Adds a 'created' timestamp to new objects", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.created).not.toBeUndefined(); | ||||
|             }); | ||||
|             it("Calls 'create' on provider if object is new", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockProvider.create).toHaveBeenCalled(); | ||||
|                 expect(mockProvider.update).not.toHaveBeenCalled(); | ||||
|             }); | ||||
|             it("Calls 'update' on provider if object is not new", () => { | ||||
|             it("Calls 'update' on provider if object is not new", async () => { | ||||
|                 mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES; | ||||
|                 mockDomainObject.modified = Date.now(); | ||||
|  | ||||
|                 objectAPI.save(mockDomainObject); | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockProvider.create).not.toHaveBeenCalled(); | ||||
|                 expect(mockProvider.update).toHaveBeenCalled(); | ||||
|             }); | ||||
|             describe("the persisted timestamp for existing objects", () => { | ||||
|                 let persistedTimestamp; | ||||
|                 beforeEach(() => { | ||||
|                     persistedTimestamp = Date.now() - FIFTEEN_MINUTES; | ||||
|                     mockDomainObject.persisted = persistedTimestamp; | ||||
|                     mockDomainObject.modified = Date.now(); | ||||
|                 }); | ||||
|  | ||||
|                 it("is updated", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted).toBeDefined(); | ||||
|                     expect(mockDomainObject.persisted > persistedTimestamp).toBe(true); | ||||
|                 }); | ||||
|                 it("is >= modified timestamp", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true); | ||||
|                 }); | ||||
|             }); | ||||
|             describe("the persisted timestamp for new objects", () => { | ||||
|                 it("is updated", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted).toBeDefined(); | ||||
|                 }); | ||||
|                 it("is >= modified timestamp", async () => { | ||||
|                     await objectAPI.save(mockDomainObject); | ||||
|                     expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             it("Sets the current user for 'createdBy' on new objects", async () => { | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.createdBy).toBe(USERNAME); | ||||
|             }); | ||||
|             it("Sets the current user for 'modifedBy' on existing objects", async () => { | ||||
|                 mockDomainObject.persisted = Date.now() - FIFTEEN_MINUTES; | ||||
|                 mockDomainObject.modified = Date.now(); | ||||
|  | ||||
|                 await objectAPI.save(mockDomainObject); | ||||
|                 expect(mockDomainObject.modifiedBy).toBe(USERNAME); | ||||
|             }); | ||||
|  | ||||
|             it("Does not persist if the object is unchanged", () => { | ||||
|                 mockDomainObject.persisted = | ||||
|   | ||||
| @@ -17,6 +17,7 @@ class Overlay extends EventEmitter { | ||||
|         dismissable = true, | ||||
|         element, | ||||
|         onDestroy, | ||||
|         onDismiss, | ||||
|         size | ||||
|     } = {}) { | ||||
|         super(); | ||||
| @@ -32,7 +33,7 @@ class Overlay extends EventEmitter { | ||||
|                 OverlayComponent: OverlayComponent | ||||
|             }, | ||||
|             provide: { | ||||
|                 dismiss: this.dismiss.bind(this), | ||||
|                 dismiss: this.notifyAndDismiss.bind(this), | ||||
|                 element, | ||||
|                 buttons, | ||||
|                 dismissable: this.dismissable | ||||
| @@ -43,6 +44,10 @@ class Overlay extends EventEmitter { | ||||
|         if (onDestroy) { | ||||
|             this.once('destroy', onDestroy); | ||||
|         } | ||||
|  | ||||
|         if (onDismiss) { | ||||
|             this.once('dismiss', onDismiss); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     dismiss() { | ||||
| @@ -51,6 +56,12 @@ class Overlay extends EventEmitter { | ||||
|         this.component.$destroy(); | ||||
|     } | ||||
|  | ||||
|     //Ensures that any callers are notified that the overlay is dismissed | ||||
|     notifyAndDismiss() { | ||||
|         this.emit('dismiss'); | ||||
|         this.dismiss(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      **/ | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class OverlayAPI { | ||||
|     dismissLastOverlay() { | ||||
|         let lastOverlay = this.activeOverlays[this.activeOverlays.length - 1]; | ||||
|         if (lastOverlay && lastOverlay.dismissable) { | ||||
|             lastOverlay.dismiss(); | ||||
|             lastOverlay.notifyAndDismiss(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -27,7 +27,6 @@ import TelemetryMetadataManager from './TelemetryMetadataManager'; | ||||
| import TelemetryValueFormatter from './TelemetryValueFormatter'; | ||||
| import DefaultMetadataProvider from './DefaultMetadataProvider'; | ||||
| import objectUtils from 'objectUtils'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class TelemetryAPI { | ||||
|  | ||||
| @@ -73,7 +72,7 @@ export default class TelemetryAPI { | ||||
|      * @returns {boolean} true if the object is a telemetry object. | ||||
|      */ | ||||
|     isTelemetryObject(domainObject) { | ||||
|         return Boolean(this.findMetadataProvider(domainObject)); | ||||
|         return Boolean(this.#findMetadataProvider(domainObject)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -87,7 +86,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     canProvideTelemetry(domainObject) { | ||||
|         return Boolean(this.findSubscriptionProvider(domainObject)) | ||||
|         return Boolean(this.#findSubscriptionProvider(domainObject)) | ||||
|                 || Boolean(this.findRequestProvider(domainObject)); | ||||
|     } | ||||
|  | ||||
| @@ -120,7 +119,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findSubscriptionProvider() { | ||||
|     #findSubscriptionProvider() { | ||||
|         const args = Array.prototype.slice.apply(arguments); | ||||
|         function supportsDomainObject(provider) { | ||||
|             return provider.supportsSubscribe.apply(provider, args); | ||||
| @@ -130,9 +129,10 @@ export default class TelemetryAPI { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * Returns a telemetry request provider that supports | ||||
|      * a given domain object and options. | ||||
|      */ | ||||
|     findRequestProvider(domainObject) { | ||||
|     findRequestProvider() { | ||||
|         const args = Array.prototype.slice.apply(arguments); | ||||
|         function supportsDomainObject(provider) { | ||||
|             return provider.supportsRequest.apply(provider, args); | ||||
| @@ -144,7 +144,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findMetadataProvider(domainObject) { | ||||
|     #findMetadataProvider(domainObject) { | ||||
|         return this.metadataProviders.filter(function (p) { | ||||
|             return p.supportsMetadata(domainObject); | ||||
|         })[0]; | ||||
| @@ -153,7 +153,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     findLimitEvaluator(domainObject) { | ||||
|     #findLimitEvaluator(domainObject) { | ||||
|         return this.limitProviders.filter(function (p) { | ||||
|             return p.supportsLimits(domainObject); | ||||
|         })[0]; | ||||
| @@ -161,6 +161,7 @@ export default class TelemetryAPI { | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      * Though used in TelemetryCollection as well | ||||
|      */ | ||||
|     standardizeRequestOptions(options) { | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'start')) { | ||||
| @@ -174,6 +175,10 @@ export default class TelemetryAPI { | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'domain')) { | ||||
|             options.domain = this.openmct.time.timeSystem().key; | ||||
|         } | ||||
|  | ||||
|         if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) { | ||||
|             options.timeContext = this.openmct.time; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -241,7 +246,7 @@ export default class TelemetryAPI { | ||||
|     /** | ||||
|      * Request historical telemetry for a domain object. | ||||
|      * The `options` argument allows you to specify filters | ||||
|      * (start, end, etc.), sort order, and strategies for retrieving | ||||
|      * (start, end, etc.), sort order, time context, and strategies for retrieving | ||||
|      * telemetry (aggregation, latest available, etc.). | ||||
|      * | ||||
|      * @method request | ||||
| @@ -255,7 +260,7 @@ export default class TelemetryAPI { | ||||
|      */ | ||||
|     async request(domainObject) { | ||||
|         if (this.noRequestProviderForAllObjects) { | ||||
|             return Promise.resolve([]); | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         if (arguments.length === 1) { | ||||
| @@ -273,22 +278,24 @@ export default class TelemetryAPI { | ||||
|         if (!provider) { | ||||
|             this.requestAbortControllers.delete(abortController); | ||||
|  | ||||
|             return this.handleMissingRequestProvider(domainObject); | ||||
|             return this.#handleMissingRequestProvider(domainObject); | ||||
|         } | ||||
|  | ||||
|         arguments[1] = await this.applyRequestInterceptors(domainObject, arguments[1]); | ||||
|         try { | ||||
|             const telemetry = await provider.request(...arguments); | ||||
|  | ||||
|         return provider.request.apply(provider, arguments) | ||||
|             .catch((rejected) => { | ||||
|                 if (rejected.name !== 'AbortError') { | ||||
|                     this.openmct.notifications.error('Error requesting telemetry data, see console for details'); | ||||
|                     console.error(rejected); | ||||
|                 } | ||||
|             return telemetry; | ||||
|         } catch (error) { | ||||
|             if (error.name !== 'AbortError') { | ||||
|                 this.openmct.notifications.error('Error requesting telemetry data, see console for details'); | ||||
|                 console.error(error); | ||||
|             } | ||||
|  | ||||
|                 return Promise.reject(rejected); | ||||
|             }).finally(() => { | ||||
|                 this.requestAbortControllers.delete(abortController); | ||||
|             }); | ||||
|             throw new Error(error); | ||||
|         } finally { | ||||
|             this.requestAbortControllers.delete(abortController); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -306,7 +313,7 @@ export default class TelemetryAPI { | ||||
|      *          the subscription | ||||
|      */ | ||||
|     subscribe(domainObject, callback, options) { | ||||
|         const provider = this.findSubscriptionProvider(domainObject); | ||||
|         const provider = this.#findSubscriptionProvider(domainObject); | ||||
|  | ||||
|         if (!this.subscribeCache) { | ||||
|             this.subscribeCache = {}; | ||||
| @@ -353,7 +360,7 @@ export default class TelemetryAPI { | ||||
|      */ | ||||
|     getMetadata(domainObject) { | ||||
|         if (!this.metadataCache.has(domainObject)) { | ||||
|             const metadataProvider = this.findMetadataProvider(domainObject); | ||||
|             const metadataProvider = this.#findMetadataProvider(domainObject); | ||||
|             if (!metadataProvider) { | ||||
|                 return; | ||||
|             } | ||||
| @@ -369,33 +376,6 @@ export default class TelemetryAPI { | ||||
|         return this.metadataCache.get(domainObject); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return an array of valueMetadatas that are common to all supplied | ||||
|      * telemetry objects and match the requested hints. | ||||
|      * | ||||
|      */ | ||||
|     commonValuesForHints(metadatas, hints) { | ||||
|         const options = metadatas.map(function (metadata) { | ||||
|             const values = metadata.valuesForHints(hints); | ||||
|  | ||||
|             return _.keyBy(values, 'key'); | ||||
|         }).reduce(function (a, b) { | ||||
|             const results = {}; | ||||
|             Object.keys(a).forEach(function (key) { | ||||
|                 if (Object.prototype.hasOwnProperty.call(b, key)) { | ||||
|                     results[key] = a[key]; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             return results; | ||||
|         }); | ||||
|         const sortKeys = hints.map(function (h) { | ||||
|             return 'hints.' + h; | ||||
|         }); | ||||
|  | ||||
|         return _.sortBy(options, sortKeys); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get a value formatter for a given valueMetadata. | ||||
|      * | ||||
| @@ -450,7 +430,7 @@ export default class TelemetryAPI { | ||||
|      * | ||||
|      * @returns Promise | ||||
|      */ | ||||
|     handleMissingRequestProvider(domainObject) { | ||||
|     #handleMissingRequestProvider(domainObject) { | ||||
|         this.noRequestProviderForAllObjects = this.requestProviders.every(requestProvider => { | ||||
|             const supportsRequest = requestProvider.supportsRequest.apply(requestProvider, arguments); | ||||
|             const hasRequestProvider = Object.prototype.hasOwnProperty.call(requestProvider, 'request') && typeof requestProvider.request === 'function'; | ||||
| @@ -540,7 +520,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     getLimitEvaluator(domainObject) { | ||||
|         const provider = this.findLimitEvaluator(domainObject); | ||||
|         const provider = this.#findLimitEvaluator(domainObject); | ||||
|         if (!provider) { | ||||
|             return { | ||||
|                 evaluate: function () {} | ||||
| @@ -578,7 +558,7 @@ export default class TelemetryAPI { | ||||
|      * @memberof module:openmct.TelemetryAPI~TelemetryProvider# | ||||
|      */ | ||||
|     getLimits(domainObject) { | ||||
|         const provider = this.findLimitEvaluator(domainObject); | ||||
|         const provider = this.#findLimitEvaluator(domainObject); | ||||
|         if (!provider || !provider.getLimits) { | ||||
|             return { | ||||
|                 limits: function () { | ||||
|   | ||||
| @@ -23,11 +23,11 @@ import { createOpenMct, resetApplicationState } from 'utils/testing'; | ||||
| import TelemetryAPI from './TelemetryAPI'; | ||||
| import TelemetryCollection from './TelemetryCollection'; | ||||
|  | ||||
| describe('Telemetry API', function () { | ||||
| describe('Telemetry API', () => { | ||||
|     let openmct; | ||||
|     let telemetryAPI; | ||||
|  | ||||
|     beforeEach(function () { | ||||
|     beforeEach(() => { | ||||
|         openmct = { | ||||
|             time: jasmine.createSpyObj('timeAPI', [ | ||||
|                 'timeSystem', | ||||
| @@ -47,11 +47,11 @@ describe('Telemetry API', function () { | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     describe('telemetry providers', function () { | ||||
|     describe('telemetry providers', () => { | ||||
|         let telemetryProvider; | ||||
|         let domainObject; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             telemetryProvider = jasmine.createSpyObj('telemetryProvider', [ | ||||
|                 'supportsSubscribe', | ||||
|                 'subscribe', | ||||
| @@ -73,19 +73,16 @@ describe('Telemetry API', function () { | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         it('provides consistent results without providers', function (done) { | ||||
|         it('provides consistent results without providers', async () => { | ||||
|             const unsubscribe = telemetryAPI.subscribe(domainObject); | ||||
|  | ||||
|             expect(unsubscribe).toEqual(jasmine.any(Function)); | ||||
|  | ||||
|             telemetryAPI.request(domainObject) | ||||
|                 .then((data) => { | ||||
|                     expect(data).toEqual([]); | ||||
|                 }) | ||||
|                 .finally(done); | ||||
|             const data = await telemetryAPI.request(domainObject); | ||||
|             expect(data).toEqual([]); | ||||
|         }); | ||||
|  | ||||
|         it('skips providers that do not match', function (done) { | ||||
|         it('skips providers that do not match', async () => { | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(false); | ||||
|             telemetryProvider.supportsRequest.and.returnValue(false); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
| @@ -98,14 +95,13 @@ describe('Telemetry API', function () { | ||||
|             expect(telemetryProvider.subscribe).not.toHaveBeenCalled(); | ||||
|             expect(unsubscribe).toEqual(jasmine.any(Function)); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then((response) => { | ||||
|                 expect(telemetryProvider.supportsRequest) | ||||
|                     .toHaveBeenCalledWith(domainObject, jasmine.any(Object)); | ||||
|                 expect(telemetryProvider.request).not.toHaveBeenCalled(); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             expect(telemetryProvider.supportsRequest) | ||||
|                 .toHaveBeenCalledWith(domainObject, jasmine.any(Object)); | ||||
|             expect(telemetryProvider.request).not.toHaveBeenCalled(); | ||||
|         }); | ||||
|  | ||||
|         it('sends subscribe calls to matching providers', function () { | ||||
|         it('sends subscribe calls to matching providers', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -133,7 +129,7 @@ describe('Telemetry API', function () { | ||||
|             expect(callback).not.toHaveBeenCalledWith('otherValue'); | ||||
|         }); | ||||
|  | ||||
|         it('subscribes once per object', function () { | ||||
|         it('subscribes once per object', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -164,7 +160,7 @@ describe('Telemetry API', function () { | ||||
|             expect(callbacktwo).not.toHaveBeenCalledWith('anotherValue'); | ||||
|         }); | ||||
|  | ||||
|         it('only deletes subscription cache when there are no more subscribers', function () { | ||||
|         it('only deletes subscription cache when there are no more subscribers', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -187,7 +183,7 @@ describe('Telemetry API', function () { | ||||
|             unsubscribeThree(); | ||||
|         }); | ||||
|  | ||||
|         it('does subscribe/unsubscribe', function () { | ||||
|         it('does subscribe/unsubscribe', () => { | ||||
|             const unsubFunc = jasmine.createSpy('unsubscribe'); | ||||
|             telemetryProvider.subscribe.and.returnValue(unsubFunc); | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -203,7 +199,7 @@ describe('Telemetry API', function () { | ||||
|             unsubscribe(); | ||||
|         }); | ||||
|  | ||||
|         it('subscribes for different object', function () { | ||||
|         it('subscribes for different object', () => { | ||||
|             const unsubFuncs = []; | ||||
|             const notifiers = []; | ||||
|             telemetryProvider.supportsSubscribe.and.returnValue(true); | ||||
| @@ -243,120 +239,120 @@ describe('Telemetry API', function () { | ||||
|             expect(unsubFuncs[1]).toHaveBeenCalled(); | ||||
|         }); | ||||
|  | ||||
|         it('sends requests to matching providers', function (done) { | ||||
|         it('sends requests to matching providers', async () => { | ||||
|             const telemPromise = Promise.resolve([]); | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(telemPromise); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then(() => { | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     jasmine.any(Object) | ||||
|                 ); | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     domainObject, | ||||
|                     jasmine.any(Object) | ||||
|                 ); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 jasmine.any(Object) | ||||
|             ); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 domainObject, | ||||
|                 jasmine.any(Object) | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         it('generates default request options', function (done) { | ||||
|         it('generates default request options', async () => { | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject).then(() => { | ||||
|                 const { signal } = new AbortController(); | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         signal, | ||||
|                         start: 0, | ||||
|                         end: 1, | ||||
|                         domain: 'system' | ||||
|                     } | ||||
|                 ); | ||||
|             await telemetryAPI.request(domainObject); | ||||
|             const { signal } = new AbortController(); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         signal, | ||||
|                         start: 0, | ||||
|                         end: 1, | ||||
|                         domain: 'system' | ||||
|                     } | ||||
|                 ); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 telemetryProvider.supportsRequest.calls.reset(); | ||||
|                 telemetryProvider.request.calls.reset(); | ||||
|             telemetryProvider.supportsRequest.calls.reset(); | ||||
|             telemetryProvider.request.calls.reset(); | ||||
|  | ||||
|                 telemetryAPI.request(domainObject, {}).then(() => { | ||||
|                     expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                         jasmine.any(Object), | ||||
|                         { | ||||
|                             signal, | ||||
|                             start: 0, | ||||
|                             end: 1, | ||||
|                             domain: 'system' | ||||
|                         } | ||||
|                     ); | ||||
|  | ||||
|                     expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                         jasmine.any(Object), | ||||
|                         { | ||||
|                             signal, | ||||
|                             start: 0, | ||||
|                             end: 1, | ||||
|                             domain: 'system' | ||||
|                         } | ||||
|                     ); | ||||
|                 }); | ||||
|             }).finally(done); | ||||
|             await telemetryAPI.request(domainObject, {}); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     signal, | ||||
|                     start: 0, | ||||
|                     end: 1, | ||||
|                     domain: 'system', | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         it('do not overwrite existing request options', function (done) { | ||||
|         it('do not overwrite existing request options', async () => { | ||||
|             telemetryProvider.supportsRequest.and.returnValue(true); | ||||
|             telemetryProvider.request.and.returnValue(Promise.resolve([])); | ||||
|             telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|             telemetryAPI.request(domainObject, { | ||||
|             await telemetryAPI.request(domainObject, { | ||||
|                 start: 20, | ||||
|                 end: 30, | ||||
|                 domain: 'someDomain' | ||||
|             }).then(() => { | ||||
|                 const { signal } = new AbortController(); | ||||
|                 expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         start: 20, | ||||
|                         end: 30, | ||||
|                         domain: 'someDomain', | ||||
|                         signal | ||||
|                     } | ||||
|                 ); | ||||
|             }); | ||||
|             const { signal } = new AbortController(); | ||||
|             expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     start: 20, | ||||
|                     end: 30, | ||||
|                     domain: 'someDomain', | ||||
|                     signal, | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|  | ||||
|                 expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                     jasmine.any(Object), | ||||
|                     { | ||||
|                         start: 20, | ||||
|                         end: 30, | ||||
|                         domain: 'someDomain', | ||||
|                         signal | ||||
|                     } | ||||
|                 ); | ||||
|  | ||||
|             }).finally(done); | ||||
|             expect(telemetryProvider.request).toHaveBeenCalledWith( | ||||
|                 jasmine.any(Object), | ||||
|                 { | ||||
|                     start: 20, | ||||
|                     end: 30, | ||||
|                     domain: 'someDomain', | ||||
|                     signal, | ||||
|                     timeContext: jasmine.any(Object) | ||||
|                 } | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('metadata', function () { | ||||
|     describe('metadata', () => { | ||||
|         let mockMetadata = {}; | ||||
|         let mockObjectType = { | ||||
|             definition: {} | ||||
|         }; | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             telemetryAPI.addProvider({ | ||||
|                 key: 'mockMetadataProvider', | ||||
|                 supportsMetadata() { | ||||
| @@ -369,7 +365,7 @@ describe('Telemetry API', function () { | ||||
|             openmct.types.get.and.returnValue(mockObjectType); | ||||
|         }); | ||||
|  | ||||
|         it('respects explicit priority', function () { | ||||
|         it('respects explicit priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -408,7 +404,7 @@ describe('Telemetry API', function () { | ||||
|                 expect(value.hints.priority).toBe(index + 1); | ||||
|             }); | ||||
|         }); | ||||
|         it('if no explicit priority, defaults to order defined', function () { | ||||
|         it('if no explicit priority, defaults to order defined', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -435,7 +431,7 @@ describe('Telemetry API', function () { | ||||
|                 expect(value.key).toBe(mockMetadata.values[index].key); | ||||
|             }); | ||||
|         }); | ||||
|         it('respects domain priority', function () { | ||||
|         it('respects domain priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -477,7 +473,7 @@ describe('Telemetry API', function () { | ||||
|             expect(values[0].key).toBe('timestamp-local'); | ||||
|             expect(values[1].key).toBe('timestamp-utc'); | ||||
|         }); | ||||
|         it('respects range priority', function () { | ||||
|         it('respects range priority', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "name", | ||||
| @@ -519,7 +515,7 @@ describe('Telemetry API', function () { | ||||
|             expect(values[0].key).toBe('cos'); | ||||
|             expect(values[1].key).toBe('sin'); | ||||
|         }); | ||||
|         it('respects priority and domain ordering', function () { | ||||
|         it('respects priority and domain ordering', () => { | ||||
|             mockMetadata.values = [ | ||||
|                 { | ||||
|                     key: "id", | ||||
| @@ -588,7 +584,7 @@ describe('Telemetry API', function () { | ||||
|             definition: {} | ||||
|         }; | ||||
|  | ||||
|         beforeEach(function () { | ||||
|         beforeEach(() => { | ||||
|             openmct.telemetry = telemetryAPI; | ||||
|             telemetryAPI.addProvider({ | ||||
|                 key: 'mockMetadataProvider', | ||||
| @@ -644,16 +640,14 @@ describe('Telemetery', () => { | ||||
|         return resetApplicationState(openmct); | ||||
|     }); | ||||
|  | ||||
|     it('should not abort request without navigation', function (done) { | ||||
|     it('should not abort request without navigation', async () => { | ||||
|         telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|         telemetryAPI.request({}).finally(() => { | ||||
|             expect(watchedSignal.aborted).toBe(false); | ||||
|             done(); | ||||
|         }); | ||||
|         await telemetryAPI.request({}); | ||||
|         expect(watchedSignal.aborted).toBe(false); | ||||
|     }); | ||||
|  | ||||
|     it('should abort request on navigation', function (done) { | ||||
|     it('should abort request on navigation', (done) => { | ||||
|         telemetryAPI.addProvider(telemetryProvider); | ||||
|  | ||||
|         telemetryAPI.request({}).finally(() => { | ||||
|   | ||||
| @@ -202,8 +202,13 @@ class IndependentTimeContext extends TimeContext { | ||||
|     } | ||||
|  | ||||
|     getUpstreamContext() { | ||||
|         let timeContext = this.globalTimeContext; | ||||
|         const objectKey = this.openmct.objects.makeKeyString(this.objectPath[this.objectPath.length - 1].identifier); | ||||
|         const doesObjectHaveTimeContext = this.globalTimeContext.independentContexts.get(objectKey); | ||||
|         if (doesObjectHaveTimeContext) { | ||||
|             return undefined; | ||||
|         } | ||||
|  | ||||
|         let timeContext = this.globalTimeContext; | ||||
|         this.objectPath.some((item, index) => { | ||||
|             const key = this.openmct.objects.makeKeyString(item.identifier); | ||||
|             //last index is the view object itself | ||||
|   | ||||
| @@ -229,6 +229,25 @@ describe("The Time API", function () { | ||||
|             expect(api.clock()).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('Provides a default time context', () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             expect(timeContext).not.toBe(null); | ||||
|         }); | ||||
|  | ||||
|         it("Without a clock, is in fixed time mode", () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             expect(timeContext.isRealTime()).toBe(false); | ||||
|         }); | ||||
|  | ||||
|         it("Provided a clock, is in real-time mode", () => { | ||||
|             const timeContext = api.getContextForView([]); | ||||
|             timeContext.clock('mts', { | ||||
|                 start: 0, | ||||
|                 end: 1 | ||||
|             }); | ||||
|             expect(timeContext.isRealTime()).toBe(true); | ||||
|         }); | ||||
|  | ||||
|     }); | ||||
|  | ||||
|     it("on tick, observes offsets, and indicates tick in bounds callback", function () { | ||||
|   | ||||
| @@ -362,6 +362,18 @@ class TimeContext extends EventEmitter { | ||||
|         this.boundsVal = newBounds; | ||||
|         this.emit('bounds', this.boundsVal, true); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if this time context is in real-time mode or not. | ||||
|      * @returns {boolean} true if this context is in real-time mode, false if not | ||||
|     */ | ||||
|     isRealTime() { | ||||
|         if (this.clock()) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default TimeContext; | ||||
|   | ||||
| @@ -114,6 +114,8 @@ export default { | ||||
|         this.formats = this.openmct.telemetry.getFormatMap(this.metadata); | ||||
|         this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|         this.timeContext = this.openmct.time.getContextForView(this.objectPath); | ||||
|  | ||||
|         this.limitEvaluator = this.openmct | ||||
|             .telemetry | ||||
|             .limitEvaluator(this.domainObject); | ||||
| @@ -134,7 +136,8 @@ export default { | ||||
|  | ||||
|         this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, { | ||||
|             size: 1, | ||||
|             strategy: 'latest' | ||||
|             strategy: 'latest', | ||||
|             timeContext: this.timeContext | ||||
|         }); | ||||
|         this.telemetryCollection.on('add', this.setLatestValues); | ||||
|         this.telemetryCollection.on('clear', this.resetValues); | ||||
|   | ||||
| @@ -112,11 +112,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         removeFromComposition(telemetryObject) { | ||||
|             let composition = this.domainObject.composition.filter(id => | ||||
|                 !this.openmct.objects.areIdsEqual(id, telemetryObject.identifier) | ||||
|             ); | ||||
|  | ||||
|             this.openmct.objects.mutate(this.domainObject, 'composition', composition); | ||||
|             this.composition.remove(telemetryObject); | ||||
|         }, | ||||
|         addTelemetryObject(telemetryObject) { | ||||
|             // grab information we need from the added telmetry object | ||||
|   | ||||
| @@ -104,10 +104,14 @@ export default { | ||||
|             this.$set(this.plotSeries, this.plotSeries.length, series); | ||||
|             this.setAxesLabels(); | ||||
|         }, | ||||
|         removeSeries(series) { | ||||
|             const index = this.plotSeries.findIndex(plotSeries => this.openmct.objects.areIdsEqual(series.identifier, plotSeries.identifier)); | ||||
|             if (index !== undefined) { | ||||
|                 this.$delete(this.plotSeries, index); | ||||
|         removeSeries(seriesKey) { | ||||
|             const seriesIndex = this.plotSeries.findIndex( | ||||
|                 plotSeries => this.openmct.objects.areIdsEqual(seriesKey, plotSeries.identifier) | ||||
|             ); | ||||
|  | ||||
|             const foundSeries = seriesIndex > -1; | ||||
|             if (foundSeries) { | ||||
|                 this.$delete(this.plotSeries, seriesIndex); | ||||
|                 this.setAxesLabels(); | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -68,6 +68,7 @@ export default function ClockPlugin(options) { | ||||
|                     ] | ||||
|                 }, | ||||
|                 { | ||||
|                     ariaLabel: "12 or 24 hour clock", | ||||
|                     control: 'select', | ||||
|                     options: [ | ||||
|                         { | ||||
|   | ||||
| @@ -30,6 +30,12 @@ | ||||
|     padding: $interiorMarginLg $interiorMarginLg * 2; | ||||
| } | ||||
|  | ||||
| .c-condition-widget__label { | ||||
|     padding: $interiorMargin; | ||||
|     text-align: center; | ||||
|     white-space: normal; | ||||
| } | ||||
|  | ||||
| a.c-condition-widget { | ||||
|     // Widget is conditionally made into a <a> when URL property has been defined | ||||
|     cursor: pointer !important; | ||||
|   | ||||
| @@ -583,6 +583,7 @@ define(['lodash'], function (_) { | ||||
|                                 domainObject: selectedParent, | ||||
|                                 icon: "icon-object", | ||||
|                                 title: "Merge into a telemetry table or plot", | ||||
|                                 label: "View type", | ||||
|                                 options: APPLICABLE_VIEWS['telemetry-view-multi'], | ||||
|                                 method: function (option) { | ||||
|                                     displayLayoutContext.mergeMultipleTelemetryViews(selection, option.value); | ||||
|   | ||||
| @@ -245,6 +245,9 @@ export default { | ||||
|             }); | ||||
|             this.gridDimensions = [wMax * this.gridSize[0], hMax * this.gridSize[1]]; | ||||
|         }, | ||||
|         clearSelection() { | ||||
|             this.$el.click(); | ||||
|         }, | ||||
|         watchDisplayResize() { | ||||
|             const resizeObserver = new ResizeObserver(() => this.updateGrid()); | ||||
|  | ||||
| @@ -478,7 +481,7 @@ export default { | ||||
|             }); | ||||
|             _.pullAt(this.layoutItems, indices); | ||||
|             this.mutate("configuration.items", this.layoutItems); | ||||
|             this.$el.click(); | ||||
|             this.clearSelection(); | ||||
|         }, | ||||
|         untrackItem(item) { | ||||
|             if (!item.identifier) { | ||||
| @@ -504,15 +507,11 @@ export default { | ||||
|             } | ||||
|  | ||||
|             if (!telemetryViewCount && !objectViewCount) { | ||||
|                 this.removeFromComposition(keyString); | ||||
|                 this.removeFromComposition(item); | ||||
|             } | ||||
|         }, | ||||
|         removeFromComposition(keyString) { | ||||
|             let composition = this.domainObject.composition ? this.domainObject.composition : []; | ||||
|             composition = composition.filter(identifier => { | ||||
|                 return this.openmct.objects.makeKeyString(identifier) !== keyString; | ||||
|             }); | ||||
|             this.mutate("composition", composition); | ||||
|         removeFromComposition(item) { | ||||
|             this.composition.remove(item); | ||||
|         }, | ||||
|         initializeItems() { | ||||
|             this.telemetryViewMap = {}; | ||||
| @@ -529,7 +528,10 @@ export default { | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             this.startTransaction(); | ||||
|             removedItems.forEach(this.removeFromConfiguration); | ||||
|  | ||||
|             return this.endTransaction(); | ||||
|         }, | ||||
|         isItemAlreadyTracked(child) { | ||||
|             let found = false; | ||||
| @@ -590,7 +592,7 @@ export default { | ||||
|                 } | ||||
|             }); | ||||
|             this.mutate("configuration.items", layoutItems); | ||||
|             this.$el.click(); | ||||
|             this.clearSelection(); | ||||
|         }, | ||||
|         orderItem(position, selectedItems) { | ||||
|             let delta = ORDERS[position]; | ||||
| @@ -773,7 +775,7 @@ export default { | ||||
|             this.$nextTick(() => { | ||||
|                 this.openmct.objects.mutate(this.domainObject, "configuration.items", this.layoutItems); | ||||
|                 this.openmct.objects.mutate(this.domainObject, "configuration.objectStyles", objectStyles); | ||||
|                 this.$el.click(); //clear selection; | ||||
|                 this.clearSelection(); | ||||
|  | ||||
|                 newDomainObjectsArray.forEach(domainObject => { | ||||
|                     this.composition.add(domainObject); | ||||
| @@ -867,6 +869,20 @@ export default { | ||||
|             this.removeItem(selection); | ||||
|             this.initSelectIndex = this.layoutItems.length - 1; //restore selection | ||||
|         }, | ||||
|         startTransaction() { | ||||
|             if (!this.openmct.objects.isTransactionActive()) { | ||||
|                 this.transaction = this.openmct.objects.startTransaction(); | ||||
|             } | ||||
|         }, | ||||
|         async endTransaction() { | ||||
|             if (!this.transaction) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             await this.transaction.commit(); | ||||
|             this.openmct.objects.endTransaction(); | ||||
|             this.transaction = null; | ||||
|         }, | ||||
|         toggleGrid() { | ||||
|             this.showGrid = !this.showGrid; | ||||
|         }, | ||||
|   | ||||
| @@ -282,12 +282,15 @@ export default { | ||||
|             this.limitEvaluator = this.openmct.telemetry.limitEvaluator(this.domainObject); | ||||
|             this.formats = this.openmct.telemetry.getFormatMap(this.metadata); | ||||
|  | ||||
|             this.timeContext = this.openmct.time.getContextForView(this.objectPath); | ||||
|  | ||||
|             const valueMetadata = this.metadata ? this.metadata.value(this.item.value) : {}; | ||||
|             this.customStringformatter = this.openmct.telemetry.customStringFormatter(valueMetadata, this.item.format); | ||||
|  | ||||
|             this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, { | ||||
|                 size: 1, | ||||
|                 strategy: 'latest' | ||||
|                 strategy: 'latest', | ||||
|                 timeContext: this.timeContext | ||||
|             }); | ||||
|             this.telemetryCollection.on('add', this.setLatestValues); | ||||
|             this.telemetryCollection.on('clear', this.refreshData); | ||||
|   | ||||
| @@ -185,10 +185,24 @@ export default { | ||||
|         this.composition.off('add', this.addFrame); | ||||
|     }, | ||||
|     methods: { | ||||
|         containsObject(identifier) { | ||||
|             if ('composition' in this.domainObject) { | ||||
|                 return this.domainObject.composition | ||||
|                     .some(childId => this.openmct.objects.areIdsEqual(childId, identifier)); | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         }, | ||||
|         buildIdentifierMap() { | ||||
|             this.containers.forEach(container => { | ||||
|                 container.frames.forEach(frame => { | ||||
|                     let keystring = this.openmct.objects.makeKeyString(frame.domainObjectIdentifier); | ||||
|                     if (!this.containsObject(frame.domainObjectIdentifier)) { | ||||
|                         this.removeChildObject(frame.domainObjectIdentifier); | ||||
|  | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     const keystring = this.openmct.objects.makeKeyString(frame.domainObjectIdentifier); | ||||
|                     this.identifierMap[keystring] = true; | ||||
|                 }); | ||||
|             }); | ||||
| @@ -296,11 +310,14 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         persist(index) { | ||||
|             this.startTransaction(); | ||||
|             if (index) { | ||||
|                 this.openmct.objects.mutate(this.domainObject, `configuration.containers[${index}]`, this.containers[index]); | ||||
|             } else { | ||||
|                 this.openmct.objects.mutate(this.domainObject, 'configuration.containers', this.containers); | ||||
|             } | ||||
|  | ||||
|             return this.endTransaction(); | ||||
|         }, | ||||
|         startContainerResizing(index) { | ||||
|             let beforeContainer = this.containers[index]; | ||||
| @@ -366,6 +383,20 @@ export default { | ||||
|             }); | ||||
|  | ||||
|             this.persist(); | ||||
|         }, | ||||
|         startTransaction() { | ||||
|             if (!this.openmct.objects.isTransactionActive()) { | ||||
|                 this.transaction = this.openmct.objects.startTransaction(); | ||||
|             } | ||||
|         }, | ||||
|         async endTransaction() { | ||||
|             if (!this.transaction) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             await this.transaction.commit(); | ||||
|             this.openmct.objects.endTransaction(); | ||||
|             this.transaction = null; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import PropertiesAction from './PropertiesAction'; | ||||
| import CreateWizard from './CreateWizard'; | ||||
|  | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class CreateAction extends PropertiesAction { | ||||
|     constructor(openmct, type, parentDomainObject) { | ||||
| @@ -50,19 +51,12 @@ export default class CreateAction extends PropertiesAction { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const properties = key.split('.'); | ||||
|             let object = this.domainObject; | ||||
|             const propertiesLength = properties.length; | ||||
|             properties.forEach((property, index) => { | ||||
|                 const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; | ||||
|                 if (isComplexProperty && object[property] !== null) { | ||||
|                     object = object[property]; | ||||
|                 } else { | ||||
|                     object[property] = value; | ||||
|                 } | ||||
|             }); | ||||
|             const existingValue = this.domainObject[key]; | ||||
|             if (!(existingValue instanceof Array) && (typeof existingValue === 'object')) { | ||||
|                 value = _.merge(existingValue, value); | ||||
|             } | ||||
|  | ||||
|             object = value; | ||||
|             _.set(this.domainObject, key, value); | ||||
|         }); | ||||
|  | ||||
|         const parentDomainObject = parentDomainObjectPath[0]; | ||||
| @@ -94,6 +88,12 @@ export default class CreateAction extends PropertiesAction { | ||||
|         dialog.dismiss(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     _onCancel() { | ||||
|         //do Nothing | ||||
|     } | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
| @@ -107,7 +107,7 @@ export default class CreateAction extends PropertiesAction { | ||||
|         } | ||||
|  | ||||
|         const url = '#/browse/' + objectPath | ||||
|             .map(object => object && this.openmct.objects.makeKeyString(object.identifier.key)) | ||||
|             .map(object => object && this.openmct.objects.makeKeyString(object.identifier)) | ||||
|             .reverse() | ||||
|             .join('/'); | ||||
|  | ||||
| @@ -151,6 +151,7 @@ export default class CreateAction extends PropertiesAction { | ||||
|         formStructure.title = 'Create a New ' + definition.name; | ||||
|  | ||||
|         this.openmct.forms.showForm(formStructure) | ||||
|             .then(this._onSave.bind(this)); | ||||
|             .then(this._onSave.bind(this)) | ||||
|             .catch(this._onCancel.bind(this)); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -22,6 +22,8 @@ | ||||
|  | ||||
| import PropertiesAction from './PropertiesAction'; | ||||
| import CreateWizard from './CreateWizard'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| export default class EditPropertiesAction extends PropertiesAction { | ||||
|     constructor(openmct) { | ||||
|         super(openmct); | ||||
| @@ -51,25 +53,23 @@ export default class EditPropertiesAction extends PropertiesAction { | ||||
|     /** | ||||
|      * @private | ||||
|      */ | ||||
|     _onSave(changes) { | ||||
|     async _onSave(changes) { | ||||
|         if (!this.openmct.objects.isTransactionActive()) { | ||||
|             this.openmct.objects.startTransaction(); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             Object.entries(changes).forEach(([key, value]) => { | ||||
|                 const properties = key.split('.'); | ||||
|                 let object = this.domainObject; | ||||
|                 const propertiesLength = properties.length; | ||||
|                 properties.forEach((property, index) => { | ||||
|                     const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; | ||||
|                     if (isComplexProperty && object[property] !== null) { | ||||
|                         object = object[property]; | ||||
|                     } else { | ||||
|                         object[property] = value; | ||||
|                     } | ||||
|                 }); | ||||
|                 const existingValue = this.domainObject[key]; | ||||
|                 if (!(Array.isArray(existingValue)) && (typeof existingValue === 'object')) { | ||||
|                     value = _.merge(existingValue, value); | ||||
|                 } | ||||
|  | ||||
|                 object = value; | ||||
|                 this.openmct.objects.mutate(this.domainObject, key, value); | ||||
|                 this.openmct.notifications.info('Save successful'); | ||||
|             }); | ||||
|             const transaction = this.openmct.objects.getActiveTransaction(); | ||||
|             await transaction.commit(); | ||||
|             this.openmct.objects.endTransaction(); | ||||
|         } catch (error) { | ||||
|             this.openmct.notifications.error('Error saving objects'); | ||||
|             console.error(error); | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import { | ||||
|     createOpenMct, | ||||
|     resetApplicationState | ||||
| } from 'utils/testing'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| import { debounce } from 'lodash'; | ||||
|  | ||||
| @@ -101,10 +102,15 @@ describe('EditPropertiesAction plugin', () => { | ||||
|             composition: [] | ||||
|         }; | ||||
|  | ||||
|         const deBouncedFormChange = debounce(handleFormPropertyChange, 500); | ||||
|         openmct.forms.on('onFormPropertyChange', deBouncedFormChange); | ||||
|         editPropertiesAction.invoke([domainObject]) | ||||
|             .then(() => { | ||||
|                 done(); | ||||
|             }) | ||||
|             .catch(() => { | ||||
|                 done(); | ||||
|             }); | ||||
|  | ||||
|         function handleFormPropertyChange(data) { | ||||
|         Vue.nextTick(() => { | ||||
|             const form = document.querySelector('.js-form'); | ||||
|             const title = form.querySelector('input'); | ||||
|             expect(title.value).toEqual(domainObject.name); | ||||
| @@ -118,17 +124,7 @@ describe('EditPropertiesAction plugin', () => { | ||||
|  | ||||
|             const clickEvent = createMouseEvent('click'); | ||||
|             buttons[1].dispatchEvent(clickEvent); | ||||
|  | ||||
|             openmct.forms.off('onFormPropertyChange', deBouncedFormChange); | ||||
|         } | ||||
|  | ||||
|         editPropertiesAction.invoke([domainObject]) | ||||
|             .then(() => { | ||||
|                 done(); | ||||
|             }) | ||||
|             .catch(() => { | ||||
|                 done(); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('edit properties action saves changes', (done) => { | ||||
| @@ -159,11 +155,9 @@ describe('EditPropertiesAction plugin', () => { | ||||
|         const deBouncedCallback = debounce(callback, 300); | ||||
|         unObserve = openmct.objects.observe(domainObject, '*', deBouncedCallback); | ||||
|  | ||||
|         let changed = false; | ||||
|         const deBouncedFormChange = debounce(handleFormPropertyChange, 500); | ||||
|         openmct.forms.on('onFormPropertyChange', deBouncedFormChange); | ||||
|         editPropertiesAction.invoke([domainObject]); | ||||
|  | ||||
|         function handleFormPropertyChange(data) { | ||||
|         Vue.nextTick(() => { | ||||
|             const form = document.querySelector('.js-form'); | ||||
|             const title = form.querySelector('input'); | ||||
|             const notes = form.querySelector('textArea'); | ||||
| @@ -172,27 +166,18 @@ describe('EditPropertiesAction plugin', () => { | ||||
|             expect(buttons[0].textContent.trim()).toEqual('OK'); | ||||
|             expect(buttons[1].textContent.trim()).toEqual('Cancel'); | ||||
|  | ||||
|             if (!changed) { | ||||
|                 expect(title.value).toEqual(domainObject.name); | ||||
|                 expect(notes.value).toEqual(domainObject.notes); | ||||
|             expect(title.value).toEqual(domainObject.name); | ||||
|             expect(notes.value).toEqual(domainObject.notes); | ||||
|  | ||||
|                 // change input field value and dispatch event for it | ||||
|                 title.focus(); | ||||
|                 title.value = newName; | ||||
|                 title.dispatchEvent(new Event('input')); | ||||
|                 title.blur(); | ||||
|             // change input field value and dispatch event for it | ||||
|             title.focus(); | ||||
|             title.value = newName; | ||||
|             title.dispatchEvent(new Event('input')); | ||||
|             title.blur(); | ||||
|  | ||||
|                 changed = true; | ||||
|             } else { | ||||
|                 // click ok to save form changes | ||||
|                 const clickEvent = createMouseEvent('click'); | ||||
|                 buttons[0].dispatchEvent(clickEvent); | ||||
|  | ||||
|                 openmct.forms.off('onFormPropertyChange', deBouncedFormChange); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         editPropertiesAction.invoke([domainObject]); | ||||
|             const clickEvent = createMouseEvent('click'); | ||||
|             buttons[0].dispatchEvent(clickEvent); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('edit properties action discards changes', (done) => { | ||||
| @@ -217,7 +202,6 @@ describe('EditPropertiesAction plugin', () => { | ||||
|             }) | ||||
|             .catch(() => { | ||||
|                 expect(domainObject.name).toEqual(name); | ||||
|  | ||||
|                 done(); | ||||
|             }); | ||||
|  | ||||
|   | ||||
| @@ -598,11 +598,7 @@ export default { | ||||
|             return this.round(((vPercent / 100) * 270) + DIAL_VALUE_DEG_OFFSET, 2); | ||||
|         }, | ||||
|         removeFromComposition(telemetryObject = this.telemetryObject) { | ||||
|             let composition = this.domainObject.composition.filter(id => | ||||
|                 !this.openmct.objects.areIdsEqual(id, telemetryObject.identifier) | ||||
|             ); | ||||
|  | ||||
|             this.openmct.objects.mutate(this.domainObject, 'composition', composition); | ||||
|             this.composition.remove(telemetryObject); | ||||
|         }, | ||||
|         refreshData(bounds, isTick) { | ||||
|             if (!isTick) { | ||||
|   | ||||
| @@ -100,6 +100,7 @@ export default { | ||||
|     components: { | ||||
|         ToggleSwitch | ||||
|     }, | ||||
|     inject: ["openmct"], | ||||
|     props: { | ||||
|         model: { | ||||
|             type: Object, | ||||
| @@ -107,11 +108,10 @@ export default { | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         this.changes = {}; | ||||
|  | ||||
|         return { | ||||
|             isUseTelemetryLimits: this.model.value.isUseTelemetryLimits, | ||||
|             isDisplayMinMax: this.model.value.isDisplayMinMax, | ||||
|             isDisplayCurVal: this.model.value.isDisplayCurVal, | ||||
|             isDisplayUnits: this.model.value.isDisplayUnits, | ||||
|             limitHigh: this.model.value.limitHigh, | ||||
|             limitLow: this.model.value.limitLow, | ||||
|             max: this.model.value.max, | ||||
| @@ -120,24 +120,15 @@ export default { | ||||
|     }, | ||||
|     methods: { | ||||
|         onChange(event) { | ||||
|             const data = { | ||||
|                 model: this.model, | ||||
|                 value: { | ||||
|                     gaugeType: this.model.value.gaugeType, | ||||
|                     isDisplayMinMax: this.isDisplayMinMax, | ||||
|                     isDisplayCurVal: this.isDisplayCurVal, | ||||
|                     isDisplayUnits: this.isDisplayUnits, | ||||
|                     isUseTelemetryLimits: this.isUseTelemetryLimits, | ||||
|                     limitLow: this.limitLow, | ||||
|                     limitHigh: this.limitHigh, | ||||
|                     max: this.max, | ||||
|                     min: this.min, | ||||
|                     precision: this.model.value.precision | ||||
|                 } | ||||
|             let data = { | ||||
|                 model: {} | ||||
|             }; | ||||
|  | ||||
|             if (event) { | ||||
|                 const target = event.target; | ||||
|                 const property = target.dataset.fieldName; | ||||
|                 data.model.property = Array.from(this.model.property).concat([property]); | ||||
|                 data.value = this[property]; | ||||
|                 const targetIndicator = target.parentElement.querySelector('.req-indicator'); | ||||
|                 if (targetIndicator.classList.contains('req')) { | ||||
|                     targetIndicator.classList.add('visited'); | ||||
| @@ -160,13 +151,13 @@ export default { | ||||
|         }, | ||||
|         toggleUseTelemetryLimits() { | ||||
|             this.isUseTelemetryLimits = !this.isUseTelemetryLimits; | ||||
|  | ||||
|             this.onChange(); | ||||
|         }, | ||||
|         toggleMinMax() { | ||||
|             this.isDisplayMinMax = !this.isDisplayMinMax; | ||||
|  | ||||
|             this.onChange(); | ||||
|             const data = { | ||||
|                 model: { | ||||
|                     property: Array.from(this.model.property).concat(['isUseTelemetryLimits']) | ||||
|                 }, | ||||
|                 value: this.isUseTelemetryLimits | ||||
|             }; | ||||
|             this.$emit('onChange', data); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -45,6 +45,10 @@ export default class GoToOriginalAction { | ||||
|             }); | ||||
|     } | ||||
|     appliesTo(objectPath) { | ||||
|         if (this._openmct.editor.isEditing()) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         let parentKeystring = objectPath[1] && this._openmct.objects.makeKeyString(objectPath[1].identifier); | ||||
|  | ||||
|         if (!parentKeystring) { | ||||
|   | ||||
| @@ -31,21 +31,32 @@ | ||||
|     :title="image.formattedTime" | ||||
| > | ||||
|     <a | ||||
|         class="c-thumb__image-wrapper" | ||||
|         href="" | ||||
|         :download="image.imageDownloadName" | ||||
|         @click.prevent | ||||
|     > | ||||
|         <img | ||||
|             ref="img" | ||||
|             class="c-thumb__image" | ||||
|             :src="image.url" | ||||
|             fetchpriority="low" | ||||
|             @load="imageLoadCompleted" | ||||
|         > | ||||
|     </a> | ||||
|     <div | ||||
|         v-if="viewableArea" | ||||
|         class="c-thumb__viewable-area" | ||||
|         :style="viewableAreaStyle" | ||||
|     ></div> | ||||
|     <div class="c-thumb__timestamp">{{ image.formattedTime }}</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| const THUMB_PADDING = 4; | ||||
| const BORDER_WIDTH = 2; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         image: { | ||||
| @@ -63,6 +74,77 @@ export default { | ||||
|         realTime: { | ||||
|             type: Boolean, | ||||
|             required: true | ||||
|         }, | ||||
|         viewableArea: { | ||||
|             type: Object, | ||||
|             default: function () { | ||||
|                 return null; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             imgWidth: 0, | ||||
|             imgHeight: 0 | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         viewableAreaStyle() { | ||||
|             if (!this.viewableArea || !this.imgWidth || !this.imgHeight) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             const { widthRatio, heightRatio, xOffsetRatio, yOffsetRatio } = this.viewableArea; | ||||
|             const imgWidth = this.imgWidth; | ||||
|             const imgHeight = this.imgHeight; | ||||
|  | ||||
|             let translateX = imgWidth * xOffsetRatio; | ||||
|             let translateY = imgHeight * yOffsetRatio; | ||||
|             let width = imgWidth * widthRatio; | ||||
|             let height = imgHeight * heightRatio; | ||||
|  | ||||
|             if (translateX < 0) { | ||||
|                 width += translateX; | ||||
|                 translateX = 0; | ||||
|             } | ||||
|  | ||||
|             if (translateX + width > imgWidth) { | ||||
|                 width = imgWidth - translateX; | ||||
|             } | ||||
|  | ||||
|             if (translateX + 2 * BORDER_WIDTH > imgWidth) { | ||||
|                 translateX = imgWidth - 2 * BORDER_WIDTH; | ||||
|             } | ||||
|  | ||||
|             if (translateY < 0) { | ||||
|                 height += translateY; | ||||
|                 translateY = 0; | ||||
|             } | ||||
|  | ||||
|             if (translateY + height > imgHeight) { | ||||
|                 height = imgHeight - translateY; | ||||
|             } | ||||
|  | ||||
|             if (translateY + 2 * BORDER_WIDTH > imgHeight) { | ||||
|                 translateY = imgHeight - 2 * BORDER_WIDTH; | ||||
|             } | ||||
|  | ||||
|             return { | ||||
|                 'transform': `translate(${translateX + THUMB_PADDING}px, ${translateY + THUMB_PADDING}px)`, | ||||
|                 'width': `${width}px`, | ||||
|                 'height': `${height}px` | ||||
|             }; | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         imageLoadCompleted() { | ||||
|             if (!this.$refs.img) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const { width: imgWidth, height: imgHeight } = this.$refs.img; | ||||
|             this.imgWidth = imgWidth; | ||||
|             this.imgHeight = imgHeight; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -25,7 +25,7 @@ | ||||
|     tabindex="0" | ||||
|     class="c-imagery" | ||||
|     @keyup="arrowUpHandler" | ||||
|     @keydown="arrowDownHandler" | ||||
|     @keydown.prevent="arrowDownHandler" | ||||
|     @mouseover="focusElement" | ||||
| > | ||||
|     <div | ||||
| @@ -147,7 +147,7 @@ | ||||
|                     v-if="!isFixed" | ||||
|                     class="c-button icon-pause pause-play" | ||||
|                     :class="{'is-paused': isPaused}" | ||||
|                     @click="paused(!isPaused)" | ||||
|                     @click="handlePauseButton(!isPaused)" | ||||
|                 ></button> | ||||
|             </div> | ||||
|         </div> | ||||
| @@ -165,6 +165,9 @@ | ||||
|         <div | ||||
|             ref="thumbsWrapper" | ||||
|             class="c-imagery__thumbs-scroll-area" | ||||
|             :class="[{ | ||||
|                 'animate-scroll': animateThumbScroll | ||||
|             }]" | ||||
|             @scroll="handleScroll" | ||||
|         > | ||||
|             <ImageThumbnail | ||||
| @@ -174,6 +177,7 @@ | ||||
|                 :active="focusedImageIndex === index" | ||||
|                 :selected="focusedImageIndex === index && isPaused" | ||||
|                 :real-time="!isFixed" | ||||
|                 :viewable-area="focusedImageIndex === index ? viewableArea : null" | ||||
|                 @click.native="thumbnailClicked(index)" | ||||
|             /> | ||||
|         </div> | ||||
| @@ -181,7 +185,7 @@ | ||||
|         <button | ||||
|             class="c-imagery__auto-scroll-resume-button c-icon-button icon-play" | ||||
|             title="Resume automatic scrolling of image thumbnails" | ||||
|             @click="scrollToRight('reset')" | ||||
|             @click="scrollToRight" | ||||
|         ></button> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -191,6 +195,7 @@ | ||||
| import eventHelpers from '../lib/eventHelpers'; | ||||
| import _ from 'lodash'; | ||||
| import moment from 'moment'; | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| import RelatedTelemetry from './RelatedTelemetry/RelatedTelemetry'; | ||||
| import Compass from './Compass/Compass.vue'; | ||||
| @@ -219,6 +224,8 @@ const ZOOM_SCALE_DEFAULT = 1; | ||||
| const SHOW_THUMBS_THRESHOLD_HEIGHT = 200; | ||||
| const SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT = 600; | ||||
|  | ||||
| const IMAGE_CONTAINER_BORDER_WIDTH = 1; | ||||
|  | ||||
| export default { | ||||
|     name: 'ImageryView', | ||||
|     components: { | ||||
| @@ -281,10 +288,13 @@ export default { | ||||
|             }, | ||||
|             imageTranslateX: 0, | ||||
|             imageTranslateY: 0, | ||||
|             imageViewportWidth: 0, | ||||
|             imageViewportHeight: 0, | ||||
|             pan: undefined, | ||||
|             animateZoom: true, | ||||
|             imagePanned: false, | ||||
|             forceShowThumbnails: false | ||||
|             forceShowThumbnails: false, | ||||
|             animateThumbScroll: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -388,6 +398,12 @@ export default { | ||||
|  | ||||
|             return disabled; | ||||
|         }, | ||||
|         isComposedInLayout() { | ||||
|             return ( | ||||
|                 this.currentView?.objectPath | ||||
|                 && !this.openmct.router.isNavigatedObject(this.currentView.objectPath) | ||||
|             ); | ||||
|         }, | ||||
|         focusedImage() { | ||||
|             return this.imageHistory[this.focusedImageIndex]; | ||||
|         }, | ||||
| @@ -516,6 +532,23 @@ export default { | ||||
|             } | ||||
|  | ||||
|             return 'Alt drag to pan'; | ||||
|         }, | ||||
|         viewableArea() { | ||||
|             if (this.zoomFactor === 1) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             const imageWidth = this.sizedImageWidth * this.zoomFactor; | ||||
|             const imageHeight = this.sizedImageHeight * this.zoomFactor; | ||||
|             const xOffset = (imageWidth - this.imageViewportWidth) / 2; | ||||
|             const yOffset = (imageHeight - this.imageViewportHeight) / 2; | ||||
|  | ||||
|             return { | ||||
|                 widthRatio: this.imageViewportWidth / imageWidth, | ||||
|                 heightRatio: this.imageViewportHeight / imageHeight, | ||||
|                 xOffsetRatio: (xOffset - this.imageTranslateX * this.zoomFactor) / imageWidth, | ||||
|                 yOffsetRatio: (yOffset - this.imageTranslateY * this.zoomFactor) / imageHeight | ||||
|             }; | ||||
|         } | ||||
|     }, | ||||
|     watch: { | ||||
| @@ -548,10 +581,10 @@ export default { | ||||
|  | ||||
|                 if (!this.isPaused) { | ||||
|                     this.setFocusedImage(imageIndex); | ||||
|                     this.scrollToRight(); | ||||
|                 } else { | ||||
|                     this.scrollToFocused(); | ||||
|                 } | ||||
|  | ||||
|                 this.scrollHandler(); | ||||
|  | ||||
|             }, | ||||
|             deep: true | ||||
|         }, | ||||
| @@ -562,7 +595,7 @@ export default { | ||||
|             this.getImageNaturalDimensions(); | ||||
|         }, | ||||
|         bounds() { | ||||
|             this.scrollToFocused(); | ||||
|             this.scrollHandler(); | ||||
|         }, | ||||
|         isFixed(newValue) { | ||||
|             const isRealTime = !newValue; | ||||
| @@ -620,6 +653,8 @@ export default { | ||||
|  | ||||
|         this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this); | ||||
|         this.loadVisibleLayers(); | ||||
|         // // set after render so initial scroll event is skipped | ||||
|         setTimeout(this.setScrollBehavior, 3 * 1000); | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         this.persistVisibleLayers(); | ||||
| @@ -826,6 +861,13 @@ export default { | ||||
|             const disableScroll = scrollWidth > Math.ceil(scrollLeft + clientWidth); | ||||
|             this.autoScroll = !disableScroll; | ||||
|         }, | ||||
|         handlePauseButton(newState) { | ||||
|             this.paused(newState); | ||||
|             if (newState) { | ||||
|                 // need to set the focused index or the paused focus will drift | ||||
|                 this.thumbnailClicked(this.focusedImageIndex); | ||||
|             } | ||||
|         }, | ||||
|         paused(state) { | ||||
|             this.isPaused = Boolean(state); | ||||
|  | ||||
| @@ -833,38 +875,63 @@ export default { | ||||
|                 this.previousFocusedImage = null; | ||||
|                 this.setFocusedImage(this.nextImageIndex); | ||||
|                 this.autoScroll = true; | ||||
|                 this.scrollToRight(); | ||||
|                 this.scrollHandler(); | ||||
|             } | ||||
|         }, | ||||
|         scrollToFocused() { | ||||
|         async scrollToFocused() { | ||||
|             const thumbsWrapper = this.$refs.thumbsWrapper; | ||||
|             if (!thumbsWrapper) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             let domThumb = thumbsWrapper.children[this.focusedImageIndex]; | ||||
|  | ||||
|             if (domThumb) { | ||||
|                 domThumb.scrollIntoView({ | ||||
|                     behavior: 'smooth', | ||||
|                     block: 'center', | ||||
|                     inline: 'center' | ||||
|                 }); | ||||
|             } | ||||
|         }, | ||||
|         scrollToRight(type) { | ||||
|             if (type !== 'reset' && (this.isPaused || !this.$refs.thumbsWrapper || !this.autoScroll)) { | ||||
|             if (!domThumb) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const scrollWidth = this.$refs.thumbsWrapper.scrollWidth || 0; | ||||
|             // separate scrollTo function had to be implemented since scrollIntoView | ||||
|             // caused undesirable behavior in layouts | ||||
|             // and could not simply be scoped to the parent element | ||||
|             if (this.isComposedInLayout) { | ||||
|                 await Vue.nextTick(); | ||||
|                 const wrapperWidth = this.$refs.thumbsWrapper.clientWidth ?? 0; | ||||
|                 this.$refs.thumbsWrapper.scrollLeft = ( | ||||
|                     domThumb.offsetLeft - (wrapperWidth - domThumb.clientWidth) / 2); | ||||
|  | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             domThumb.scrollIntoView({ | ||||
|                 behavior: 'smooth', | ||||
|                 block: 'center', | ||||
|                 inline: 'center' | ||||
|             }); | ||||
|         }, | ||||
|         async scrollToRight() { | ||||
|  | ||||
|             const scrollWidth = this.$refs?.thumbsWrapper?.scrollWidth ?? 0; | ||||
|             if (!scrollWidth) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.$nextTick(() => { | ||||
|                 this.$refs.thumbsWrapper.scrollLeft = scrollWidth; | ||||
|             }); | ||||
|             await Vue.nextTick(); | ||||
|             this.$refs.thumbsWrapper.scrollLeft = scrollWidth; | ||||
|         }, | ||||
|         async scrollHandler() { | ||||
|             if (this.isPaused) { | ||||
|                 await this.scrollToFocused(); | ||||
|  | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (this.autoScroll) { | ||||
|  | ||||
|                 this.scrollToRight(); | ||||
|             } | ||||
|  | ||||
|         }, | ||||
|         setScrollBehavior(value = true) { | ||||
|             this.animateThumbScroll = value; | ||||
|         }, | ||||
|         matchIndexOfPreviousImage(previous, imageHistory) { | ||||
|             // match logic uses a composite of url and time to account | ||||
| @@ -1063,12 +1130,12 @@ export default { | ||||
|             } | ||||
|  | ||||
|             this.setSizedImageDimensions(); | ||||
|             this.setImageViewport(); | ||||
|             this.calculateViewHeight(); | ||||
|             this.scrollToFocused(); | ||||
|             this.scrollHandler(); | ||||
|         }, | ||||
|         setSizedImageDimensions() { | ||||
|             this.focusedImageNaturalAspectRatio = this.$refs.focusedImage.naturalWidth / this.$refs.focusedImage.naturalHeight; | ||||
|  | ||||
|             if ((this.imageContainerWidth / this.imageContainerHeight) > this.focusedImageNaturalAspectRatio) { | ||||
|                 // container is wider than image | ||||
|                 this.sizedImageWidth = this.imageContainerHeight * this.focusedImageNaturalAspectRatio; | ||||
| @@ -1079,6 +1146,17 @@ export default { | ||||
|                 this.sizedImageHeight = this.imageContainerWidth / this.focusedImageNaturalAspectRatio; | ||||
|             } | ||||
|         }, | ||||
|         setImageViewport() { | ||||
|             if (this.imageContainerHeight > this.sizedImageHeight + IMAGE_CONTAINER_BORDER_WIDTH) { | ||||
|                 // container is taller than wrapper | ||||
|                 this.imageViewportWidth = this.sizedImageWidth; | ||||
|                 this.imageViewportHeight = this.sizedImageHeight; | ||||
|             } else { | ||||
|                 // container is wider than wrapper | ||||
|                 this.imageViewportWidth = this.imageContainerWidth; | ||||
|                 this.imageViewportHeight = this.imageContainerHeight; | ||||
|             } | ||||
|         }, | ||||
|         handleThumbWindowResizeStart() { | ||||
|             if (!this.autoScroll) { | ||||
|                 return; | ||||
| @@ -1089,9 +1167,7 @@ export default { | ||||
|             this.handleThumbWindowResizeEnded(); | ||||
|         }, | ||||
|         handleThumbWindowResizeEnded() { | ||||
|             if (!this.isPaused) { | ||||
|                 this.scrollToRight('reset'); | ||||
|             } | ||||
|             this.scrollHandler(); | ||||
|  | ||||
|             this.calculateViewHeight(); | ||||
|  | ||||
| @@ -1104,7 +1180,6 @@ export default { | ||||
|         }, | ||||
|         wheelZoom(e) { | ||||
|             e.preventDefault(); | ||||
|  | ||||
|             this.$refs.imageControls.wheelZoom(e); | ||||
|         }, | ||||
|         startPan(e) { | ||||
|   | ||||
| @@ -194,6 +194,9 @@ | ||||
|         overflow-y: hidden; | ||||
|         margin-bottom: 1px; | ||||
|         padding-bottom: $interiorMarginSm; | ||||
|         &.animate-scroll { | ||||
|             scroll-behavior: smooth;  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     &__auto-scroll-resume-button { | ||||
| @@ -285,6 +288,13 @@ | ||||
|         flex: 0 0 auto; | ||||
|         padding: 2px 3px; | ||||
|     } | ||||
|  | ||||
|     &__viewable-area { | ||||
|         position: absolute; | ||||
|         border: 2px yellow solid; | ||||
|         left: 0; | ||||
|         top: 0; | ||||
|     } | ||||
| } | ||||
|  | ||||
| .is-small-thumbs { | ||||
|   | ||||
| @@ -481,19 +481,16 @@ describe("The Imagery View Layouts", () => { | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|         it ('scrollToRight is called when clicking on auto scroll button', (done) => { | ||||
|             Vue.nextTick(() => { | ||||
|                 // use spyon to spy the scroll function | ||||
|                 spyOn(imageryView._getInstance().$refs.ImageryContainer, 'scrollToRight'); | ||||
|                 imageryView._getInstance().$refs.ImageryContainer.autoScroll = false; | ||||
|                 Vue.nextTick(() => { | ||||
|                     parent.querySelector('.c-imagery__auto-scroll-resume-button').click(); | ||||
|                     expect(imageryView._getInstance().$refs.ImageryContainer.scrollToRight).toHaveBeenCalledWith('reset'); | ||||
|                     done(); | ||||
|                 }); | ||||
|             }); | ||||
|         it ('scrollToRight is called when clicking on auto scroll button', async () => { | ||||
|             await Vue.nextTick(); | ||||
|             // use spyon to spy the scroll function | ||||
|             spyOn(imageryView._getInstance().$refs.ImageryContainer, 'scrollHandler'); | ||||
|             imageryView._getInstance().$refs.ImageryContainer.autoScroll = false; | ||||
|             await Vue.nextTick(); | ||||
|             parent.querySelector('.c-imagery__auto-scroll-resume-button').click(); | ||||
|             expect(imageryView._getInstance().$refs.ImageryContainer.scrollHandler); | ||||
|         }); | ||||
|         xit('should change the image zoom factor when using the zoom buttons', async (done) => { | ||||
|         xit('should change the image zoom factor when using the zoom buttons', async () => { | ||||
|             await Vue.nextTick(); | ||||
|             let imageSizeBefore; | ||||
|             let imageSizeAfter; | ||||
| @@ -512,7 +509,6 @@ describe("The Imagery View Layouts", () => { | ||||
|             imageSizeAfter = parent.querySelector('.c-imagery_main-image_background-image').getBoundingClientRect(); | ||||
|             expect(imageSizeAfter.height).toBeLessThan(imageSizeBefore.height); | ||||
|             expect(imageSizeAfter.width).toBeLessThan(imageSizeBefore.width); | ||||
|             done(); | ||||
|         }); | ||||
|         xit('should reset the zoom factor on the image when clicking the zoom button', async (done) => { | ||||
|             await Vue.nextTick(); | ||||
| @@ -529,6 +525,19 @@ describe("The Imagery View Layouts", () => { | ||||
|             done(); | ||||
|         }); | ||||
|  | ||||
|         it('should display the viewable area when zoom factor is greater than 1', async () => { | ||||
|             await Vue.nextTick(); | ||||
|             expect(parent.querySelectorAll('.c-thumb__viewable-area').length).toBe(0); | ||||
|  | ||||
|             parent.querySelector('.t-btn-zoom-in').click(); | ||||
|             await Vue.nextTick(); | ||||
|             expect(parent.querySelectorAll('.c-thumb__viewable-area').length).toBe(1); | ||||
|  | ||||
|             parent.querySelector('.t-btn-zoom-reset').click(); | ||||
|             await Vue.nextTick(); | ||||
|             expect(parent.querySelectorAll('.c-thumb__viewable-area').length).toBe(0); | ||||
|         }); | ||||
|  | ||||
|         it('should reset the brightness and contrast when clicking the reset button', async () => { | ||||
|             const viewInstance = imageryView._getInstance(); | ||||
|             await Vue.nextTick(); | ||||
|   | ||||
| @@ -37,14 +37,15 @@ function myItemsInterceptor(openmct, identifierObject, name) { | ||||
|             return identifier.key === MY_ITEMS_KEY; | ||||
|         }, | ||||
|         invoke: (identifier, object) => { | ||||
|             if (openmct.objects.isMissing(object)) { | ||||
|             if (!object || openmct.objects.isMissing(object)) { | ||||
|                 openmct.objects.save(myItemsModel); | ||||
|  | ||||
|                 return myItemsModel; | ||||
|             } | ||||
|  | ||||
|             return object; | ||||
|         } | ||||
|         }, | ||||
|         priority: openmct.priority.HIGH | ||||
|     }; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -889,37 +889,21 @@ export default { | ||||
|             this.syncUrlWithPageAndSection(); | ||||
|             this.filterAndSortEntries(); | ||||
|         }, | ||||
|         activeTransaction() { | ||||
|             return this.openmct.objects.getActiveTransaction(); | ||||
|         }, | ||||
|         startTransaction() { | ||||
|             if (!this.openmct.editor.isEditing()) { | ||||
|                 this.openmct.objects.startTransaction(); | ||||
|             if (!this.openmct.objects.isTransactionActive()) { | ||||
|                 this.transaction = this.openmct.objects.startTransaction(); | ||||
|             } | ||||
|         }, | ||||
|         saveTransaction() { | ||||
|             const transaction = this.activeTransaction(); | ||||
|  | ||||
|             if (!transaction || this.openmct.editor.isEditing()) { | ||||
|                 return; | ||||
|         async saveTransaction() { | ||||
|             if (this.transaction !== undefined) { | ||||
|                 await this.transaction.commit(); | ||||
|                 this.openmct.objects.endTransaction(); | ||||
|             } | ||||
|  | ||||
|             return transaction.commit() | ||||
|                 .catch(error => { | ||||
|                     throw error; | ||||
|                 }).finally(() => { | ||||
|                     this.openmct.objects.endTransaction(); | ||||
|                 }); | ||||
|         }, | ||||
|         cancelTransaction() { | ||||
|             if (!this.openmct.editor.isEditing()) { | ||||
|                 const transaction = this.activeTransaction(); | ||||
|                 transaction.cancel() | ||||
|                     .catch(error => { | ||||
|                         throw error; | ||||
|                     }).finally(() => { | ||||
|                         this.openmct.objects.endTransaction(); | ||||
|                     }); | ||||
|         async cancelTransaction() { | ||||
|             if (this.transaction !== undefined) { | ||||
|                 await this.transaction.cancel(); | ||||
|                 this.openmct.objects.endTransaction(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -31,8 +31,8 @@ export default class OpenInNewTab { | ||||
|  | ||||
|         this._openmct = openmct; | ||||
|     } | ||||
|     invoke(objectPath) { | ||||
|         let url = objectPathToUrl(this._openmct, objectPath); | ||||
|     invoke(objectPath, urlParams = undefined) { | ||||
|         let url = objectPathToUrl(this._openmct, objectPath, urlParams); | ||||
|         window.open(url); | ||||
|     } | ||||
| } | ||||
|   | ||||