diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcd1504ef0..e726007800 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -291,7 +291,7 @@ checklist.) 1. Changes address original issue? 2. Unit tests included and/or updated with changes? 3. Command line build passes? -4. Expect to pass code review? +4. Changes have been smoke-tested? ### Reviewer Checklist diff --git a/docs/src/guide/index.md b/docs/src/guide/index.md index b7c999bc20..8451210d35 100644 --- a/docs/src/guide/index.md +++ b/docs/src/guide/index.md @@ -941,6 +941,12 @@ look at field (see below) to determine which field in the model should be modified. * `ngRequired`: True if input is required. * `ngPattern`: The pattern to match against (for text entry) +* `ngBlur`: A function that may be invoked to evaluate the expression + associated with the `ng-blur` attribute associated with the control. + * This should be called when the control has lost focus; for controls + which simply wrap or augment `input` elements, this should be fired + on `blur` events associated with those elements, while more complex + custom controls may fire this at the end of more specific interactions. * `options`: The options for this control, as passed from the `options` property of an individual row definition. * `field`: Name of the field in `ngModel` which will hold the value for this diff --git a/docs/src/process/cycle.md b/docs/src/process/cycle.md new file mode 100644 index 0000000000..e872044078 --- /dev/null +++ b/docs/src/process/cycle.md @@ -0,0 +1,161 @@ +# Development Cycle + +Development of Open MCT Web occurs on an iterative cycle of +sprints and releases. + +* A _sprint_ is three weeks in duration, and represents a + set of improvements that can be completed and tested by the + development team. Software at the end of the sprint is + "semi-stable"; it will have undergone reduced testing and may carry + defects or usability issues of lower severity, particularly if + there are workarounds. +* A _release_ occurs every four sprints. Releases are stable, and + will have undergone full acceptance testing to ensure that the + software behaves correctly and usably. + +## Roles + +The sprint process assumes the presence of a __project manager.__ +The project manager is responsible for +making tactical decisions about what development work will be +performed, and for coordinating with stakeholders to arrive at +higher-level strategic decisions about desired functionality +and characteristics of the software, major external milestones, +and so forth. + +In the absence of a dedicated project manager, this role may be rotated +among members of the development team on a per-sprint basis. + +Responsibilities of the project manager including: + +* Maintaining (with agreement of stakeholders) a "road map" of work + planned for future releases/sprints; this should be higher-level, + usually expressed as "themes", + with just enough specificity to gauge feasibility of plans, + relate work back to milestones, and identify longer-term + dependencies. +* Determining (with assistance from the rest of the team) which + issues to work on in a given sprint and how they shall be + assigned. +* Pre-planning subsequent sprints to ensure that all members of the + team always have a clear direction. +* Scheduling and/or ensuring adherence to + [process points](#process-points). +* Responding to changes within the sprint (shifting priorities, + new issues) and re-allocating work for the sprint as needed. + +## Sprint Calendar + +Certain [process points](#process-points) are regularly scheduled in +the sprint cycle. + +### Sprints by Release + +Allocation of work among sprints should be planned relative to release +goals and milestones. As a general guideline, higher-risk work (large +new features which may carry new defects, major refactoring, design +changes with uncertain effects on usability) should be allocated to +earlier sprints, allowing for time in later sprints to ensure stability. + +| Sprint | Focus | +|:------:|:--------------------------------------------------------| +| __1__ | Prototyping, design, experimentation. | +| __2__ | New features, refinements, enhancements. | +| __3__ | Feature completion, low-risk enhancements, bug fixing. | +| __4__ | Stability & quality assurance. | + +### Sprints 1-3 + +The first three sprints of a release are primarily centered around +development work, with regular acceptance testing in the third +week. During this third week, the top priority should be passing +acceptance testing (e.g. by resolving any blockers found); any +resources not needed for this effort should be used to begin work +for the subsequent sprint. + +| Week | Mon | Tue | Wed | Thu | Fri | +|:-----:|:-------------------------:|:------:|:---:|:----------------------------:|:-----------:| +| __1__ | Sprint plan | Tag-up | | | | +| __2__ | | Tag-up | | | Code freeze | +| __3__ | Per-sprint testing | Triage | | _Per-sprint testing*_ | Ship | + +* If necessary. + +### Sprint 4 + +The software must be stable at the end of the fourth sprint; because of +this, the fourth sprint is scheduled differently, with a heightened +emphasis on testing. + +| Week | Mon | Tue | Wed | Thu | Fri | +|-------:|:-------------------------:|:------:|:---:|:----------------------------:|:-----------:| +| __1__ | Sprint plan | Tag-up | | | Code freeze | +| __2__ | Per-release testing | Triage | | | | +| __3__ | _Per-release testing*_ | Triage | | _Per-release testing*_ | Ship | + +* If necessary. + +## Process Points + +* __Sprint plan.__ Project manager allocates issues based on + theme(s) for sprint, then reviews with team. Each team member + should have roughly two weeks of work allocated (to allow time + in the third week for testing of work completed.) + * Project manager should also sketch out subsequent sprint so + that team may begin work for that sprint during the + third week, since testing and blocker resolution is unlikely + to require all available resources. +* __Tag-up.__ Check in and status update among development team. + May amend plan for sprint as-needed. +* __Code freeze.__ Any new work from this sprint + (features, bug fixes, enhancements) must be integrated by the + end of the second week of the sprint. After code freeze + (and until the end of the sprint) the only changes that should be + merged into the master branch should directly address issues + needed to pass acceptance testing. +* [__Per-release Testing.__](testing/plan.md#per-release-testing) + Structured testing with predefined + success criteria. No release should ship without passing + acceptance tests. Time is allocated in each sprint for subsequent + rounds of acceptance testing if issues are identified during a + prior round. Specific details of acceptance testing need to be + agreed-upon with relevant stakeholders and delivery recipients, + and should be flexible enough to allow changes to plans + (e.g. deferring delivery of some feature in order to ensure + stability of other features.) Baseline testing includes: + * [__Testathon.__](testing/plan.md#user-testing) + Multi-user testing, involving as many users as + is feasible, plus development team. Open-ended; should verify + completed work from this sprint, test exploratorily for + regressions, et cetera. + * [__Long-Duration Test.__](testing/plan.md#long-duration-testing) A + test to verify that the software remains + stable after running for longer durations. May include some + combination of automated testing and user verification (e.g. + checking to verify that software remains subjectively + responsive at conclusion of test.) + * [__Unit Testing.__](testing/plan.md#unit-testing) + Automated testing integrated into the + build. (These tests are verified to pass more often than once + per sprint, as they run before any merge to master, but still + play an important role in per-release testing.) +* [__Per-sprint Testing.__](testing/plan.md#per-sprint-testing) + Subset of Pre-release Testing + which should be performed before shipping at the end of any + sprint. Time is allocated for a second round of + Pre-release Testing if the first round is not passed. +* __Triage.__ Team reviews issues from acceptance testing and uses + success criteria to determine whether or not they should block + release, then formulates a plan to address these issues before + the next round of acceptance testing. Focus here should be on + ensuring software passes that testing in order to ship on time; + may prefer to disable malfunctioning components and fix them + in a subsequent sprint, for example. +* __Ship.__ Tag a code snapshot that has passed acceptance + testing and deploy that version. (Only true if acceptance + testing has passed by this point; if acceptance testing has not + been passed, will need to make ad hoc decisions with stakeholders, + e.g. "extend the sprint" or "defer shipment until end of next + sprint.") + + diff --git a/docs/src/process/index.md b/docs/src/process/index.md index 4a39513a91..61a22ed6a0 100644 --- a/docs/src/process/index.md +++ b/docs/src/process/index.md @@ -1,156 +1,13 @@ -# Development Cycle - -Development of Open MCT Web occurs on an iterative cycle of -sprints and releases. - -* A _sprint_ is three weeks in duration, and represents a - set of improvements that can be completed and tested by the - development team. Software at the end of the sprint is - "semi-stable"; it will have undergone reduced testing and may carry - defects or usability issues of lower severity, particularly if - there are workarounds. -* A _release_ occurs every four sprints. Releases are stable, and - will have undergone full acceptance testing to ensure that the - software behaves correctly and usably. - -## Roles - -The sprint process assumes the presence of a __project manager.__ -The project manager is responsible for -making tactical decisions about what development work will be -performed, and for coordinating with stakeholders to arrive at -higher-level strategic decisions about desired functionality -and characteristics of the software, major external milestones, -and so forth. - -In the absence of a dedicated project manager, this role may be rotated -among members of the development team on a per-sprint basis. - -Responsibilities of the project manager including: - -* Maintaining (with agreement of stakeholders) a "road map" of work - planned for future releases/sprints; this should be higher-level, - usually expressed as "themes", - with just enough specificity to gauge feasibility of plans, - relate work back to milestones, and identify longer-term - dependencies. -* Determining (with assistance from the rest of the team) which - issues to work on in a given sprint and how they shall be - assigned. -* Pre-planning subsequent sprints to ensure that all members of the - team always have a clear direction. -* Scheduling and/or ensuring adherence to - [process points](#process-points). -* Responding to changes within the sprint (shifting priorities, - new issues) and re-allocating work for the sprint as needed. - -## Sprint Calendar - -Certain [process points](#process-points) are regularly scheduled in -the sprint cycle. - -### Sprints by Release - -Allocation of work among sprints should be planned relative to release -goals and milestones. As a general guideline, higher-risk work (large -new features which may carry new defects, major refactoring, design -changes with uncertain effects on usability) should be allocated to -earlier sprints, allowing for time in later sprints to ensure stability. - -| Sprint | Focus | -|:------:|:--------------------------------------------------------| -| __1__ | Prototyping, design, experimentation. | -| __2__ | New features, refinements, enhancements. | -| __3__ | Feature completion, low-risk enhancements, bug fixing. | -| __4__ | Stability & quality assurance. | - -### Sprints 1-3 - -The first three sprints of a release are primarily centered around -development work, with regular acceptance testing in the third -week. During this third week, the top priority should be passing -acceptance testing (e.g. by resolving any blockers found); any -resources not needed for this effort should be used to begin work -for the subsequent sprint. - -| Week | Mon | Tue | Wed | Thu | Fri | -|:-----:|:-------------------------:|:------:|:---:|:----------------------------:|:-----------:| -| __1__ | Sprint plan | Tag-up | | | | -| __2__ | | Tag-up | | | Code freeze | -| __3__ | Sprint acceptance testing | Triage | | _Sprint acceptance testing*_ | Ship | - -* If necessary. - -### Sprint 4 - -The software must be stable at the end of the fourth sprint; because of -this, the fourth sprint is scheduled differently, with a heightened -emphasis on testing. - -| Week | Mon | Tue | Wed | Thu | Fri | -|-------:|:-------------------------:|:------:|:---:|:----------------------------:|:-----------:| -| __1__ | Sprint plan | Tag-up | | | Code freeze | -| __2__ | Acceptance testing | Triage | | | | -| __3__ | _Acceptance testing*_ | Triage | | _Acceptance testing*_ | Ship | - -* If necessary. - -## Process Points - -* __Sprint plan.__ Project manager allocates issues based on - theme(s) for sprint, then reviews with team. Each team member - should have roughly two weeks of work allocated (to allow time - in the third week for testing of work completed.) - * Project manager should also sketch out subsequent sprint so - that team may begin work for that sprint during the - third week, since testing and blocker resolution is unlikely - to require all available resources. -* __Tag-up.__ Check in and status update among development team. - May amend plan for sprint as-needed. -* __Code freeze.__ Any new work from this sprint - (features, bug fixes, enhancements) must be integrated by the - end of the second week of the sprint. After code freeze - (and until the end of the sprint) the only changes that should be - merged into the master branch should directly address issues - needed to pass acceptance testing. -* __Acceptance Testing.__ Structured testing with predefined - success criteria. No release should ship without passing - acceptance tests. Time is allocated in each sprint for subsequent - rounds of acceptance testing if issues are identified during a - prior round. Specific details of acceptance testing need to be - agreed-upon with relevant stakeholders and delivery recipients, - and should be flexible enough to allow changes to plans - (e.g. deferring delivery of some feature in order to ensure - stability of other features.) Baseline testing includes: - * __Testathon.__ Multi-user testing, involving as many users as - is feasible, plus development team. Open-ended; should verify - completed work from this sprint, test exploratorily for - regressions, et cetera. - * __24-Hour Test.__ A test to verify that the software remains - stable after running for longer durations. May include some - combination of automated testing and user verification (e.g. - checking to verify that software remains subjectively - responsive at conclusion of test.) - * __Automated Testing.__ Automated testing integrated into the - build. (These tests are verified to pass more often than once - per sprint, as they run before any merge to master, but still - play an important role in acceptance testing.) -* __Sprint Acceptance Testing.__ Subset of Acceptance Testing - which should be performed before shipping at the end of any - sprint. Time is allocated for a second round of - Sprint Acceptance Testing if the first round is not passed. -* __Triage.__ Team reviews issues from acceptance testing and uses - success criteria to determine whether or not they should block - release, then formulates a plan to address these issues before - the next round of acceptance testing. Focus here should be on - ensuring software passes that testing in order to ship on time; - may prefer to disable malfunctioning components and fix them - in a subsequent sprint, for example. -* __Ship.__ Tag a code snapshot that has passed acceptance - testing and deploy that version. (Only true if acceptance - testing has passed by this point; if acceptance testing has not - been passed, will need to make ad hoc decisions with stakeholders, - e.g. "extend the sprint" or "defer shipment until end of next - sprint.") +# Development Process +The process used to develop Open MCT Web is described in the following +documents: +* [Development Cycle](cycle.md): Describes how and when specific + process points are repeated during development. +* Testing is described in two documents: + * The [Test Plan](testing/plan.md) summarizes the approaches used + to test Open MCT Web. + * The [Test Procedures](testing/procedures.md) document what + specific tests are performed to verify correctness, and how + they should be carried out. diff --git a/docs/src/process/testing/plan.md b/docs/src/process/testing/plan.md new file mode 100644 index 0000000000..fead5f5a50 --- /dev/null +++ b/docs/src/process/testing/plan.md @@ -0,0 +1,127 @@ +# Test Plan + +## Test Levels + +Testing for Open MCT Web includes: + +* _Smoke testing_: Brief, informal testing to verify that no major issues + or regressions are present in the software, or in specific features of + the software. +* _Unit testing_: Automated verification of the performance of individual + software components. +* _User testing_: Testing with a representative user base to verify + that application behaves usably and as specified. +* _Long-duration testing_: Testing which takes place over a long period + of time to detect issues which are not readily noticeable during + shorter test periods. + +### Smoke Testing + +Manual, non-rigorous testing of the software and/or specific features +of interest. Verifies that the software runs and that basic functionality +is present. + +### Unit Testing + +Unit tests are automated tests which exercise individual software +components. Tests are subject to code review along with the actual +implementation, to ensure that tests are applicable and useful. + +Unit tests should meet +[test standards](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#test-standards) +as described in the contributing guide. + +### User Testing + +User testing is performed at scheduled times involving target users +of the software or reasonable representatives, along with members of +the development team exercising known use cases. Users test the +software directly; the software should be configured as similarly to +its planned production configuration as is feasible without introducing +other risks (e.g. damage to data in a production instance.) + +User testing will focus on the following activities: + +* Verifying issues resolved since the last test session. +* Checking for regressions in areas related to recent changes. +* Using major or important features of the software, + as determined by the user. +* General "trying to break things." + +During user testing, users will +[report issues](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#issue-reporting) +as they are encountered. + +Desired outcomes of user testing are: + +* Identified software defects. +* Areas for usability improvement. +* Feature requests (particularly missed requirements.) +* Recorded issue verification. + +### Long-duration Testing + +Long-duration testing occurs over a twenty-four hour period. The +software is run in one or more stressing cases representative of expected +usage. After twenty-four hours, the software is evaluated for: + +* Performance metrics: Have memory usage or CPU utilization increased + during this time period in unexpected or undesirable ways? +* Subjective usability: Does the software behave in the same way it did + at the start of the test? Is it as responsive? + +Any defects or unexpected behavior identified during testing should be +[reported as issues](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#issue-reporting) +and reviewed for severity. + +## Test Performance + +Tests are performed at various levels of frequency. + +* _Per-merge_: Performed before any new changes are integrated into + the software. +* _Per-sprint_: Performed at the end of every [sprint](../cycle.md). +* _Per-release_: Performed at the end of every [release](../cycle.md). + +### Per-merge Testing + +Before changes are merged, the author of the changes must perform: + +* _Smoke testing_ (both generally, and for areas which interact with + the new changes.) +* _Unit testing_ (as part of the automated build step.) + +Changes are not merged until the author has affirmed that both +forms of testing have been performed successfully; this is documented +by the [Author Checklist](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#author-checklist). + +### Per-sprint Testing + +Before a sprint is closed, the development team must additionally +perform: + +* A relevant subset of [_user testing_](procedures.md#user-test-procedures) + identified by the acting [project manager](../cycle.md#roles). +* [_Long-duration testing_](procedures.md#long-duration-testng) + (specifically, for 24 hours.) + +Issues are reported as a product of both forms of testing. + +A sprint is not closed until both categories have been performed on +the latest snapshot of the software, _and_ no issues labelled as +["blocker"](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#issue-reporting) +remain open. + +### Per-release Testing + +As [per-sprint testing](#per-sprint-testing), except that _user testing_ +should cover all test cases, with less focus on changes from the specific +sprint or release. + +Per-release testing should also include any acceptance testing steps +agreed upon with recipients of the software. + +A release is not closed until both categories have been performed on +the latest snapshot of the software, _and_ no issues labelled as +["blocker" or "critical"](https://github.com/nasa/openmctweb/blob/master/CONTRIBUTING.md#issue-reporting) +remain open. diff --git a/docs/src/process/testing/procedures.md b/docs/src/process/testing/procedures.md new file mode 100644 index 0000000000..b33f88c6d1 --- /dev/null +++ b/docs/src/process/testing/procedures.md @@ -0,0 +1,169 @@ +# Test Procedures + +## Introduction + +This document is intended to be used: + +* By testers, to verify that Open MCT Web behaves as specified. +* By the development team, to document new test cases and to provide + guidance on how to author these. + +## Writing Procedures + +### Template + +Procedures for individual tests should use the following template, +adapted from [https://swehb.nasa.gov/display/7150/SWE-114](). + +Property | Value +---------------|--------------------------------------------------------------- +Test ID | +Relevant reqs. | +Prerequisites | +Test input | +Instructions | +Expectation | +Eval. criteria | + +For multi-line descriptions, use an asterisk or similar indicator to refer +to a longer-form description below. + +#### Example Procedure - Edit a Layout + +Property | Value +---------------|--------------------------------------------------------------- +Test ID | MCT-TEST-000X - Edit a layout +Relevant reqs. | MCT-EDIT-000Y +Prerequisites | Create a layout, as in MCT-TEST-000Z +Test input | Domain object database XYZ +Instructions | See below * +Expectation | Change to editing context † +Eval. criteria | Visual inspection + +* Follow the following steps: + +1. Verify that the created layout is currently navigated-to, + as in MCT-TEST-00ZZ. +2. Click the Edit button, identified by a pencil icon and the text "Edit" + displayed on hover. + +† Right-hand viewing area should be surrounded by a dashed +blue border when a domain object is being edited. + +### Guidelines + +Test procedures should be written assuming minimal prior knowledge of the +application: Non-standard terms should only be used when they are documented +in [the glossary](#glossary), and shorthands used for user actions should +be accompanied by useful references to test procedures describing those +actions (when available) or descriptions in user documentation. + +Test cases should be narrow in scope; if a list of steps is excessively +long (or must be written vaguely to be kept short) it should be broken +down into multiple tests which reference one another. + +All requirements satisfied by Open MCT Web should be verifiable using +one or more test procedures. + +## Glossary + +This section will contain terms used in test procedures. This may link to +a common glossary, to avoid replication of content. + +## Procedures + +This section will contain specific test procedures. Presently, procedures +are placeholders describing general patterns for setting up and conducting +testing. + +### User Testing Setup + +These procedures describes a general pattern for setting up for user +testing. Specific deployments should customize this pattern with +relevant data and any additional steps necessary. + +Property | Value +---------------|--------------------------------------------------------------- +Test ID | MCT-TEST-SETUP0 - User Testing Setup +Relevant reqs. | TBD +Prerequisites | Build of relevant components +Test input | Exemplary database; exemplary telemetry data set +Instructions | See below +Expectation | Able to load application in a web browser (Google Chrome) +Eval. criteria | Visual inspection + +Instructions: + +1. Start telemetry server. +2. Start ElasticSearch. +3. Restore database snapshot to ElasticSearch. +4. Start telemetry playback. +5. Start HTTP server for client sources. + +### User Test Procedures + +Specific user test cases have not yet been authored. In their absence, +user testing is conducted by: + +* Reviewing the text of issues from the issue tracker to understand the + desired behavior, and exercising this behavior in the running application. + (For instance, by following steps to reproduce from the original issue.) + * Issues which appear to be resolved should be marked as such with comments + on the original issue (e.g. "verified during user testing MM/DD/YYYY".) + * Issues which appear not to have been resolved should be reopened with an + explanation of what unexpected behavior has been observed. + * In cases where an issue appears resolved as-worded but other related + undesirable behavior is observed during testing, a new issue should be + opened, and linked to from a comment in the original issues. +* General usage of new features and/or existing features which have undergone + recent changes. Defects or problems with usability should be documented + by filing issues in the issue tracker. +* Open-ended testing to discover defects, identify usability issues, and + generate feature requests. + +### Long-Duration Testing + +The purpose of long-duration testing is to identify performance issues +and/or other defects which are sensitive to the amount of time the +application is kept running. (Memory leaks, for instance.) + +Property | Value +---------------|--------------------------------------------------------------- +Test ID | MCT-TEST-LDT0 - Long-duration Testing +Relevant reqs. | TBD +Prerequisites | MCT-TEST-SETUP0 +Test input | (As for test setup.) +Instructions | See "Instructions" below * +Expectation | See "Expectations" below † +Eval. criteria | Visual inspection + +* Instructions: + +1. Start `top` or a similar tool to measure CPU usage and memory utilization. +2. Open several user-created displays (as many as would be realistically + opened during actual usage in a stressing case) in some combination of + separate tabs and windows (approximately as many tabs-per-window as + total windows.) +3. Ensure that playback data is set to run continuously for at least 24 hours + (e.g. on a loop.) +4. Record CPU usage and memory utilization. +5. In at least one tab, try some general user interface gestures and make + notes about the subjective experience of using the application. (Particularly, + the degree of responsiveness.) +6. Leave client displays open for 24 hours. +7. Record CPU usage and memory utilization again. +8. Make additional notes about the subjective experience of using the + application (again, particularly responsiveness.) +9. Check logs for any unexpected warnings or errors. + +† Expectations: + +* At the end of the test, CPU usage and memory usage should both be similar + to their levels at the start of the test. +* At the end of the test, subjective usage of the application should not + be observably different from the way it was at the start of the test. + (In particular, responsiveness should not decrease.) +* Logs should not contain any unexpected warnings or errors ("expected" + warnings or errors are those that have been documented and prioritized + as known issues, or those that are explained by transient conditions + external to the software, such as network outages.) \ No newline at end of file diff --git a/example/profiling/src/DigestIndicator.js b/example/profiling/src/DigestIndicator.js index 02fbc7a08b..f19f28b548 100644 --- a/example/profiling/src/DigestIndicator.js +++ b/example/profiling/src/DigestIndicator.js @@ -39,8 +39,11 @@ define( start = Date.now(); function update() { - var secs = (Date.now() - start) / 1000; + var now = Date.now(), + secs = (now - start) / 1000; displayed = Math.round(digests / secs); + start = now; + digests = 0; } function increment() { diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index 5cb4b09502..d1f2d0f964 100644 --- a/platform/commonUI/browse/bundle.json +++ b/platform/commonUI/browse/bundle.json @@ -105,6 +105,12 @@ "implementation": "navigation/NavigationService.js" } ], + "policies": [ + { + "implementation": "creation/CreationPolicy.js", + "category": "creation" + } + ], "actions": [ { "key": "navigate", diff --git a/platform/commonUI/browse/src/creation/CreateActionProvider.js b/platform/commonUI/browse/src/creation/CreateActionProvider.js index 2624dc0961..14db1c8656 100644 --- a/platform/commonUI/browse/src/creation/CreateActionProvider.js +++ b/platform/commonUI/browse/src/creation/CreateActionProvider.js @@ -68,7 +68,7 @@ define( // Introduce one create action per type return this.typeService.listTypes().filter(function (type) { - return type.hasFeature("creation"); + return self.policyService.allow("creation", type); }).map(function (type) { return new CreateAction( type, diff --git a/platform/commonUI/browse/src/creation/CreationPolicy.js b/platform/commonUI/browse/src/creation/CreationPolicy.js new file mode 100644 index 0000000000..28749e711f --- /dev/null +++ b/platform/commonUI/browse/src/creation/CreationPolicy.js @@ -0,0 +1,45 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * A policy for determining whether objects of a given type can be + * created. + * @constructor + * @implements {Policy} + * @memberof platform/commonUI/browse + */ + function CreationPolicy() { + } + + CreationPolicy.prototype.allow = function (type) { + return type.hasFeature("creation"); + }; + + return CreationPolicy; + } +); \ No newline at end of file diff --git a/platform/commonUI/browse/test/creation/CreateActionProviderSpec.js b/platform/commonUI/browse/test/creation/CreateActionProviderSpec.js index 431f6101c2..fdba091623 100644 --- a/platform/commonUI/browse/test/creation/CreateActionProviderSpec.js +++ b/platform/commonUI/browse/test/creation/CreateActionProviderSpec.js @@ -33,6 +33,9 @@ define( var mockTypeService, mockDialogService, mockCreationService, + mockPolicyService, + mockCreationPolicy, + mockPolicyMap = {}, mockTypes, provider; @@ -67,14 +70,32 @@ define( "creationService", [ "createObject" ] ); + mockPolicyService = jasmine.createSpyObj( + "policyService", + [ "allow" ] + ); + mockTypes = [ "A", "B", "C" ].map(createMockType); + mockTypes.forEach(function(type){ + mockPolicyMap[type.getName()] = true; + }); + + mockCreationPolicy = function(type){ + return mockPolicyMap[type.getName()]; + }; + + mockPolicyService.allow.andCallFake(function(category, type){ + return category === "creation" && mockCreationPolicy(type) ? true : false; + }); + mockTypeService.listTypes.andReturn(mockTypes); provider = new CreateActionProvider( mockTypeService, mockDialogService, - mockCreationService + mockCreationService, + mockPolicyService ); }); @@ -94,15 +115,15 @@ define( it("does not expose non-creatable types", function () { // One of the types won't have the creation feature... - mockTypes[1].hasFeature.andReturn(false); + mockPolicyMap[mockTypes[0].getName()] = false; // ...so it should have been filtered out. expect(provider.getActions({ key: "create", domainObject: {} }).length).toEqual(2); // Make sure it was creation which was used to check - expect(mockTypes[1].hasFeature) - .toHaveBeenCalledWith("creation"); + expect(mockPolicyService.allow) + .toHaveBeenCalledWith("creation", mockTypes[0]); }); }); } diff --git a/platform/commonUI/browse/test/creation/CreationPolicySpec.js b/platform/commonUI/browse/test/creation/CreationPolicySpec.js new file mode 100644 index 0000000000..1f88c1b149 --- /dev/null +++ b/platform/commonUI/browse/test/creation/CreationPolicySpec.js @@ -0,0 +1,53 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/creation/CreationPolicy"], + function (CreationPolicy) { + "use strict"; + + describe("The creation policy", function () { + var mockType, + policy; + + beforeEach(function () { + mockType = jasmine.createSpyObj( + 'type', + ['hasFeature'] + ); + + policy = new CreationPolicy(); + }); + + it("allows creation of types with the creation feature", function () { + mockType.hasFeature.andReturn(true); + expect(policy.allow(mockType)).toBeTruthy(); + }); + + it("disallows creation of types without the creation feature", function () { + mockType.hasFeature.andReturn(false); + expect(policy.allow(mockType)).toBeFalsy(); + }); + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/browse/test/suite.json b/platform/commonUI/browse/test/suite.json index b9292b6ef1..70e62bcfa6 100644 --- a/platform/commonUI/browse/test/suite.json +++ b/platform/commonUI/browse/test/suite.json @@ -8,6 +8,7 @@ "creation/CreateMenuController", "creation/CreateWizard", "creation/CreationService", + "creation/CreationPolicy", "creation/LocatorController", "navigation/NavigateAction", "navigation/NavigationService", diff --git a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js index 42b08c72b1..92d29f66ad 100644 --- a/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditablePersistenceCapability.js @@ -50,7 +50,7 @@ define( // Simply trigger refresh of in-view objects; do not // write anything to database. persistence.persist = function () { - cache.markDirty(editableObject); + return cache.markDirty(editableObject); }; // Delegate refresh to the original object; this avoids refreshing diff --git a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js index a468b5283c..21e25eabfb 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js @@ -115,6 +115,7 @@ define( */ EditableDomainObjectCache.prototype.markDirty = function (domainObject) { this.dirtyObjects[domainObject.getId()] = domainObject; + return this.$q.when(true); }; /** diff --git a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js index f442bf106b..7fa0ac0dd7 100644 --- a/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js +++ b/platform/commonUI/edit/test/capabilities/EditablePersistenceCapabilitySpec.js @@ -31,6 +31,7 @@ define( mockEditableObject, mockDomainObject, mockCache, + mockPromise, capability; beforeEach(function () { @@ -50,7 +51,9 @@ define( "cache", [ "markDirty" ] ); + mockPromise = jasmine.createSpyObj("promise", ["then"]); + mockCache.markDirty.andReturn(mockPromise); mockDomainObject.getCapability.andReturn(mockPersistence); capability = new EditablePersistenceCapability( @@ -84,6 +87,10 @@ define( expect(mockPersistence.refresh).toHaveBeenCalled(); }); + it("returns a promise from persist", function () { + expect(capability.persist().then).toEqual(jasmine.any(Function)); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index 1f76c286cc..46952947db 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -19,6 +19,10 @@ { "implementation": "StyleSheetLoader.js", "depends": [ "stylesheets[]", "$document", "THEME" ] + }, + { + "implementation": "UnsupportedBrowserWarning.js", + "depends": [ "notificationService", "agentService" ] } ], "stylesheets": [ diff --git a/platform/commonUI/general/res/sass/mobile/_constants.scss b/platform/commonUI/general/res/sass/mobile/_constants.scss index c23493ca2c..1a794b29f6 100644 --- a/platform/commonUI/general/res/sass/mobile/_constants.scss +++ b/platform/commonUI/general/res/sass/mobile/_constants.scss @@ -36,29 +36,29 @@ $mobileTreeRightArrowW: 30px; /************************** DEVICE WIDTHS */ // IMPORTANT! Usage assumes that ranges are mutually exclusive and have no gaps -$phoMaxW: 514px; -$tabMinW: 515px; -$tabMaxW: 1280px; -$desktopMinW: 1281px; +$phoMaxW: 767px; +$tabMinW: 768px; +$tabMaxW: 1024px; +$desktopMinW: 1025px; /************************** MEDIA QUERIES: WINDOW CHECKS FOR SPECIFIC ORIENTATIONS FOR EACH DEVICE */ -$screenPortrait: "screen and (orientation: portrait)"; -$screenLandscape: "screen and (orientation: landscape)"; +$screenPortrait: "(orientation: portrait)"; +$screenLandscape: "(orientation: landscape)"; -$mobileDevice: "(max-device-width: #{$tabMaxW})"; +//$mobileDevice: "(max-device-width: #{$tabMaxW})"; $phoneCheck: "(max-device-width: #{$phoMaxW})"; -$tabletCheck: $mobileDevice; -$desktopCheck: "(min-device-width: #{$desktopMinW})"; +$tabletCheck: "(min-device-width: #{$tabMinW}) and (max-device-width: #{$tabMaxW})"; +$desktopCheck: "(min-device-width: #{$desktopMinW}) and (-webkit-min-device-pixel-ratio: 1)"; /************************** MEDIA QUERIES: WINDOWS FOR SPECIFIC ORIENTATIONS FOR EACH DEVICE */ -$phonePortrait: "#{$screenPortrait} and #{$phoneCheck} and #{$mobileDevice}"; -$phoneLandscape: "#{$screenLandscape} and #{$phoneCheck} and #{$mobileDevice}"; +$phonePortrait: "only screen and #{$screenPortrait} and #{$phoneCheck}"; +$phoneLandscape: "only screen and #{$screenLandscape} and #{$phoneCheck}"; -$tabletPortrait: "#{$screenPortrait} and #{$tabletCheck} and #{$mobileDevice}"; -$tabletLandscape: "#{$screenLandscape} and #{$tabletCheck} and #{$mobileDevice}"; +$tabletPortrait: "only screen and #{$screenPortrait} and #{$tabletCheck}"; +$tabletLandscape: "only screen and #{$screenLandscape} and #{$tabletCheck}"; -$desktop: "screen and #{$desktopCheck}"; +$desktop: "only screen and #{$desktopCheck}"; /************************** DEVICE PARAMETERS FOR MENUS/REPRESENTATIONS */ $proporMenuOnly: 90%; diff --git a/platform/commonUI/general/res/templates/controls/datetime-field.html b/platform/commonUI/general/res/templates/controls/datetime-field.html index 2c0423c32b..e9b394c530 100644 --- a/platform/commonUI/general/res/templates/controls/datetime-field.html +++ b/platform/commonUI/general/res/templates/controls/datetime-field.html @@ -1,7 +1,29 @@ +
diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html index e44a9ff77c..335dee61c3 100644 --- a/platform/commonUI/general/res/templates/controls/time-controller.html +++ b/platform/commonUI/general/res/templates/controls/time-controller.html @@ -20,12 +20,14 @@ at runtime from the About dialog for additional information. -->
-
+
C @@ -36,12 +38,15 @@   -
+ + +
diff --git a/platform/commonUI/general/src/UnsupportedBrowserWarning.js b/platform/commonUI/general/src/UnsupportedBrowserWarning.js new file mode 100644 index 0000000000..f2fa0c3f20 --- /dev/null +++ b/platform/commonUI/general/src/UnsupportedBrowserWarning.js @@ -0,0 +1,64 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define*/ + +/** + * This bundle provides various general-purpose UI elements, including + * platform styling. + * @namespace platform/commonUI/general + */ +define( + [], + function () { + "use strict"; + + var WARNING_TITLE = "Unsupported browser", + WARNING_DESCRIPTION = [ + "This software has been developed and tested", + "using the latest Google Chrome,", + "and may be unstable in other browsers." + ].join(" "), + MOBILE_BROWSER = "Safari", + DESKTOP_BROWSER = "Chrome"; + + /** + * Shows a warning if a user's browser is unsupported. + * @memberof platform/commonUI/general + * @constructor + * @param {NotificationService} notificationService the notification + * service + */ + function UnsupportedBrowserWarning(notificationService, agentService) { + var testToBrowser = agentService.isMobile() ? + MOBILE_BROWSER : DESKTOP_BROWSER; + + if (!agentService.isBrowser(testToBrowser)) { + notificationService.alert({ + title: WARNING_TITLE, + actionText: WARNING_DESCRIPTION + }); + } + } + + return UnsupportedBrowserWarning; + } +); diff --git a/platform/commonUI/general/src/controllers/DateTimeFieldController.js b/platform/commonUI/general/src/controllers/DateTimeFieldController.js index 325a112e13..ef0827e515 100644 --- a/platform/commonUI/general/src/controllers/DateTimeFieldController.js +++ b/platform/commonUI/general/src/controllers/DateTimeFieldController.js @@ -53,7 +53,9 @@ define( formatter.parse($scope.textValue) !== value) { $scope.textValue = formatter.format(value); $scope.textInvalid = false; + $scope.lastValidValue = $scope.textValue; } + $scope.pickerModel = { value: value }; } function updateFromView(textValue) { @@ -61,6 +63,17 @@ define( if (!$scope.textInvalid) { $scope.ngModel[$scope.field] = formatter.parse(textValue); + $scope.lastValidValue = $scope.textValue; + } + } + + function updateFromPicker(value) { + if (value !== $scope.ngModel[$scope.field]) { + $scope.ngModel[$scope.field] = value; + updateFromModel(value); + if ($scope.ngBlur) { + $scope.ngBlur(); + } } } @@ -69,10 +82,18 @@ define( updateFromModel($scope.ngModel[$scope.field]); } + function restoreTextValue() { + $scope.textValue = $scope.lastValidValue; + updateFromView($scope.textValue); + } + + $scope.restoreTextValue = restoreTextValue; + $scope.picker = { active: false }; $scope.$watch('structure.format', setFormat); $scope.$watch('ngModel[field]', updateFromModel); + $scope.$watch('pickerModel.value', updateFromPicker); $scope.$watch('textValue', updateFromView); } diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js index cdcdb7f8d0..b036bd3fc7 100644 --- a/platform/commonUI/general/src/controllers/TimeRangeController.js +++ b/platform/commonUI/general/src/controllers/TimeRangeController.js @@ -175,6 +175,13 @@ define( updateViewFromModel($scope.ngModel); } + function updateFormModel() { + $scope.formModel = { + start: (($scope.ngModel || {}).outer || {}).start, + end: (($scope.ngModel || {}).outer || {}).end + }; + } + function updateOuterStart(t) { var ngModel = $scope.ngModel; @@ -192,6 +199,7 @@ define( ngModel.inner.end ); + updateFormModel(); updateViewForInnerSpanFromModel(ngModel); updateTicks(); } @@ -213,6 +221,7 @@ define( ngModel.inner.start ); + updateFormModel(); updateViewForInnerSpanFromModel(ngModel); updateTicks(); } @@ -223,6 +232,14 @@ define( updateTicks(); } + function updateBoundsFromForm() { + $scope.ngModel = $scope.ngModel || {}; + $scope.ngModel.outer = { + start: $scope.formModel.start, + end: $scope.formModel.end + }; + } + $scope.startLeftDrag = startLeftDrag; $scope.startRightDrag = startRightDrag; $scope.startMiddleDrag = startMiddleDrag; @@ -230,10 +247,13 @@ define( $scope.rightDrag = rightDrag; $scope.middleDrag = middleDrag; + $scope.updateBoundsFromForm = updateBoundsFromForm; + $scope.ticks = []; // Initialize scope to defaults updateViewFromModel($scope.ngModel); + updateFormModel(); $scope.$watchCollection("ngModel", updateViewFromModel); $scope.$watch("spanWidth", updateSpanWidth); diff --git a/platform/commonUI/general/src/directives/MCTSplitPane.js b/platform/commonUI/general/src/directives/MCTSplitPane.js index 9abc641ebd..b094ba785f 100644 --- a/platform/commonUI/general/src/directives/MCTSplitPane.js +++ b/platform/commonUI/general/src/directives/MCTSplitPane.js @@ -204,7 +204,7 @@ define( // And poll for position changes enforced by styles activeInterval = $interval(function () { getSetPosition(getSetPosition()); - }, POLLING_INTERVAL, false); + }, POLLING_INTERVAL, 0, false); // ...and stop polling when we're destroyed. $scope.$on('$destroy', function () { diff --git a/platform/commonUI/general/test/UnsupportedBrowserWarningSpec.js b/platform/commonUI/general/test/UnsupportedBrowserWarningSpec.js new file mode 100644 index 0000000000..507a92c62f --- /dev/null +++ b/platform/commonUI/general/test/UnsupportedBrowserWarningSpec.js @@ -0,0 +1,98 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/UnsupportedBrowserWarning"], + function (UnsupportedBrowserWarning) { + "use strict"; + + var MOBILE_BROWSER = "Safari", + DESKTOP_BROWSER = "Chrome", + UNSUPPORTED_BROWSERS = [ + "Firefox", + "IE", + "Opera", + "Iceweasel" + ]; + + describe("The unsupported browser warning", function () { + var mockNotificationService, + mockAgentService, + testAgent; + + function instantiateWith(browser) { + testAgent = "Mozilla/5.0 " + browser + "/12.34.56"; + return new UnsupportedBrowserWarning( + mockNotificationService, + mockAgentService + ); + } + + beforeEach(function () { + testAgent = "chrome"; + mockNotificationService = jasmine.createSpyObj( + "notificationService", + [ "alert" ] + ); + mockAgentService = jasmine.createSpyObj( + "agentService", + [ "isMobile", "isBrowser" ] + ); + mockAgentService.isBrowser.andCallFake(function (substr) { + substr = substr.toLowerCase(); + return testAgent.toLowerCase().indexOf(substr) !== -1; + }); + }); + + [ false, true ].forEach(function (isMobile) { + var deviceType = isMobile ? "mobile" : "desktop", + goodBrowser = isMobile ? MOBILE_BROWSER : DESKTOP_BROWSER, + badBrowsers = UNSUPPORTED_BROWSERS.concat([ + isMobile ? DESKTOP_BROWSER : MOBILE_BROWSER + ]); + + describe("on " + deviceType + " devices", function () { + beforeEach(function () { + mockAgentService.isMobile.andReturn(isMobile); + }); + + it("is not shown for " + goodBrowser, function () { + instantiateWith(goodBrowser); + expect(mockNotificationService.alert) + .not.toHaveBeenCalled(); + }); + + badBrowsers.forEach(function (badBrowser) { + it("is shown for " + badBrowser, function () { + instantiateWith(badBrowser); + expect(mockNotificationService.alert) + .toHaveBeenCalled(); + }); + }); + }); + }); + + }); + } +); + diff --git a/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js index 714c7d072e..7d71095772 100644 --- a/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js +++ b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js @@ -67,21 +67,13 @@ define( mockScope.ngModel = { testField: 12321 }; mockScope.field = "testField"; mockScope.structure = { format: "someFormat" }; + mockScope.ngBlur = jasmine.createSpy('blur'); controller = new DateTimeFieldController( mockScope, mockFormatService ); - }); - - it("updates models from user-entered text", function () { - var newText = "1977-05-25 17:30:00"; - - mockScope.textValue = newText; - fireWatch("textValue", newText); - expect(mockScope.ngModel.testField) - .toEqual(mockFormat.parse(newText)); - expect(mockScope.textInvalid).toBeFalsy(); + fireWatch("ngModel[field]", mockScope.ngModel.testField); }); it("updates text from model values", function () { @@ -91,16 +83,55 @@ define( expect(mockScope.textValue).toEqual("1977-05-25 17:30:00"); }); + describe("when valid text is entered", function () { + var newText; + + beforeEach(function () { + newText = "1977-05-25 17:30:00"; + mockScope.textValue = newText; + fireWatch("textValue", newText); + }); + + it("updates models from user-entered text", function () { + expect(mockScope.ngModel.testField) + .toEqual(mockFormat.parse(newText)); + expect(mockScope.textInvalid).toBeFalsy(); + }); + + it("does not indicate a blur event", function () { + expect(mockScope.ngBlur).not.toHaveBeenCalled(); + }); + }); + + describe("when a date is chosen via the date picker", function () { + var newValue; + + beforeEach(function () { + newValue = 12345654321; + mockScope.pickerModel.value = newValue; + fireWatch("pickerModel.value", newValue); + }); + + it("updates models", function () { + expect(mockScope.ngModel.testField).toEqual(newValue); + }); + + it("fires a blur event", function () { + expect(mockScope.ngBlur).toHaveBeenCalled(); + }); + }); + it("exposes toggle state for date-time picker", function () { expect(mockScope.picker.active).toBe(false); }); describe("when user input is invalid", function () { - var newText, oldValue; + var newText, oldText, oldValue; beforeEach(function () { newText = "Not a date"; oldValue = mockScope.ngModel.testField; + oldText = mockScope.textValue; mockScope.textValue = newText; fireWatch("textValue", newText); }); @@ -116,6 +147,11 @@ define( it("does not modify user input", function () { expect(mockScope.textValue).toEqual(newText); }); + + it("restores valid text values on request", function () { + mockScope.restoreTextValue(); + expect(mockScope.textValue).toEqual(oldText); + }); }); it("does not modify valid but irregular user input", function () { diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js index 85e77e4889..861f28ed45 100644 --- a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js +++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js @@ -91,6 +91,39 @@ define( .toHaveBeenCalledWith("ngModel", jasmine.any(Function)); }); + describe("when changes are made via form entry", function () { + beforeEach(function () { + mockScope.ngModel = { + outer: { start: DAY * 2, end: DAY * 3 }, + inner: { start: DAY * 2.25, end: DAY * 2.75 } + }; + mockScope.formModel = { + start: DAY * 10000, + end: DAY * 11000 + }; + // These watches may not exist, but Angular would fire + // them if they did. + fireWatchCollection("formModel", mockScope.formModel); + fireWatch("formModel.start", mockScope.formModel.start); + fireWatch("formModel.end", mockScope.formModel.end); + }); + + it("does not immediately make changes to the model", function () { + expect(mockScope.ngModel.outer.start) + .not.toEqual(mockScope.formModel.start); + expect(mockScope.ngModel.outer.end) + .not.toEqual(mockScope.formModel.end); + }); + + it("updates model bounds on request", function () { + mockScope.updateBoundsFromForm(); + expect(mockScope.ngModel.outer.start) + .toEqual(mockScope.formModel.start); + expect(mockScope.ngModel.outer.end) + .toEqual(mockScope.formModel.end); + }); + }); + describe("when dragged", function () { beforeEach(function () { mockScope.ngModel = { diff --git a/platform/commonUI/general/test/directives/MCTSplitPaneSpec.js b/platform/commonUI/general/test/directives/MCTSplitPaneSpec.js new file mode 100644 index 0000000000..2d5d5ac5a0 --- /dev/null +++ b/platform/commonUI/general/test/directives/MCTSplitPaneSpec.js @@ -0,0 +1,95 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../../src/directives/MCTSplitPane"], + function (MCTSplitPane) { + 'use strict'; + + var JQLITE_METHODS = [ + 'on', + 'addClass', + 'children', + 'eq' + ]; + + describe("The mct-split-pane directive", function () { + var mockParse, + mockLog, + mockInterval, + mctSplitPane; + + beforeEach(function () { + mockParse = jasmine.createSpy('$parse'); + mockLog = + jasmine.createSpyObj('$log', ['warn', 'info', 'debug']); + mockInterval = jasmine.createSpy('$interval'); + mockInterval.cancel = jasmine.createSpy('mockCancel'); + mctSplitPane = new MCTSplitPane( + mockParse, + mockLog, + mockInterval + ); + }); + + it("is only applicable as an element", function () { + expect(mctSplitPane.restrict).toEqual("E"); + }); + + describe("when its controller is applied", function () { + var mockScope, + mockElement, + testAttrs, + mockChildren, + controller; + + beforeEach(function () { + mockScope = + jasmine.createSpyObj('$scope', ['$apply', '$watch', '$on']); + mockElement = + jasmine.createSpyObj('element', JQLITE_METHODS); + testAttrs = {}; + mockChildren = + jasmine.createSpyObj('children', JQLITE_METHODS); + + mockElement.children.andReturn(mockChildren); + mockChildren.eq.andReturn(mockChildren); + mockChildren[0] = {}; + + controller = mctSplitPane.controller[3]( + mockScope, + mockElement, + testAttrs + ); + }); + + it("sets an interval which does not trigger digests", function () { + expect(mockInterval.mostRecentCall.args[3]).toBe(false); + }); + + }); + + }); + + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 6b89f83d61..777001174a 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -19,8 +19,10 @@ "directives/MCTPopup", "directives/MCTResize", "directives/MCTScroll", + "directives/MCTSplitPane", "services/Popup", "services/PopupService", "services/UrlService", - "StyleSheetLoader" + "StyleSheetLoader", + "UnsupportedBrowserWarning" ] diff --git a/platform/commonUI/mobile/src/AgentService.js b/platform/commonUI/mobile/src/AgentService.js index 26bb173ee7..109520d8bd 100644 --- a/platform/commonUI/mobile/src/AgentService.js +++ b/platform/commonUI/mobile/src/AgentService.js @@ -43,6 +43,7 @@ define( var userAgent = $window.navigator.userAgent, matches = userAgent.match(/iPad|iPhone|Android/i) || []; + this.userAgent = userAgent; this.mobileName = matches[0]; this.$window = $window; } @@ -91,6 +92,18 @@ define( return !this.isPortrait(); }; + /** + * Check if the user agent matches a certain named device, + * as indicated by checking for a case-insensitive substring + * match. + * @param {string} name the name to check for + * @returns {boolean} true if the user agent includes that name + */ + AgentService.prototype.isBrowser = function (name) { + name = name.toLowerCase(); + return this.userAgent.toLowerCase().indexOf(name) !== -1; + }; + return AgentService; } ); diff --git a/platform/commonUI/mobile/test/AgentServiceSpec.js b/platform/commonUI/mobile/test/AgentServiceSpec.js index c0735e3f4c..573e72ddfb 100644 --- a/platform/commonUI/mobile/test/AgentServiceSpec.js +++ b/platform/commonUI/mobile/test/AgentServiceSpec.js @@ -81,6 +81,13 @@ define( expect(agentService.isPortrait()).toBeTruthy(); expect(agentService.isLandscape()).toBeFalsy(); }); + + it("allows for checking browser type", function () { + testWindow.navigator.userAgent = "Chromezilla Safarifox"; + agentService = new AgentService(testWindow); + expect(agentService.isBrowser("Chrome")).toBe(true); + expect(agentService.isBrowser("Firefox")).toBe(false); + }); }); } ); diff --git a/platform/core/bundle.json b/platform/core/bundle.json index b22e225e84..0a76948e39 100644 --- a/platform/core/bundle.json +++ b/platform/core/bundle.json @@ -188,7 +188,8 @@ { "key": "persistence", "implementation": "capabilities/PersistenceCapability.js", - "depends": [ "persistenceService", "identifierService" ] + "depends": [ "persistenceService", "identifierService", + "notificationService", "$q" ] }, { "key": "metadata", diff --git a/platform/core/src/capabilities/PersistenceCapability.js b/platform/core/src/capabilities/PersistenceCapability.js index 8bd29c7b7c..637be193c2 100644 --- a/platform/core/src/capabilities/PersistenceCapability.js +++ b/platform/core/src/capabilities/PersistenceCapability.js @@ -20,6 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ /*global define*/ +/*jslint es5: true */ define( @@ -47,6 +48,8 @@ define( function PersistenceCapability( persistenceService, identifierService, + notificationService, + $q, domainObject ) { // Cache modified timestamp @@ -55,6 +58,8 @@ define( this.domainObject = domainObject; this.identifierService = identifierService; this.persistenceService = persistenceService; + this.notificationService = notificationService; + this.$q = $q; } // Utility function for creating promise-like objects which @@ -72,6 +77,46 @@ define( return parts.length > 1 ? parts.slice(1).join(":") : id; } + /** + * Checks if the value returned is falsey, and if so returns a + * rejected promise + */ + function rejectIfFalsey(value, $q){ + if (!value){ + return $q.reject("Error persisting object"); + } else { + return value; + } + } + + function formatError(error){ + if (error && error.message) { + return error.message; + } else if (error && typeof error === "string"){ + return error; + } else { + return "unknown error"; + } + } + + /** + * Display a notification message if an error has occurred during + * persistence. + */ + function notifyOnError(error, domainObject, notificationService, $q){ + var errorMessage = "Unable to persist " + domainObject.getModel().name; + if (error) { + errorMessage += ": " + formatError(error); + } + + notificationService.error({ + title: "Error persisting " + domainObject.getModel().name, + hint: errorMessage || "Unknown error" + }); + + return $q.reject(error); + } + /** * Persist any changes which have been made to this * domain object's model. @@ -80,7 +125,8 @@ define( * if not. */ PersistenceCapability.prototype.persist = function () { - var domainObject = this.domainObject, + var self = this, + domainObject = this.domainObject, model = domainObject.getModel(), modified = model.modified, persistenceService = this.persistenceService, @@ -98,7 +144,11 @@ define( this.getSpace(), getKey(domainObject.getId()), domainObject.getModel() - ]); + ]).then(function(result){ + return rejectIfFalsey(result, self.$q); + }).catch(function(error){ + return notifyOnError(error, domainObject, self.notificationService, self.$q); + }); }; /** diff --git a/platform/core/test/capabilities/PersistenceCapabilitySpec.js b/platform/core/test/capabilities/PersistenceCapabilitySpec.js index 5b40e34c64..5b46ca3890 100644 --- a/platform/core/test/capabilities/PersistenceCapabilitySpec.js +++ b/platform/core/test/capabilities/PersistenceCapabilitySpec.js @@ -20,6 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ +/*jslint es5: true */ /** * PersistenceCapabilitySpec. Created by vwoeltje on 11/6/14. @@ -34,24 +35,36 @@ define( mockIdentifierService, mockDomainObject, mockIdentifier, + mockNofificationService, + mockQ, id = "object id", - model = { someKey: "some value"}, + model, SPACE = "some space", - persistence; + persistence, + happyPromise; - function asPromise(value) { + function asPromise(value, doCatch) { return (value || {}).then ? value : { then: function (callback) { return asPromise(callback(value)); + }, + catch: function(callback) { + //Define a default 'happy' catch, that skips over the + // catch callback + return doCatch ? asPromise(callback(value)): asPromise(value); } }; } beforeEach(function () { + happyPromise = asPromise(true); + model = { someKey: "some value", name: "domain object"}; + mockPersistenceService = jasmine.createSpyObj( "persistenceService", [ "updateObject", "readObject", "createObject", "deleteObject" ] ); + mockIdentifierService = jasmine.createSpyObj( 'identifierService', [ 'parse', 'generate' ] @@ -60,6 +73,15 @@ define( 'identifier', [ 'getSpace', 'getKey', 'getDefinedSpace' ] ); + mockQ = jasmine.createSpyObj( + "$q", + ["reject"] + ); + mockNofificationService = jasmine.createSpyObj( + "notificationService", + ["error"] + ); + mockDomainObject = { getId: function () { return id; }, getModel: function () { return model; }, @@ -76,66 +98,99 @@ define( persistence = new PersistenceCapability( mockPersistenceService, mockIdentifierService, + mockNofificationService, + mockQ, mockDomainObject ); }); - it("creates unpersisted objects with the persistence service", function () { - // Verify precondition; no call made during constructor - expect(mockPersistenceService.createObject).not.toHaveBeenCalled(); + describe("successful persistence", function() { + beforeEach(function () { + mockPersistenceService.updateObject.andReturn(happyPromise); + mockPersistenceService.createObject.andReturn(happyPromise); + }); + it("creates unpersisted objects with the persistence service", function () { + // Verify precondition; no call made during constructor + expect(mockPersistenceService.createObject).not.toHaveBeenCalled(); - persistence.persist(); + persistence.persist(); - expect(mockPersistenceService.createObject).toHaveBeenCalledWith( - SPACE, - id, - model - ); + expect(mockPersistenceService.createObject).toHaveBeenCalledWith( + SPACE, + id, + model + ); + }); + + it("updates previously persisted objects with the persistence service", function () { + // Verify precondition; no call made during constructor + expect(mockPersistenceService.updateObject).not.toHaveBeenCalled(); + + model.persisted = 12321; + persistence.persist(); + + expect(mockPersistenceService.updateObject).toHaveBeenCalledWith( + SPACE, + id, + model + ); + }); + + it("reports which persistence space an object belongs to", function () { + expect(persistence.getSpace()).toEqual(SPACE); + }); + + it("updates persisted timestamp on persistence", function () { + model.modified = 12321; + persistence.persist(); + expect(model.persisted).toEqual(12321); + }); + it("refreshes the domain object model from persistence", function () { + var refreshModel = {someOtherKey: "some other value"}; + mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); + persistence.refresh(); + expect(model).toEqual(refreshModel); + }); + + it("does not overwrite unpersisted changes on refresh", function () { + var refreshModel = {someOtherKey: "some other value"}, + mockCallback = jasmine.createSpy(); + model.modified = 2; + model.persisted = 1; + mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); + persistence.refresh().then(mockCallback); + expect(model).not.toEqual(refreshModel); + // Should have also indicated that no changes were actually made + expect(mockCallback).toHaveBeenCalledWith(false); + }); + + it("does not trigger error notification on successful" + + " persistence", function () { + persistence.persist(); + expect(mockQ.reject).not.toHaveBeenCalled(); + expect(mockNofificationService.error).not.toHaveBeenCalled(); + }); }); + describe("unsuccessful persistence", function() { + var sadPromise = { + then: function(callback){ + return asPromise(callback(0), true); + } + }; + beforeEach(function () { + mockPersistenceService.createObject.andReturn(sadPromise); + }); + it("rejects on falsey persistence result", function () { + persistence.persist(); + expect(mockQ.reject).toHaveBeenCalled(); + }); - it("updates previously persisted objects with the persistence service", function () { - // Verify precondition; no call made during constructor - expect(mockPersistenceService.updateObject).not.toHaveBeenCalled(); - - model.persisted = 12321; - persistence.persist(); - - expect(mockPersistenceService.updateObject).toHaveBeenCalledWith( - SPACE, - id, - model - ); + it("notifies user on persistence failure", function () { + persistence.persist(); + expect(mockQ.reject).toHaveBeenCalled(); + expect(mockNofificationService.error).toHaveBeenCalled(); + }); }); - - it("reports which persistence space an object belongs to", function () { - expect(persistence.getSpace()).toEqual(SPACE); - }); - - it("updates persisted timestamp on persistence", function () { - model.modified = 12321; - persistence.persist(); - expect(model.persisted).toEqual(12321); - }); - - it("refreshes the domain object model from persistence", function () { - var refreshModel = { someOtherKey: "some other value" }; - mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); - persistence.refresh(); - expect(model).toEqual(refreshModel); - }); - - it("does not overwrite unpersisted changes on refresh", function () { - var refreshModel = { someOtherKey: "some other value" }, - mockCallback = jasmine.createSpy(); - model.modified = 2; - model.persisted = 1; - mockPersistenceService.readObject.andReturn(asPromise(refreshModel)); - persistence.refresh().then(mockCallback); - expect(model).not.toEqual(refreshModel); - // Should have also indicated that no changes were actually made - expect(mockCallback).toHaveBeenCalledWith(false); - }); - }); } ); diff --git a/platform/entanglement/bundle.json b/platform/entanglement/bundle.json index fc17ef9ef2..b1e54730a7 100644 --- a/platform/entanglement/bundle.json +++ b/platform/entanglement/bundle.json @@ -39,6 +39,15 @@ "glyph": "\u00F4", "category": "contextual", "implementation": "actions/GoToOriginalAction.js" + }, + { + "key": "locate", + "name": "Set Primary Location", + "description": "Set a domain object's primary location.", + "glyph": "", + "category": "contextual", + "implementation": "actions/SetPrimaryLocationAction.js" + } ], "components": [ @@ -89,8 +98,7 @@ "name": "Copy Service", "description": "Provides a service for copying objects", "implementation": "services/CopyService.js", - "depends": ["$q", "creationService", "policyService", - "persistenceService", "now"] + "depends": ["$q", "policyService", "now"] }, { "key": "locationService", diff --git a/platform/entanglement/src/actions/SetPrimaryLocationAction.js b/platform/entanglement/src/actions/SetPrimaryLocationAction.js new file mode 100644 index 0000000000..25ebd5ccf8 --- /dev/null +++ b/platform/entanglement/src/actions/SetPrimaryLocationAction.js @@ -0,0 +1,60 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define */ +define( + function () { + "use strict"; + + /** + * Implements the "Set Primary Location" action, which sets a + * location property for objects to match their contextual + * location. + * + * @implements {Action} + * @constructor + * @private + * @memberof platform/entanglement + * @param {ActionContext} context the context in which the action + * will be performed + */ + function SetPrimaryLocationAction(context) { + this.domainObject = context.domainObject; + } + + SetPrimaryLocationAction.prototype.perform = function () { + var location = this.domainObject.getCapability('location'); + return location.setPrimaryLocation( + location.getContextualLocation() + ); + }; + + SetPrimaryLocationAction.appliesTo = function (context) { + var domainObject = context.domainObject; + return domainObject && domainObject.hasCapability("location") + && (domainObject.getModel().location === undefined); + }; + + return SetPrimaryLocationAction; + } +); + diff --git a/platform/entanglement/src/policies/CrossSpacePolicy.js b/platform/entanglement/src/policies/CrossSpacePolicy.js index a113972815..c47e653f08 100644 --- a/platform/entanglement/src/policies/CrossSpacePolicy.js +++ b/platform/entanglement/src/policies/CrossSpacePolicy.js @@ -28,9 +28,7 @@ define( var DISALLOWED_ACTIONS = [ "move", - "copy", - "link", - "compose" + "copy" ]; /** diff --git a/platform/entanglement/src/services/CopyService.js b/platform/entanglement/src/services/CopyService.js index 29c6be4d10..03f32b76ce 100644 --- a/platform/entanglement/src/services/CopyService.js +++ b/platform/entanglement/src/services/CopyService.js @@ -38,12 +38,9 @@ define( * @memberof platform/entanglement * @implements {platform/entanglement.AbstractComposeService} */ - function CopyService($q, creationService, policyService, persistenceService, now) { + function CopyService($q, policyService) { this.$q = $q; - this.creationService = creationService; this.policyService = policyService; - this.persistenceService = persistenceService; - this.now = now; } CopyService.prototype.validate = function (object, parentCandidate) { @@ -71,7 +68,7 @@ define( */ CopyService.prototype.perform = function (domainObject, parent) { var $q = this.$q, - copyTask = new CopyTask(domainObject, parent, this.persistenceService, this.$q, this.now); + copyTask = new CopyTask(domainObject, parent, this.policyService, this.$q); if (this.validate(domainObject, parent)) { return copyTask.perform(); } else { diff --git a/platform/entanglement/src/services/CopyTask.js b/platform/entanglement/src/services/CopyTask.js index f484856448..d20c233b42 100644 --- a/platform/entanglement/src/services/CopyTask.js +++ b/platform/entanglement/src/services/CopyTask.js @@ -23,8 +23,8 @@ /*global define */ define( - ["uuid"], - function (uuid) { + [], + function () { "use strict"; /** @@ -33,36 +33,48 @@ define( * * @param domainObject The object to copy * @param parent The new location of the cloned object tree - * @param persistenceService * @param $q - * @param now * @constructor */ - function CopyTask (domainObject, parent, persistenceService, $q, now){ + function CopyTask (domainObject, parent, policyService, $q){ this.domainObject = domainObject; this.parent = parent; + this.firstClone = undefined; this.$q = $q; this.deferred = undefined; - this.persistenceService = persistenceService; + this.policyService = policyService; this.persisted = 0; - this.now = now; this.clones = []; } - function composeChild(child, parent) { + function composeChild(child, parent, setLocation) { //Once copied, associate each cloned // composee with its parent clone - child.model.location = parent.id; - parent.model.composition = parent.model.composition || []; - return parent.model.composition.push(child.id); + + parent.getModel().composition.push(child.getId()); + + //If a location is not specified, set it. + if (setLocation && child.getModel().location === undefined) { + child.getModel().location = parent.getId(); + } } function cloneObjectModel(objectModel) { var clone = JSON.parse(JSON.stringify(objectModel)); - delete clone.composition; + /** + * Reset certain fields. + */ + //If has a composition, set it to an empty array. Will be + // recomposed later with the ids of its cloned children. + if (clone.composition) { + //Important to set it to an empty array here, otherwise + // hasCapability("composition") returns false; + clone.composition = []; + } delete clone.persisted; delete clone.modified; + delete clone.location; return clone; } @@ -73,13 +85,10 @@ define( * result in automatic request batching by the browser. */ function persistObjects(self) { - return self.$q.all(self.clones.map(function(clone){ - clone.model.persisted = self.now(); - return self.persistenceService.createObject(clone.persistenceSpace, clone.id, clone.model) - .then(function(){ - self.deferred.notify({phase: "copying", totalObjects: self.clones.length, processed: ++self.persisted}); - }); + return clone.getCapability("persistence").persist().then(function(){ + self.deferred.notify({phase: "copying", totalObjects: self.clones.length, processed: ++self.persisted}); + }); })).then(function(){ return self; }); @@ -89,18 +98,10 @@ define( * Will add a list of clones to the specified parent's composition */ function addClonesToParent(self) { - var parentClone = self.clones[self.clones.length-1]; - - if (!self.parent.hasCapability('composition')){ - return self.$q.reject(); - } - - return self.persistenceService - .updateObject(parentClone.persistenceSpace, parentClone.id, parentClone.model) - .then(function(){return self.parent.getCapability("composition").add(parentClone.id);}) + return self.firstClone.getCapability("persistence").persist() + .then(function(){self.parent.getCapability("composition").add(self.firstClone.getId());}) .then(function(){return self.parent.getCapability("persistence").persist();}) - .then(function(){return parentClone;}); - // Ensure the clone of the original domainObject is returned + .then(function(){return self.firstClone;}); } /** @@ -112,13 +113,16 @@ define( CopyTask.prototype.copyComposees = function(composees, clonedParent, originalParent){ var self = this; - return (composees || []).reduce(function(promise, composee){ + return (composees || []).reduce(function(promise, originalComposee){ //If the composee is composed of other // objects, chain a promise.. return promise.then(function(){ // ...to recursively copy it (and its children) - return self.copy(composee, originalParent).then(function(composee){ - composeChild(composee, clonedParent); + return self.copy(originalComposee, originalParent).then(function(clonedComposee){ + //Compose the child within its parent. Cloned + // objects will need to also have their location + // set, however linked objects will not. + return composeChild(clonedComposee, clonedParent, clonedComposee !== originalComposee); }); });}, self.$q.when(undefined) ); @@ -131,29 +135,43 @@ define( * cloning objects, and composing them with their child clones * as it goes * @private - * @param originalObject - * @param originalParent - * @returns {*} + * @returns {DomainObject} If the type of the original object allows for + * duplication, then a duplicate of the object, otherwise the object + * itself (to allow linking to non duplicatable objects). */ - CopyTask.prototype.copy = function(originalObject, originalParent) { + CopyTask.prototype.copy = function(originalObject) { var self = this, - modelClone = { - id: uuid(), - model: cloneObjectModel(originalObject.getModel()), - persistenceSpace: originalParent.hasCapability('persistence') && originalParent.getCapability('persistence').getSpace() - }; + clone; - return this.$q.when(originalObject.useCapability('composition')).then(function(composees){ - self.deferred.notify({phase: "preparing"}); - //Duplicate the object's children, and their children, and - // so on down to the leaf nodes of the tree. - return self.copyComposees(composees, modelClone, originalObject).then(function (){ - //Add the clone to the list of clones that will - //be returned by this function - self.clones.push(modelClone); - return modelClone; + //Check if the type of the object being copied allows for + // creation of new instances. If it does not, then a link to the + // original will be created instead. + if (this.policyService.allow("creation", originalObject.getCapability("type"))){ + //create a new clone of the original object. Use the + // creation capability of the targetParent to create the + // new clone. This will ensure that the correct persistence + // space is used. + clone = this.parent.useCapability("instantiation", cloneObjectModel(originalObject.getModel())); + + //Iterate through child tree + return this.$q.when(originalObject.useCapability('composition')).then(function(composees){ + self.deferred.notify({phase: "preparing"}); + //Duplicate the object's children, and their children, and + // so on down to the leaf nodes of the tree. + //If it is a link, don't both with children + return self.copyComposees(composees, clone, originalObject).then(function (){ + //Add the clone to the list of clones that will + //be returned by this function + self.clones.push(clone); + return clone; + }); }); - }); + } else { + //Creating a link, no need to iterate children + return self.$q.when(originalObject); + } + + }; /** @@ -172,7 +190,10 @@ define( var self = this; return this.copy(self.domainObject, self.parent).then(function(domainObjectClone){ - domainObjectClone.model.location = self.parent.getId(); + if (domainObjectClone !== self.domainObject) { + domainObjectClone.getModel().location = self.parent.getId(); + } + self.firstClone = domainObjectClone; return self; }); }; diff --git a/platform/entanglement/test/actions/SetPrimaryLocationActionSpec.js b/platform/entanglement/test/actions/SetPrimaryLocationActionSpec.js new file mode 100644 index 0000000000..76fb3a344b --- /dev/null +++ b/platform/entanglement/test/actions/SetPrimaryLocationActionSpec.js @@ -0,0 +1,80 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web 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 Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define,describe,beforeEach,it,jasmine,expect */ + +define( + [ + '../../src/actions/SetPrimaryLocationAction', + '../DomainObjectFactory' + ], + function (SetPrimaryLocation, domainObjectFactory) { + 'use strict'; + + describe("The 'set primary location' action", function () { + var testContext, + testModel, + testId, + mockLocationCapability, + mockContextCapability; + + beforeEach(function () { + testId = "some-id"; + testModel = { name: "some name" }; + + mockLocationCapability = jasmine.createSpyObj( + 'location', + [ 'setPrimaryLocation', 'getContextualLocation' ] + ); + + mockLocationCapability.getContextualLocation.andReturn(testId); + + testContext = { + domainObject: domainObjectFactory({ + capabilities: { + location: mockLocationCapability + }, + model: testModel + }) + }; + }); + + it("is applicable to objects with no location specified", function () { + expect(SetPrimaryLocation.appliesTo(testContext)) + .toBe(true); + testContext.domainObject.getModel.andReturn({ + location: "something", + name: "some name" + }); + expect(SetPrimaryLocation.appliesTo(testContext)) + .toBe(false); + }); + + it("sets the location contextually when performed", function () { + new SetPrimaryLocation(testContext).perform(); + expect(mockLocationCapability.setPrimaryLocation) + .toHaveBeenCalledWith(testId); + }); + + }); + } +); diff --git a/platform/entanglement/test/policies/CrossSpacePolicySpec.js b/platform/entanglement/test/policies/CrossSpacePolicySpec.js index 214efc1cc3..a0b030f04f 100644 --- a/platform/entanglement/test/policies/CrossSpacePolicySpec.js +++ b/platform/entanglement/test/policies/CrossSpacePolicySpec.js @@ -72,7 +72,7 @@ define( policy = new CrossSpacePolicy(); }); - ['move', 'copy', 'link', 'compose'].forEach(function (key) { + ['move', 'copy'].forEach(function (key) { describe("for " + key + " actions", function () { beforeEach(function () { testActionMetadata.key = key; diff --git a/platform/entanglement/test/services/CopyServiceSpec.js b/platform/entanglement/test/services/CopyServiceSpec.js index 391d90913c..3d4ebf147e 100644 --- a/platform/entanglement/test/services/CopyServiceSpec.js +++ b/platform/entanglement/test/services/CopyServiceSpec.js @@ -63,7 +63,6 @@ define( beforeEach(function () { copyService = new CopyService( - null, null, policyService ); @@ -130,47 +129,50 @@ define( creationService, createObjectPromise, copyService, - mockPersistenceService, mockNow, object, newParent, copyResult, copyFinished, persistObjectPromise, - parentPersistenceCapability, + persistenceCapability, + instantiationCapability, + compositionCapability, + locationCapability, resolvedValue; beforeEach(function () { - creationService = jasmine.createSpyObj( - 'creationService', - ['createObject'] - ); createObjectPromise = synchronousPromise(undefined); - creationService.createObject.andReturn(createObjectPromise); policyService.allow.andReturn(true); - - mockPersistenceService = jasmine.createSpyObj( - 'persistenceService', - ['createObject', 'updateObject'] - ); + persistObjectPromise = synchronousPromise(undefined); - mockPersistenceService.createObject.andReturn(persistObjectPromise); - mockPersistenceService.updateObject.andReturn(persistObjectPromise); - - parentPersistenceCapability = jasmine.createSpyObj( - "persistence", + + instantiationCapability = jasmine.createSpyObj( + "instantiation", + [ "invoke" ] + ); + + persistenceCapability = jasmine.createSpyObj( + "persistenceCapability", [ "persist", "getSpace" ] ); + persistenceCapability.persist.andReturn(persistObjectPromise); - parentPersistenceCapability.persist.andReturn(persistObjectPromise); - parentPersistenceCapability.getSpace.andReturn("testSpace"); + compositionCapability = jasmine.createSpyObj( + 'compositionCapability', + ['invoke', 'add'] + ); - mockNow = jasmine.createSpyObj("mockNow", ["now"]); - mockNow.now.andCallFake(function(){ - return 1234; - }); + locationCapability = jasmine.createSpyObj( + 'locationCapability', + ['isLink'] + ); + locationCapability.isLink.andReturn(false); - mockDeferred = jasmine.createSpyObj('mockDeferred', ['notify', 'resolve']); + mockDeferred = jasmine.createSpyObj( + 'mockDeferred', + ['notify', 'resolve', 'reject'] + ); mockDeferred.notify.andCallFake(function(notification){}); mockDeferred.resolve.andCallFake(function(value){resolvedValue = value;}); mockDeferred.promise = { @@ -179,7 +181,11 @@ define( } }; - mockQ = jasmine.createSpyObj('mockQ', ['when', 'all', 'reject', 'defer']); + mockQ = jasmine.createSpyObj( + 'mockQ', + ['when', 'all', 'reject', 'defer'] + ); + mockQ.reject.andReturn(synchronousPromise(undefined)); mockQ.when.andCallFake(synchronousPromise); mockQ.all.andCallFake(function (promises) { var result = {}; @@ -194,6 +200,8 @@ define( describe("on domain object without composition", function () { beforeEach(function () { + var objectCopy; + newParent = domainObjectFactory({ name: 'newParent', id: '456', @@ -201,7 +209,9 @@ define( composition: [] }, capabilities: { - persistence: parentPersistenceCapability + instantiation: instantiationCapability, + persistence: persistenceCapability, + composition: compositionCapability } }); @@ -210,31 +220,46 @@ define( id: 'abc', model: { name: 'some object', - location: newParent.id, - persisted: mockNow.now() + location: '456', + someOtherAttribute: 'some other value', + embeddedObjectAttribute: { + name: 'Some embedded object' + } + }, + capabilities: { + persistence: persistenceCapability } }); - - copyService = new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); + + objectCopy = domainObjectFactory({ + name: 'object', + id: 'abc.copy.fdgdfgdf', + capabilities: { + persistence: persistenceCapability, + location: locationCapability + } + }); + + instantiationCapability.invoke.andCallFake( + function(model){ + objectCopy.model = model; + return objectCopy; + } + ); + + copyService = new CopyService(mockQ, policyService); copyResult = copyService.perform(object, newParent); copyFinished = jasmine.createSpy('copyFinished'); copyResult.then(copyFinished); }); - it("uses persistence service", function () { - expect(mockPersistenceService.createObject) - .toHaveBeenCalledWith(parentPersistenceCapability.getSpace(), jasmine.any(String), object.getModel()); - - expect(persistObjectPromise.then) - .toHaveBeenCalledWith(jasmine.any(Function)); - }); + it("uses persistence capability", function () { + expect(persistenceCapability.persist) + .toHaveBeenCalled(); + }); it("deep clones object model", function () { - //var newModel = creationService - var newModel = mockPersistenceService - .createObject - .mostRecentCall - .args[2]; + var newModel = copyFinished.calls[0].args[0].getModel(); expect(newModel).toEqual(object.model); expect(newModel).not.toBe(object.model); }); @@ -249,27 +274,57 @@ define( describe("on domainObject with composition", function () { var newObject, childObject, - compositionCapability, - locationCapability, + objectClone, + childObjectClone, compositionPromise; beforeEach(function () { + var invocationCount = 0, + objectClones; + instantiationCapability.invoke.andCallFake( + function(model){ + var cloneToReturn = objectClones[invocationCount++]; + cloneToReturn.model = model; + return cloneToReturn; + } + ); - locationCapability = jasmine.createSpyObj('locationCapability', ['isLink']); - locationCapability.isLink.andReturn(true); + newParent = domainObjectFactory({ + name: 'newParent', + id: '456', + model: { + composition: [] + }, + capabilities: { + instantiation: instantiationCapability, + persistence: persistenceCapability, + composition: compositionCapability + } + }); childObject = domainObjectFactory({ name: 'childObject', id: 'def', model: { - name: 'a child object' + name: 'a child object', + location: 'abc' + }, + capabilities: { + persistence: persistenceCapability, + location: locationCapability } }); - compositionCapability = jasmine.createSpyObj( - 'compositionCapability', - ['invoke', 'add'] - ); + + childObjectClone = domainObjectFactory({ + name: 'childObject', + id: 'def.clone', + capabilities: { + persistence: persistenceCapability, + location: locationCapability + } + }); + compositionPromise = jasmine.createSpyObj( 'compositionPromise', ['then'] @@ -280,7 +335,7 @@ define( .andReturn(synchronousPromise([childObject])); object = domainObjectFactory({ - name: 'object', + name: 'some object', id: 'abc', model: { name: 'some object', @@ -288,36 +343,27 @@ define( location: 'testLocation' }, capabilities: { + instantiation: instantiationCapability, composition: compositionCapability, - location: locationCapability - } - }); - newObject = domainObjectFactory({ - name: 'object', - id: 'abc2', - model: { - name: 'some object', - composition: [] - }, - capabilities: { - composition: compositionCapability - } - }); - newParent = domainObjectFactory({ - name: 'newParent', - id: '456', - model: { - composition: [] - }, - capabilities: { - composition: compositionCapability, - persistence: parentPersistenceCapability + location: locationCapability, + persistence: persistenceCapability } }); - createObjectPromise = synchronousPromise(newObject); - creationService.createObject.andReturn(createObjectPromise); - copyService = new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); + objectClone = domainObjectFactory({ + name: 'some object', + id: 'abc.clone', + capabilities: { + instantiation: instantiationCapability, + composition: compositionCapability, + location: locationCapability, + persistence: persistenceCapability + } + }); + + objectClones = [objectClone, childObjectClone]; + + copyService = new CopyService(mockQ, policyService); }); describe("the cloning process", function(){ @@ -327,10 +373,9 @@ define( copyResult.then(copyFinished); }); - it("copies object and children in a bottom-up" + - " fashion", function () { - expect(mockPersistenceService.createObject.calls[0].args[2].name).toEqual(childObject.model.name); - expect(mockPersistenceService.createObject.calls[1].args[2].name).toEqual(object.model.name); + it("returns a promise", function () { + expect(copyResult.then).toBeDefined(); + expect(copyFinished).toHaveBeenCalled(); }); it("returns a promise", function () { @@ -338,15 +383,27 @@ define( expect(copyFinished).toHaveBeenCalled(); }); - it("clears modified and sets persisted", function () { - expect(copyFinished.mostRecentCall.args[0].model.modified).toBeUndefined(); - expect(copyFinished.mostRecentCall.args[0].model.persisted).toBe(mockNow.now()); - }); - it ("correctly locates cloned objects", function() { - expect(mockPersistenceService.createObject.calls[0].args[2].location).toEqual(mockPersistenceService.createObject.calls[1].args[1]); + expect(childObjectClone.getModel().location).toEqual(objectClone.getId()); }); + }); + describe("when cloning non-creatable objects", function() { + beforeEach(function () { + policyService.allow.andCallFake(function(category){ + //Return false for 'creation' policy + return category !== 'creation'; + }); + copyResult = copyService.perform(object, newParent); + copyFinished = jasmine.createSpy('copyFinished'); + copyResult.then(copyFinished); + }); + it ("creates link instead of clone", function() { + var copiedObject = copyFinished.calls[0].args[0]; + expect(copiedObject).toBe(object); + expect(compositionCapability.add).toHaveBeenCalledWith(copiedObject.getId()); + //expect(newParent.getModel().composition).toContain(copiedObject.getId()); + }); }); }); @@ -355,20 +412,28 @@ define( object = domainObjectFactory({ name: 'object', capabilities: { - type: { type: 'object' } + type: { type: 'object' }, + location: locationCapability, + persistence: persistenceCapability } }); + newParent = domainObjectFactory({ name: 'parentCandidate', capabilities: { - type: { type: 'parentCandidate' } + type: { type: 'parentCandidate' }, + instantiation: instantiationCapability, + composition: compositionCapability, + persistence: persistenceCapability } }); + + instantiationCapability.invoke.andReturn(object); }); it("throws an error", function () { var copyService = - new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); + new CopyService(mockQ, policyService); function perform() { copyService.perform(object, newParent); diff --git a/platform/entanglement/test/suite.json b/platform/entanglement/test/suite.json index b954ab8ebc..243da94c39 100644 --- a/platform/entanglement/test/suite.json +++ b/platform/entanglement/test/suite.json @@ -4,6 +4,7 @@ "actions/GoToOriginalAction", "actions/LinkAction", "actions/MoveAction", + "actions/SetPrimaryLocationAction", "policies/CrossSpacePolicy", "services/CopyService", "services/LinkService", diff --git a/platform/features/layout/src/LayoutController.js b/platform/features/layout/src/LayoutController.js index 68de8b3bac..017793d2e6 100644 --- a/platform/features/layout/src/LayoutController.js +++ b/platform/features/layout/src/LayoutController.js @@ -45,43 +45,8 @@ define( * @param {Scope} $scope the controller's Angular scope */ function LayoutController($scope) { - var self = this; - - // Utility function to copy raw positions from configuration, - // without writing directly to configuration (to avoid triggering - // persistence from watchers during drags). - function shallowCopy(obj, keys) { - var copy = {}; - keys.forEach(function (k) { - copy[k] = obj[k]; - }); - return copy; - } - - /** - * Compute panel positions based on the layout's object model. - * Defined as member function to facilitate testing. - * @private - */ - LayoutController.prototype.layoutPanels = function layoutPanels (ids) { - var configuration = $scope.configuration || {}; - - // Pull panel positions from configuration - self.rawPositions = - shallowCopy(configuration.panels || {}, ids); - - // Clear prior computed positions - self.positions = {}; - - // Update width/height that we are tracking - self.gridSize = - ($scope.model || {}).layoutGrid || DEFAULT_GRID_SIZE; - - // Compute positions and add defaults where needed - ids.forEach(function (id, index) { - self.populatePosition(id, index); - }); - }; + var self = this, + callbackCount = 0; // Update grid size when it changed function updateGridSize(layoutGrid) { @@ -127,23 +92,26 @@ define( e.preventDefault(); } - function getComposition(domainObject){ - return domainObject.useCapability('composition'); - } - - function composeView (composition){ - $scope.composition = composition; - return composition.map(function (object) { - return object.getId(); - }) || []; - } - //Will fetch fully contextualized composed objects, and populate // scope with them. function refreshComposition() { - return getComposition($scope.domainObject) - .then(composeView) - .then(self.layoutPanels); + //Keep a track of how many composition callbacks have been made + var thisCount = ++callbackCount; + + $scope.domainObject.useCapability('composition').then(function(composition){ + var ids; + + //Is this callback for the most recent composition + // request? If not, discard it. Prevents race condition + if (thisCount === callbackCount){ + ids = composition.map(function (object) { + return object.getId(); + }) || []; + + $scope.composition = composition; + self.layoutPanels(ids); + } + }); } // End drag; we don't want to put $scope into this @@ -176,7 +144,7 @@ define( $scope.$watch("model.layoutGrid", updateGridSize); // Update composed objects on screen, and position panes - $scope.$watch("model.composition", refreshComposition); + $scope.$watchCollection("model.composition", refreshComposition); // Position panes where they are dropped $scope.$on("mctDrop", handleDrop); @@ -282,6 +250,43 @@ define( } }; + // Utility function to copy raw positions from configuration, + // without writing directly to configuration (to avoid triggering + // persistence from watchers during drags). + function shallowCopy(obj, keys) { + var copy = {}; + keys.forEach(function (k) { + copy[k] = obj[k]; + }); + return copy; + } + + /** + * Compute panel positions based on the layout's object model. + * Defined as member function to facilitate testing. + * @private + */ + LayoutController.prototype.layoutPanels = function (ids) { + var configuration = this.$scope.configuration || {}, + self = this; + + // Pull panel positions from configuration + this.rawPositions = + shallowCopy(configuration.panels || {}, ids); + + // Clear prior computed positions + this.positions = {}; + + // Update width/height that we are tracking + this.gridSize = + (this.$scope.model || {}).layoutGrid || DEFAULT_GRID_SIZE; + + // Compute positions and add defaults where needed + ids.forEach(function (id, index) { + self.populatePosition(id, index); + }); + }; + /** * End the active drag gesture. This will update the * view configuration. diff --git a/platform/features/layout/test/LayoutControllerSpec.js b/platform/features/layout/test/LayoutControllerSpec.js index eb17df62fe..bbed271d2c 100644 --- a/platform/features/layout/test/LayoutControllerSpec.js +++ b/platform/features/layout/test/LayoutControllerSpec.js @@ -33,7 +33,8 @@ define( testConfiguration, controller, mockCompositionCapability, - mockComposition; + mockComposition, + mockCompositionObjects; function mockPromise(value){ return { @@ -57,7 +58,7 @@ define( beforeEach(function () { mockScope = jasmine.createSpyObj( "$scope", - [ "$watch", "$on", "commit" ] + [ "$watch", "$watchCollection", "$on", "commit" ] ); mockEvent = jasmine.createSpyObj( 'event', @@ -67,6 +68,7 @@ define( testModel = {}; mockComposition = ["a", "b", "c"]; + mockCompositionObjects = mockComposition.map(mockDomainObject); testConfiguration = { panels: { @@ -77,7 +79,7 @@ define( } }; - mockCompositionCapability = mockPromise(mockComposition.map(mockDomainObject)); + mockCompositionCapability = mockPromise(mockCompositionObjects); mockScope.domainObject = mockDomainObject("mockDomainObject"); mockScope.model = testModel; @@ -91,14 +93,14 @@ define( // Model changes will indicate that panel positions // may have changed, for instance. it("watches for changes to composition", function () { - expect(mockScope.$watch).toHaveBeenCalledWith( + expect(mockScope.$watchCollection).toHaveBeenCalledWith( "model.composition", jasmine.any(Function) ); }); it("Retrieves updated composition from composition capability", function () { - mockScope.$watch.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); expect(mockScope.domainObject.useCapability).toHaveBeenCalledWith( "composition" ); @@ -107,8 +109,32 @@ define( ); }); + it("Is robust to concurrent changes to composition", function () { + var secondMockComposition = ["a", "b", "c", "d"], + secondMockCompositionObjects = secondMockComposition.map(mockDomainObject), + firstCompositionCB, + secondCompositionCB; + + spyOn(mockCompositionCapability, "then"); + mockScope.$watchCollection.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); + + firstCompositionCB = mockCompositionCapability.then.calls[0].args[0]; + secondCompositionCB = mockCompositionCapability.then.calls[1].args[0]; + + //Resolve promises in reverse order + secondCompositionCB(secondMockCompositionObjects); + firstCompositionCB(mockCompositionObjects); + + //Expect the promise call that was initiated most recently to + // be the one used to populate scope, irrespective of order that + // it was eventually resolved + expect(mockScope.composition).toBe(secondMockCompositionObjects); + }); + + it("provides styles for frames, from configuration", function () { - mockScope.$watch.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); expect(controller.getFrameStyle("a")).toEqual({ top: "320px", left: "640px", @@ -121,7 +147,7 @@ define( var styleB, styleC; // b and c do not have configured positions - mockScope.$watch.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); styleB = controller.getFrameStyle("b"); styleC = controller.getFrameStyle("c"); @@ -138,7 +164,7 @@ define( it("allows panels to be dragged", function () { // Populate scope - mockScope.$watch.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); // Verify precondtion expect(testConfiguration.panels.b).not.toBeDefined(); @@ -157,7 +183,7 @@ define( it("invokes commit after drag", function () { // Populate scope - mockScope.$watch.mostRecentCall.args[1](); + mockScope.$watchCollection.mostRecentCall.args[1](); // Do a drag controller.startDrag("b", [1, 1], [0, 0]); @@ -218,7 +244,7 @@ define( // White-boxy; we know which watch is which mockScope.$watch.calls[0].args[1](testModel.layoutGrid); - mockScope.$watch.calls[1].args[1](testModel.composition); + mockScope.$watchCollection.calls[0].args[1](testModel.composition); styleB = controller.getFrameStyle("b"); diff --git a/platform/features/plot/src/MCTChart.js b/platform/features/plot/src/MCTChart.js index e8c9db74e4..05e45a2948 100644 --- a/platform/features/plot/src/MCTChart.js +++ b/platform/features/plot/src/MCTChart.js @@ -146,6 +146,7 @@ define( if (canvas.width !== canvas.offsetWidth || canvas.height !== canvas.offsetHeight) { doDraw(scope.draw); + scope.$apply(); } } @@ -181,7 +182,7 @@ define( canvas.addEventListener("webglcontextlost", fallbackFromWebGL); // Check for resize, on a timer - activeInterval = $interval(drawIfResized, 1000); + activeInterval = $interval(drawIfResized, 1000, 0, false); // Watch "draw" for external changes to the set of // things to be drawn. diff --git a/platform/features/plot/test/MCTChartSpec.js b/platform/features/plot/test/MCTChartSpec.js index 9c60b034a6..2277b65975 100644 --- a/platform/features/plot/test/MCTChartSpec.js +++ b/platform/features/plot/test/MCTChartSpec.js @@ -45,8 +45,10 @@ define( jasmine.createSpy("$interval"); mockLog = jasmine.createSpyObj("$log", ["warn", "info", "debug"]); - mockScope = - jasmine.createSpyObj("$scope", ["$watchCollection", "$on"]); + mockScope = jasmine.createSpyObj( + "$scope", + ["$watchCollection", "$on", "$apply"] + ); mockElement = jasmine.createSpyObj("element", ["find", "html"]); mockInterval.cancel = jasmine.createSpy("cancelInterval"); @@ -152,7 +154,9 @@ define( // Should track canvas size in an interval expect(mockInterval).toHaveBeenCalledWith( jasmine.any(Function), - jasmine.any(Number) + jasmine.any(Number), + 0, + false ); // Verify pre-condition diff --git a/platform/forms/src/MCTControl.js b/platform/forms/src/MCTControl.js index b46ba6e7a1..78ac8c194d 100644 --- a/platform/forms/src/MCTControl.js +++ b/platform/forms/src/MCTControl.js @@ -79,6 +79,9 @@ define( // Used to choose which form control to use key: "=", + // Allow controls to trigger blur-like events + ngBlur: "&", + // The state of the form value itself ngModel: "=", diff --git a/platform/persistence/elastic/src/ElasticIndicator.js b/platform/persistence/elastic/src/ElasticIndicator.js index 78a29605c1..767c37c8cb 100644 --- a/platform/persistence/elastic/src/ElasticIndicator.js +++ b/platform/persistence/elastic/src/ElasticIndicator.js @@ -80,7 +80,7 @@ define( // Update the indicator initially, and start polling. updateIndicator(); - $interval(updateIndicator, interval, false); + $interval(updateIndicator, interval, 0, false); } ElasticIndicator.prototype.getGlyph = function () { diff --git a/platform/persistence/elastic/test/ElasticIndicatorSpec.js b/platform/persistence/elastic/test/ElasticIndicatorSpec.js index 196bc77a83..63aa49f0ac 100644 --- a/platform/persistence/elastic/test/ElasticIndicatorSpec.js +++ b/platform/persistence/elastic/test/ElasticIndicatorSpec.js @@ -55,6 +55,7 @@ define( expect(mockInterval).toHaveBeenCalledWith( jasmine.any(Function), testInterval, + 0, false ); }); diff --git a/platform/representation/src/MCTRepresentation.js b/platform/representation/src/MCTRepresentation.js index 5fab004205..33c203322b 100644 --- a/platform/representation/src/MCTRepresentation.js +++ b/platform/representation/src/MCTRepresentation.js @@ -97,7 +97,7 @@ define( counter = 0, couldRepresent = false, couldEdit = false, - lastId, + lastIdPath = [], lastKey, changeTemplate = templateLinker.link($scope, element); @@ -144,15 +144,31 @@ define( }); } - function unchanged(canRepresent, canEdit, id, key) { + function unchanged(canRepresent, canEdit, idPath, key) { return canRepresent && couldRepresent && - id === lastId && key === lastKey && + idPath.length === lastIdPath.length && + idPath.every(function (id, i) { + return id === lastIdPath[i]; + }) && canEdit && couldEdit; } + function getIdPath(domainObject) { + if (!domainObject) { + return []; + } + if (!domainObject.hasCapability('context')) { + return [domainObject.getId()]; + } + return domainObject.getCapability('context') + .getPath().map(function (pathObject) { + return pathObject.getId(); + }); + } + // General-purpose refresh mechanism; should set up the scope // as appropriate for current representation key and // domain object. @@ -163,10 +179,10 @@ define( uses = ((representation || {}).uses || []), canRepresent = !!(path && domainObject), canEdit = !!(domainObject && domainObject.hasCapability('editor')), - id = domainObject && domainObject.getId(), + idPath = getIdPath(domainObject), key = $scope.key; - if (unchanged(canRepresent, canEdit, id, key)) { + if (unchanged(canRepresent, canEdit, idPath, key)) { return; } @@ -194,8 +210,8 @@ define( // To allow simplified change detection next time around couldRepresent = canRepresent; + lastIdPath = idPath; couldEdit = canEdit; - lastId = id; lastKey = key; // Populate scope with fields associated with the current diff --git a/platform/representation/test/MCTRepresentationSpec.js b/platform/representation/test/MCTRepresentationSpec.js index 30fed7c0ca..141f2227f3 100644 --- a/platform/representation/test/MCTRepresentationSpec.js +++ b/platform/representation/test/MCTRepresentationSpec.js @@ -247,6 +247,54 @@ define( mockScope.$watch.calls[0].args[1](); expect(mockScope.testCapability).toBeUndefined(); }); + + it("detects changes among linked instances", function () { + var mockContext = jasmine.createSpyObj('context', ['getPath']), + mockContext2 = jasmine.createSpyObj('context', ['getPath']), + mockLink = jasmine.createSpyObj( + 'linkedObject', + DOMAIN_OBJECT_METHODS + ), + mockParent = jasmine.createSpyObj( + 'parentObject', + DOMAIN_OBJECT_METHODS + ), + callCount; + + mockDomainObject.getCapability.andCallFake(function (c) { + return c === 'context' && mockContext; + }); + mockLink.getCapability.andCallFake(function (c) { + return c === 'context' && mockContext2; + }); + mockDomainObject.hasCapability.andCallFake(function (c) { + return c === 'context'; + }); + mockLink.hasCapability.andCallFake(function (c) { + return c === 'context'; + }); + mockLink.getModel.andReturn({}); + + mockContext.getPath.andReturn([mockDomainObject]); + mockContext2.getPath.andReturn([mockParent, mockLink]); + + mockLink.getId.andReturn('test-id'); + mockDomainObject.getId.andReturn('test-id'); + + mockParent.getId.andReturn('parent-id'); + + mockScope.key = "abc"; + mockScope.domainObject = mockDomainObject; + + mockScope.$watch.calls[0].args[1](); + callCount = mockChangeTemplate.calls.length; + + mockScope.domainObject = mockLink; + mockScope.$watch.calls[0].args[1](); + + expect(mockChangeTemplate.calls.length) + .toEqual(callCount + 1); + }); }); } ); diff --git a/pom.xml b/pom.xml index 3c1ece26b4..e28cbdd732 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ gov.nasa.arc.wtd open-mct-web Open MCT Web - 0.8.2-SNAPSHOT + 0.8.3-SNAPSHOT war