diff --git a/Procfile b/Procfile index aa6094edfe..1e13b4ae05 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node app.js --port $PORT --include example/localstorage \ No newline at end of file +web: node app.js --port $PORT diff --git a/bundles.json b/bundles.json index a0f7edd7a8..291553ba11 100644 --- a/bundles.json +++ b/bundles.json @@ -6,10 +6,12 @@ "platform/commonUI/browse", "platform/commonUI/edit", "platform/commonUI/dialog", + "platform/commonUI/formats", "platform/commonUI/general", "platform/commonUI/inspect", "platform/commonUI/mobile", "platform/commonUI/themes/espresso", + "platform/commonUI/notification", "platform/containment", "platform/execution", "platform/telemetry", diff --git a/docs/gendocs.js b/docs/gendocs.js index 2fcda7214e..30182e30e7 100644 --- a/docs/gendocs.js +++ b/docs/gendocs.js @@ -30,7 +30,8 @@ var CONSTANTS = { DIAGRAM_WIDTH: 800, DIAGRAM_HEIGHT: 500 - }; + }, + TOC_HEAD = "# Table of Contents"; GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be defined (function () { @@ -44,6 +45,7 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define split = require("split"), stream = require("stream"), nomnoml = require('nomnoml'), + toc = require("markdown-toc"), Canvas = require('canvas'), options = require("minimist")(process.argv.slice(2)); @@ -110,6 +112,9 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define done(); }; transform._flush = function (done) { + // Prepend table of contents + markdown = + [ TOC_HEAD, toc(markdown).content, "", markdown ].join("\n"); this.push("\n"); this.push(marked(markdown)); this.push("\n\n"); @@ -133,8 +138,8 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define customRenderer.link = function (href, title, text) { // ...but only if they look like relative paths return (href || "").indexOf(":") === -1 && href[0] !== "/" ? - renderer.link(href.replace(/\.md/, ".html"), title, text) : - renderer.link.apply(renderer, arguments); + renderer.link(href.replace(/\.md/, ".html"), title, text) : + renderer.link.apply(renderer, arguments); }; return customRenderer; } @@ -179,13 +184,17 @@ GLOBAL.window = GLOBAL.window || GLOBAL; // nomnoml expects window to be define glob(options['in'] + "/**/*.@(html|css|png)", {}, function (err, files) { files.forEach(function (file) { var destination = file.replace(options['in'], options.out), - destPath = path.dirname(destination); - + destPath = path.dirname(destination), + streamOptions = {}; + if (file.match(/png$/)){ + streamOptions.encoding = null; + } else { + streamOptions.encoding = 'utf8'; + } + mkdirp(destPath, function (err) { - fs.createReadStream(file, { encoding: 'utf8' }) - .pipe(fs.createWriteStream(destination, { - encoding: 'utf8' - })); + fs.createReadStream(file, streamOptions) + .pipe(fs.createWriteStream(destination, streamOptions)); }); }); }); diff --git a/docs/src/architecture/Platform.md b/docs/src/architecture/Platform.md index 80f9e487f5..a59a6ebf9c 100644 --- a/docs/src/architecture/Platform.md +++ b/docs/src/architecture/Platform.md @@ -35,16 +35,26 @@ in __any of these tiers__. * _DOM_: The rendered HTML document, composed from HTML templates which have been processed by AngularJS and will be updated by AngularJS to reflect changes from the presentation layer. User interactions - are initiated from here and invoke behavior in the presentation layer. + are initiated from here and invoke behavior in the presentation layer. HTML  + templates are written in Angular’s template syntax; see the [Angular documentation on templates](https://docs.angularjs.org/guide/templates)​.  + These describe the page as actually seen by the user. Conceptually,  + stylesheets (controlling the look­and­feel of the rendered templates) belong  + in this grouping as well.  * [_Presentation layer_](#presentation-layer): The presentation layer is responsible for updating (and providing information to update) the displayed state of the application. The presentation layer consists primarily of _controllers_ and _directives_. The presentation layer is concerned with inspecting the information model and preparing it for display. -* [_Information model_](#information-model): The information model - describes the state and behavior of the objects with which the user - interacts. +* [_Information model_](#information-model): ​Provides a common (within Open MCT  + Web) set of interfaces for dealing with “things” ­ domain objects ­ within the  + system. User­facing concerns in a Open MCT Web application are expressed as  + domain objects; examples include folders (used to organize other domain  + objects), layouts (used to build displays), or telemetry points (used as  + handles for streams of remote measurements.) These domain objects expose a  + common set of interfaces to allow reusable user interfaces to be built in the  + presentation and template tiers; the specifics of these behaviors are then  + mapped to interactions with underlying services.  * [_Service infrastructure_](#service-infrastructure): The service infrastructure is responsible for providing the underlying general functionality needed to support the information model. This includes @@ -52,7 +62,9 @@ in __any of these tiers__. back-end. * _Back-end_: The back-end is out of the scope of Open MCT Web, except for the interfaces which are utilized by adapters participating in the - service infrastructure. + service infrastructure. Includes the underlying persistence stores, telemetry  + streams, and so forth which the Open MCT Web client is being used to interact  + with. ## Application Start-up diff --git a/docs/src/guide/index.md b/docs/src/guide/index.md index c575439d48..7b35dd66cc 100644 --- a/docs/src/guide/index.md +++ b/docs/src/guide/index.md @@ -1,3 +1,2334 @@ -# Developer Guide +# Open MCT Web Developer Guide +Victor Woeltjen -This is a placeholder for the developer guide. +[victor.woeltjen@nasa.gov](mailto:victor.woeltjen@nasa.gov) + +September 23, 2015 +Document Version 1.1 + +Date | Version | Summary of Changes | Author +------------------- | --------- | ----------------------- | --------------- +April 29, 2015 | 0 | Initial Draft | Victor Woeltjen +May 12, 2015 | 0.1 | | Victor Woeltjen +June 4, 2015 | 1.0 | Name Changes | Victor Woeltjen +October 4, 2015 | 1.1 | Conversion to MarkDown | Andrew Henry + +# Introduction +The purpose of this guide is to familiarize software developers with the Open +MCT Web platform. + +## What is Open MCT Web +Open MCT Web is a platform for building user interface and display tools, +developed at the NASA Ames Research Center in collaboration with teams at the +Jet Propulsion Laboratory. It is written in HTML5, CSS3, and JavaScript, using +[AngularJS](http://www.angularjs.org) as a framework. Its intended use is to +create single-page web applications which integrate data and behavior from a +variety of sources and domains. + +Open MCT Web has been developed to support the remote operation of space +vehicles, so some of its features are specific to that task; however, it is +flexible enough to be adapted to a variety of other application domains where a +display tool oriented toward browsing, composing, and visualizing would be +useful. + +Open MCT Web provides: + +* A common user interface paradigm which can be applied to a variety of domains +and tasks. Open MCT Web is more than a widget toolkit - it provides a standard +tree-on-the-left, view-on-the-right browsing environment which you customize by +adding new browsable object types, visualizations, and back-end adapters. +* A plugin framework and an extensible API for introducing new application +features of a variety of types. +* A set of general-purpose object types and visualizations, as well as some +visualizations and infrastructure specific to telemetry display. + +## Client-Server Relationship +Open MCT Web is client software - it runs entirely in the user's web browser. As +such, it is largely 'server agnostic'; any web server capable of serving files +from paths is capable of providing Open MCT Web. + +While Open MCT Web can be configured to run as a standalone client, this is +rarely very useful. Instead, it is intended to be used as a display and +interaction layer for information obtained from a variety of back-end services. +Doing so requires authoring or utilizing adapter plugins which allow Open MCT +Web to interact with these services. + +Typically, the pattern here is to provide a known interface that Open MCT Web +can utilize, and implement it such that it interacts with whatever back-end +provides the relevant information. Examples of back-ends that can be utilized in +this fashion include databases for the persistence of user-created objects, or +sources of telemetry data. + +See the [Architecture Guide](../architecture/index.md#Overview) for information +on the client-server relationship. + +## Developing with Open MCT Web +Building applications with Open MCT Web typically means authoring and utilizing +a set of plugins which provide application-specific details about how Open MCT +Web should behave. + +### Technologies + +Open MCT Web sources are written in JavaScript, with a number of configuration +files written in JSON. Displayable components are written in HTML5 and CSS3. +Open MCT Web is built using [AngularJS](http://www.angularjs.org) from Google. A +good understanding of Angular is recommended for developers working with Open +MCT Web. + +### Forking +Open MCT Web does not currently have a single stand-alone artifact that can be +used as a library. Instead, the recommended approach for creating a new +application is to start by forking/branching Open MCT Web, and then adding new +features from there. Put another way, Open MCT Web's source structure is built +to serve as a template for specific applications. + +Forking in this manner should not require that you edit Open MCT Web's sources. +The preferred approach is to create a new directory (peer to `index.html`) for +the new application, then add new bundles (as described in the Framework +chapter) within that directory. + +To initially clone the Open MCT Web repository: +`git clone -b open-master` + +To create a fork to begin working on a new application using Open MCT Web: + + cd + git checkout open-master + git checkout -b + +As a convention used internally, applications built using Open MCT Web have +master branch names with an identifying prefix. For instance, if building an +application called 'Foo', the last statement above would look like: + + git checkout -b foo-master + +This convention is not enforced or understood by Open MCT Web in any way; it is +mentioned here as a more general recommendation. + +# Overview + +Open MCT Web is implemented as a framework component which manages a set of +other components. These components, called _bundles_, act as containers to group +sets of related functionality; individual units of functionality are expressed +within these bundles as _extensions_. + +Extensions declare dependencies on other extensions (either individually or +categorically), and the framework provides actual extension instances at +run-time to satisfy these declared dependency. This dependency injection +approach allows software components which have been authored separately (e.g. as +plugins) but to collaborate at run-time. + +Open MCT Web's framework layer is implemented on top of AngularJS's [dependency +injection mechanism](https://docs.angularjs.org/guide/di) and is modelled after +[OSGi](hhttp://www.osgi.org/) and its [Declarative Services component model](http://wiki.osgi.org/wiki/Declarative_Services). +In particular, this is where the term _bundle_ comes from. + +## Framework Overview + +The framework's role in the application is to manage connections between +bundles. All application-specific behavior is provided by individual bundles, or +as the result of their collaboration. + +The framework is described in more detail in the [Framework Overview](../architecture/Framework.md#Overview) of the +architecture guide. + +### Tiers +While all bundles in a running Open MCT Web instance are effectively peers, it +is useful to think of them as a tiered architecture, where each tier adds more +specificity to the application. +```nomnoml +#direction: down +[Plugins (Features external to OpenMCTWeb) *Bundle]->[OpenMCTWeb | +[Application (Plots, layouts, ElasticSearch wrapper) *Bundle]->[Platform (Core API, common UI, infrastructure) *Bundle] +[Platform (Core API, common UI, infrastructure) *Bundle]->[Framework (RequireJS, AngularJS, bundle loader)]] +``` + +* __Framework__ : This tier is responsible for wiring together the set of +configured components (called _bundles_) together to instantiate the running +application. It is responsible for mediating between AngularJS (in particular, +its dependency injection mechanism) and RequireJS (to load scripts at run-time.) +It additionally interprets bundle definitions (see explanation below, as well as +further detail in the Framework chapter.) At this tier, we are at our most +general: We know only that we are a plugin-based application. +* __Platform__: Components in the Platform tier describe both the general user +interface and corresponding developer-facing interfaces of Open MCT Web. This +tier provides the general infrastructure for applications. It is less general +than the framework tier, insofar as this tier introduces a specific user +interface paradigm, but it is still non-specific as to what useful features +will be provided. Although they can be removed or replaced easily, bundles +provided by the Platform tier generally should not be thought of as optional. +* __Application__: The application tier consists of components which utilize the +infrastructure provided by the Platform to provide functionality which will (or +could) be useful to specific applications built using Open MCT Web. These +include adapters to specific persistence back-ends (such as ElasticSearch or +CouchDB) as well as bundles which describe more user-facing features (such as +_Plot_ views for visualizing time series data, or _Layout_ objects for +display-building.) Bundles from this tier can be added or removed without +compromising basic application functionality, with the caveat that at least one +persistence adapter needs to be present. +* __Plugins__: Conceptually, this tier is not so different from the application +tier; it consists of bundles describing new features, back-end adapters, that +are specific to the application being built on Open MCT Web. It is described as +a separate tier here because it has one important distinction from the +application tier: It consists of bundles that are not included with the platform +(either authored anew for the specific application, or obtained from elsewhere.) + +Note that bundles in any tier can go off and consult back-end services. In +practice, this responsibility is handled at the Application and/or Plugin tiers; +Open MCT Web is built to be server-agnostic, so any back-end is considered an +application-specific detail. + +## Platform Overview + +The "tiered" architecture described in the preceding text describes a way of +thinking of and categorizing software components of a Open MCT Web application, +as well as the framework layer's role in mediating between these components. +Once the framework layer has wired these software components together, however, +the application's logical architecture emerges. + +An overview of the logical architecture of the platform is given in the [Platform Architecture](../architecture/Platform.md#PlatformArchitecture) +section of the Platform guide + +### Web Services + +As mentioned in the Introduction, Open MCT Web is a platform single-page +applications which runs entirely in the browser. Most applications will want to +additionally interact with server-side resources, to (for example) read +telemetry data or store user-created objects. This interaction is handled by +individual bundles using APIs which are supported in browser (such as +`XMLHttpRequest`, typically wrapped by Angular's `$http`.) + +```nomnoml +#direction: right +[Web Service #1] <- [Web Browser] +[Web Service #2] <- [Web Browser] +[Web Service #3] <- [Web Browser] +[ Web Browser | + [ Open MCT Web | + [Plugin Bundle #1]-->[Core API] + [Core API]<--[Plugin Bundle #2] + [Platform Bundle #1]-->[Core API] + [Platform Bundle #2]-->[Core API] + [Platform Bundle #3]-->[Core API] + [Core API]<--[Platform Bundle #4] + [Core API]<--[Platform Bundle #5] + [Core API]<--[Plugin Bundle #3] + ] + [Open MCT Web] ->[Browser APIs] +] +``` + +This architectural approach ensures a loose coupling between applications built +using Open MCT Web and the backends which support them. + +### Glossary + +Certain terms are used throughout Open MCT Web with consistent meanings or +conventions. Other developer documentation, particularly in-line documentation, +may presume an understanding of these terms. + +* __bundle__: A bundle is a removable, reusable grouping of software elements. +The application is composed of bundles. Plug-ins are bundles. +* __capability__: A JavaScript object which exposes dynamic behavior or +non-persistent state associated with a domain object. +* __category__: A machine-readable identifier for a group that something may +belong to. +* __composition __: In the context of a domain object, this refers to the set of +other domain objects that compose or are contained by that object. A domain +object's composition is the set of domain objects that should appear immediately + beneath it in a tree hierarchy. A domain object's composition is described in +its model as an array of identifiers; its composition capability provides a +means to retrieve the actual domain object instances associated with these +identifiers asynchronously. +* __description__: When used as an object property, this refers to the human- +readable description of a thing; usually a single sentence or short paragraph. +(Most often used in the context of extensions, domain object models, or other +similar application-specific objects.) +* __domain object __: A meaningful object to the user; a distinct thing in the +work support by Open MCT Web. Anything that appears in the left-hand tree is a +domain object. +* __extension __: An extension is a unit of functionality exposed to the platform +in a declarative fashion by a bundle. The term 'extension category' is used to +distinguish types of extensions from specific extension instances. +* __id__: A string which uniquely identifies a domain object. +* __key__: When used as an object property, this refers to the machine-readable +identifier for a specific thing in a set of things. (Most often used in the +context of extensions or other similar application-specific object sets.) This +term is chosen to avoid attaching ambiguous meanings to 'id'. +* __model__: The persistent state associated with a domain object. A domain +object's model is a JavaScript object which can be converted to JSON without +losing information (that is, it contains no methods.) +* __name__: When used as an object property, this refers to the human-readable +name for a thing. (Most often used in the context of extensions, domain object +models, or other similar application-specific objects.) +* __navigation__: Refers to the current state of the application with respect to +the user's expressed interest in a specific domain object; e.g. when a user +clicks on a domain object in the tree, they are navigating to it, and it is +thereafter considered the navigated object (until the user makes another such +choice.) This term is used to distinguish navigation from selection, which +occurs in an editing context. +* __space__: A machine-readable name used to identify a persistence store. +Interactions with persistence with generally involve a space parameter in some +form, to distinguish multiple persistence stores from one another (for cases +where there are multiple valid persistence locations available.) +* __source__: A machine-readable name used to identify a source of telemetry +data. Similar to "space", this allows multiple telemetry sources to operate +side-by-side without conflicting. + +# Framework + +Open MCT Web is built on the [AngularJS framework]( http://www.angularjs.org ). A +good understanding of that framework is recommended. + +Open MCT Web adds an extra layer on top of AngularJS to (a) generalize its +dependency injection mechanism slightly, particularly to handle many-to-one +relationships; and (b) handle script loading. Combined, these features become a +plugin mechanism. + +This framework layer operates on two key concepts: + +* __Bundle:__ A bundle is a collection of related functionality that can be +added to the application as a group. More concretely, a bundle is a directory +containing a JSON file declaring its contents, as well as JavaScript sources, +HTML templates, and other resources used to support that functionality. (The +term bundle is borrowed from [OSGi](http://www.osgi.org/) - which has also +inspired many of the concepts used in the framework layer. A familiarity with +OSGi, particularly Declarative Services, may be useful when working with Open +MCT Web.) +* __Extension:__ An extension is an individual unit of functionality. Extensions +are collected together in bundles, and may interact with other extensions. + +The framework layer, loaded and initiated from `index.html`, is the main point +of entry for an application built on Open MCT Web. It is responsible for wiring +together the application at run time (much of this responsibility is actually +delegated to Angular); at a high-level, the framework does this by proceeding +through four stages: + +1. __Loading definitions:__ JSON declarations are loaded for all bundles which +will constitute the application, and wrapped in a useful API for subsequent +stages. +2. __Resolving extensions:__ Any scripts which provide implementations for +extensions exposed by bundles are loaded, using Require. +3. __Registering extensions__ Resolved extensions are registered with Angular, +such that they can be used by the application at run-time. This stage includes +both registration of Angular built-ins (directives, controllers, routes, +constants, and services) as well as registration of non-Angular extensions. +4. __Bootstrapping__ The Angular application is bootstrapped; at that point, +Angular takes over and populates the body of the page using the extensions that +have been registered. + +## Bundles + +The basic configurable unit of Open MCT Web is the _bundle_. This term has been +used a bit already; now we'll get to a more formal definition. + +A bundle is a directory which contains: + +* A bundle definition; a file named `bundle.json`. +* Subdirectories for sources, resources, and tests. +* Optionally, a `README.md` Markdown file describing its contents (this is not +used by Open MCT Web in any way, but it's a helpful convention to follow.) + +The bundle definition is the main point of entry for the bundle. The framework +looks at this to determine which components need to be loaded and how they +interact. + +A plugin in Open MCT Web is a bundle. The platform itself is also decomposed +into bundles, each of which provides some category of functionality. The +difference between a _bundle_ and a _plugin_ is purely a matter of the intended +use; a plugin is just a bundle that is meant to be easily added or removed. When +developing, it is typically more useful to think in terms of bundles. + +### Configuring Active Bundles + +To decide which bundles should be loaded, the framework loads a file named +`bundles.json` (peer to the `index.html` file which serves the application) to +determine which bundles should be loaded. This file should contain a single JSON +array of strings, where each is the path to a bundle. These paths should not +include bundle.json (this is implicit) or a trailing slash. + +For instance, if `bundles.json` contained: + + [ + "example/builtins", + "example/extensions" + ] + +...then the Open MCT Web framework would look for bundle definitions at +`example/builtins/bundle.json` and `example/extensions/bundle.json`, relative +to the path of `index.html`. No other bundles would be loaded. + +### Bundle Definition + +A bundle definition (the `bundle.json` file located within a bundle) contains a +description of the bundle itself, as well as the information exposed by the +bundle. + +This definition is expressed as a single JSON object with the following +properties (all of which are optional, falling back to reasonable defaults): + +* `key`: A machine-readable name for the bundle. (Currently used only in +logging.) +* `name`: A human-readable name for the bundle. (Also only used in logging.) +* `sources`: Names a directory in which source scripts (which will implement +extensions) are located. Defaults to 'src' +* `resources`: Names a directory in which resource files (such as HTML templates, +images, CS files, and other non-JavaScript files needed by this bundle) are +located. Defaults to 'res' +* `libraries`: Names a directory in which third-party libraries are located. +Defaults to 'lib' +* `configuration`: A bundle's configuration object, which should be formatted as +would be passed to require.config (see [RequireJS documentation](http://requirejs.org/docs/api.html ) ); +note that only paths and shim have been tested. +* `extensions`: An object containing key-value pairs, where keys are extension +categories, and values are extension definitions. See the section on Extensions +for more information. + +For example, the bundle definition for example/policy looks like: + + { + "name": "Example Policy", + "description": "Provides an example of using policies.", + "sources": "src", + "extensions": { + "policies": [ + { + "implementation": "ExamplePolicy.js", + "category": "action" + } + ] + } + } + +### Bundle Directory Structure + +In addition to the directories defined in the bundle definition, a bundle will +typically contain other directories not used at run-time. Additionally, some +useful development scripts (such as the command line build and the test suite) +expect this directory structure to be in use, and may ignore options chosen by +`b undle.json`. It is recommended that the directory structure described below be +used for new bundles. + +* `src`: Contains JavaScript sources for this bundle. May contain additional +subdirectories to organize these sources; typically, these subdirectories are +named to correspond to the extension categories they contain and/or support, but +this is only a convention. +* `res`: Contains other files needed by this bundle, such as HTML templates. May +contain additional subdirectories to organize these sources. +* `lib`: Contains JavaScript sources from third-party libraries. These are +separated from bundle sources in order to ignore them during code style checking +from the command line build. +* `test`: Contains JavaScript sources implementing [Jasmine](http://jasmine.github.io/) +tests, as well as a file named `suite.json` describing which files to test. +Should have the same folder structure as the `src` directory; see the section on +automated testing for more information. + +For example, the directory structure for bundle `platform/commonUI/about` looks +like: + + Platform + | + |-commonUI + | + +-about + | + |-res + | + |-src + | + |-test + | + |-bundle.json + | + +-README.md + +## Extensions + +While bundles provide groupings of related behaviors, the individual units of +behavior are called extensions. + +Extensions belong to categories; an extension category is the machine-readable +identifier used to identify groups of extensions. In the `extensions` property +of a bundle definition, the keys are extension categories and the values are +arrays of extension definitions. + +### General Extensions + +Extensions are intended as a general-purpose mechanism for adding new types of +functionality to Open MCT Web. + +An extension category is registered with Angular under the name of the +extension, plus a suffix of two square brackets; so, an Angular service (or, +generally, any other extension) can access the full set of registered +extensions, from all bundles, by including this string (e.g. `types[]` to get +all type definitions) in a dependency declaration. + +As a convention, extension categories are given single-word, plural nouns for +names within Open MCT Web (e.g. `types`.) This convention is not enforced by the +platform in any way. For extension categories introduced by external plugins, it +is recommended to prefix the extension category with a vendor identifier (or +similar) followed by a dot, to avoid collisions. + +### Extension Definitions + +The properties used in extension definitions are typically unique to each +category of extension; a few properties have standard interpretations by the +platform. + +* `implementation`: Identifies a JavaScript source file (in the sources +folder) which implements this extension. This JavaScript file is expected to +contain an AMD module (see http://requirejs.org/docs/whyamd.html#amd ) which +gives as its result a single constructor function. +* `depends`: An array of dependencies needed by this extension; these will be +passed on to Angular's [dependency injector](https://docs.angularjs.org/guide/di ) . +By default, this is treated as an empty array. Note that depends does not make +sense without `implementation` (since these dependencies will be passed to the +implementation when it is instantiated.) +* `priority`: A number or string indicating the priority order (see below) of +this extension instance. Before an extension category is registered with +AngularJS, the extensions of this category from all bundles will be concatenated +into a single array, and then sorted by priority. + +Extensions do not need to have an implementation. If no implementation is +provided, consumers of the extension category will receive the extension +definition as a plain JavaScript object. Otherwise, they will receive the +partialized (see below) constructor for that implementation, which will +additionally have all properties from the extension definition attached. + +#### Partial Construction + +In general, extensions are intended to be implemented as constructor functions, +which will be used elsewhere to instantiate new objects of that type. However, +the Angular-supported method for dependency injection is (effectively) +constructor-style injection; so, both declared dependencies and run-time +arguments are competing for space in a constructor's arguments. + +To resolve this, the Open MCT Web framework registers extension instances in a +partially constructed form. That is, the constructor exposed by the extension's +implementation is effectively decomposed into two calls; the first takes the +dependencies, and returns the constructor in its second form, which takes the +remaining arguments. + +This means that, when writing implementations, the constructor function should +be written to include all declared dependencies, followed by all run-time +arguments. When using extensions, only the run-time arguments need to be +provided. + +#### Priority + +Within each extension category, registration occurs in priority order. An +extension's priority may be specified as a `priority` property in its extension +definition; this may be a number, or a symbolic string. Extensions are +registered in reverse order (highest-priority first), and symbolic strings are +mapped to the numeric values as follows: + +* `fallback`: Negative infinity. Used for extensions that are not intended for +use (that is, they are meant to be overridden) but are present as an option of +last resort. +* `default`: `-100`. Used for extensions that are expected to be overridden, but +need a useful default. +* `none`: `0`. Also used if no priority is specified, or if an unknown or +malformed priority is specified. +* `optional`: `100`. Used for extensions that are meant to be used, but may be +overridden. +* `preferred`: `1000`. Used for extensions that are specifically intended to be +used, but still may be overridden in principle. +* `mandatory`: Positive infinity. Used when an extension should definitely not +be overridden. + +These symbolic names are chosen to support usage where many extensions may +satisfy a given need, but only one may be used; in this case, as a convention it +should be the lowest-ordered (highest-priority) extensions available. In other +cases, a full set (or multi-element subset) of extensions may be desired, with a +specific ordering; in these cases, it is preferable to specify priority +numerically when declaring extensions, and to understand that extensions will be +sorted according to these conventions when using them. + +### Angular Built-ins + +Several entities supported Angular are expressed and managed as extensions in +Open MCT Web. Specifically, these extension categories are _directives_, +_controllers_, _services_, _constants_, _runs_, and _routes_. + +#### Angular Directives + +New [directives]( https://docs.angularjs.org/guide/directive ) may be +registered as extensions of the directives category. Implementations of +directives in this category should take only dependencies as arguments, and +should return a directive definition object. + +The directive's name should be provided as a key property of its extension +definition, in camel-case format. + +#### Angular Controllers + +New [controllers]( https://docs.angularjs.org/guide/controller ) may be registered +as extensions of the controllers category. The implementation is registered +directly as the controller; its only constructor arguments are its declared +dependencies. + +The directive's identifier should be provided as a key property of its extension +definition. + + +#### Angular Services + +New [services](https://docs.angularjs.org/guide/services ) may be registered as +extensions of the services category. The implementation is registered via a +[service call]( https://docs.angularjs.org/api/auto/service/$provide#service ), so +it will be instantiated with the new operator. + +#### Angular Constants + +Constant values may be registered as extensions of the [ constants category](https://docs.angularjs.org/api/ng/type/angular.Module#constant ). +These extensions have no implementation; instead, they should contain a property + key , which is the name under which the constant will be registered, and a +property value , which is the constant value that will be registered. + +#### Angular Runs + +In some cases, you want to register code to run as soon as the application +starts; these can be registered as extensions of the [ runs category](https://docs.angularjs.org/api/ng/type/angular.Module#run ). +Implementations registered in this category will be invoked (with their declared +dependencies) when the Open MCT Web application first starts. (Note that, in +this case, the implementation is better thought of as just a function, as +opposed to a constructor function.) + +#### Angular Routes + +Extensions of category `routes` will be registered with Angular's [route provider](https://docs.angularjs.org/api/ngRoute/provider/$routeProvider ). +Extensions of this category have no implementations, and need only two +properties in their definition: + +* `when`: The value that will be passed as the path argument to `$routeProvider.when`; +specifically, the string that will appear in the trailing +part of the URL corresponding to this route. This property may be omitted, in +which case this extension instance will be treated as the default route. +* `templateUrl`: A path to the template to render for this route. Specified as a +path relative to the bundle's resource directory (`res` by default.) + +### Composite Services + +Composite services are described in the [relevant section](../architecture/Framework.md#Composite-Services) +of the framework guide. + +A component should include the following properties in its extension definition: + +* `provides`: The symbolic identifier for the service that will be composed. The + fully-composed service will be registered with Angular under this name. +* `type`: One of `provider`, `aggregator` or `decorator` (as above) + +In addition to any declared dependencies, _aggregators_ and _decorators_ both +receive one more argument (immediately following declared dependencies) that is +provided by the framework. For an aggregator, this will be an array of all +providers of the same service (that is, with matching `provides` properties); +for a decorator, this will be whichever provider, decorator, or aggregator is +next in the sequence of decorators. + +Services exposed by the Open MCT Web platform are often declared as composite +services, as this form is open for a variety of common modifications. + +# Core API + +Most of Open MCT Web's relevant API is provided and/or mediated by the +framework; that is, much of developing for Open MCT Web is a matter of adding +extensions which access other parts of the platform by means of dependency +injection. + +The core bundle (`platform/core`) introduces a few additional object types meant +to be passed along by other services. + +## Domain Objects + +Domain objects are the most fundamental component of Open MCT Web's information +model. A domain object is some distinct thing relevant to a user's work flow, +such as a telemetry channel, display, or similar. Open MCT Web is a tool for +viewing, browsing, manipulating, and otherwise interacting with a graph of +domain objects. + +A domain object should be conceived of as the union of the following: + +* __Identifier__: A machine-readable string that uniquely identifies the domain +object within this application instance. +* __Model__: The persistent state of the domain object. A domain object's model +is a JavaScript object that can be losslessly converted to JSON. +* __Capabilities__: Dynamic behavior associated with the domain object. +Capabilities are JavaScript objects which provide additional methods for +interacting with the domain objects which expose those capabilities. Not all +domain objects expose all capabilities. + +At run-time, a domain object has the following interface: + +* `getId()`: Get the identifier for this domain object. +* `getModel()`: Get the plain state associated with this domain object. This +will return a JavaScript object that can be losslessly converted to JSON. Note +that the model returned here can be modified directly but should not be; +instead, use the mutation capability. +* `getCapability(key)`: Get the specified capability associated with this domain +object. This will return a JavaScript object whose interface is specific to the +type of capability being requested. If the requested capability is not exposed +by this domain object, this will return undefined . +* `hasCapability(key)`: Shorthand for checking if a domain object exposes the +requested capability. +* `useCapability(key, arguments )`: Shorthand for +`getCapability(key).invoke(arguments)`, with additional checking between calls. +If the provided capability has no invoke method, the return value here functions +as `getCapability` including returning `undefined` if the capability is not +exposed. + +## Domain Object Actions + +An `Action` is behavior that can be performed upon/using a `DomainObject`. An +Action has the following interface: + +* `perform()`: Do this action. For example, if one had an instance of a +`RemoveAction` invoking its perform method would cause the domain object which +exposed it to be removed from its container. +* `getMetadata()`: Get metadata associated with this action. Returns an object +containing: + * `name`: Human-readable name. + * `description`: Human-readable summary of this action. + * `glyph`: Single character to be displayed in Open MCT Web's icon font set. + * `context`: The context in which this action is being performed (see below) + +Action instances are typically obtained via a domain object's `action` +capability. + +### Action Contexts + +An action context is a JavaScript object with the following properties: + +* `domainObject`: The domain object being acted upon. +* `selectedObject`: Optional; the selection at the time of action (e.g. the +dragged object in a drag-and-drop operation.) + +## Telemetry + +Telemetry series data in Open MCT Web is represented by a common interface, and +packaged in a consistent manner to facilitate passing telemetry updates around +multiple visualizations. + +### Telemetry Requests + +A telemetry request is a JavaScript object containing the following properties: + +* `source`: A machine-readable identifier for the source of this telemetry. This +is useful when multiple distinct data sources are in use side-by-side. +* `key`: A machine-readable identifier for a unique series of telemetry within +that source. +* _Note: This API is still under development; additional properties, such as +start and end time, should be present in future versions of Open MCT Web._ + +Additional properties may be included in telemetry requests which have specific +interpretations for specific sources. + +### Telemetry Responses + +When returned from the `telemetryService` (see [Services](#Services) section), +telemetry series data will be packaged in a `source -> key -> TelemetrySeries` +fashion. That is, telemetry is passed in an object containing key-value pairs. +Keys identify telemetry sources; values are objects containing additional +key-value pairs. In this object, keys identify individual telemetry series (and +match they `key` property from corresponding requests) and values are +`TelemetrySeries` objects (see below.) + +### Telemetry Series + +A telemetry series is a specific sequence of data, typically associated with a +specific instrument. Telemetry is modeled as an ordered sequence of domain and +range values, where domain values must be non-decreasing but range values do +not. (Typically, domain values are interpreted as UTC timestamps in milliseconds +relative to the UNIX epoch.) A series must have at least one domain and one +range, and may have more than one. + +Telemetry series data in Open MCT Web is expressed via the following +`TelemetrySeries` interface: + +* `getPointCount()`: Returns the number of unique points/samples in this series. +* `getDomainValue(index, [domain])`: Get the domain value at the specified index . +If a second domain argument is provided, this is taken as a string identifier +indicating which domain option (of, presumably, multiple) should be returned. +* `getRangeValue(index, [range])`: Get the domain value at the specified index . +If a second range argument is provided, this is taken as a string identifier +indicating which range option (of, presumably, multiple) should be returned. + +### Telemetry Metadata + +Domain objects which have associated telemetry also expose metadata about that +telemetry; this is retrievable via the `getMetadata()` of the telemetry +capability. This will return a single JavaScript object containing the following +properties: + +* `source`: The machine-readable identifier for the source of telemetry data for +this object. +* `key`: The machine-readable identifier for the individual telemetry series. +* `domains`: An array of supported domains (see TelemetrySeries above.) Each +domain should be expressed as an object which includes: + * `key`: Machine-readable identifier for this domain, as will be passed into + a getDomainValue(index, domain) call. + * `name`: Human-readable name for this domain. +* `ranges`: An array of supported ranges; same format as domains . + +Note that this metadata is also used as the prototype for telemetry requests +made using this capability. + +## Types +A domain object's type is represented as a Type object, which has the following +interface: + +* `getKey()`: Get the machine-readable identifier for this type. +* `getName()`: Get the human-readable name for this type. +* `getDescription()`: Get a human-readable summary of this type. +* `getGlyph()`: Get the single character to be rendered as an icon for this type +in Open MCT Web's custom font set. +* `getInitialModel()`: Get a domain object model that represents the initial +state (before user specification of properties) for domain objects of this type. +* `getDefinition()`: Get the extension definition for this type, as a JavaScript +object. +* `instanceOf(type)`: Check if this type is (or inherits from) a specified type . +This type can be either a string, in which case it is taken to be that type's + key , or it may be a `Type` instance. +* `hasFeature(feature)`: Returns a boolean value indicating whether or not this +type supports the specified feature, which is a symbolic string. +* `getProperties()`: Get all properties associated with this type, expressed as +an array of `TypeProperty` instances. + +### Type Features + +Features of a domain object type are expressed as symbolic string identifiers. +They are defined in practice by usage; currently, the Open MCT Web platform only +uses the creation feature to determine which domain object types should appear +in the Create menu. + +### Type Properties + +Types declare the user-editable properties of their domain object instances in +order to allow the forms which appear in the __Create__ and __Edit Properties__ +dialogs to be generated by the platform. A `TypeProperty` has the following interface: + +* `getValue(model)`: Get the current value for this property, as it appears in +the provided domain object model. +* `setValue(model, value)`: Set a new value for this property in the provided +domain object model . +* `getDefinition()`: Get the raw definition for this property as a JavaScript +object (as it was declared in this type's extension definition.) + +# Extension Categories + +The information in this section is focused on registering new extensions of +specific types; it does not contain a catalog of the extension instances of +these categories provided by the platform. Relevant summaries there are provided +in subsequent sections. + +## Actions Category + +An action is a thing that can be done to or using a domain object, typically as +initiated by the user. + +An action's implementation: + +* Should take a single `context` argument in its constructor. (See Action +Contexts, under Core API.) +* Should provide a method `perform` which causes the behavior associated with +the action to occur. +* May provide a method `getMetadata` which provides metadata associated with +the action. If omitted, one will be provided by the platform which includes +metadata from the action's extension definition. +* May provide a static method `appliesTo(context)` (that is, a function +available as a property of the implementation's constructor itself), which will +be used by the platform to filter out actions from contexts in which they are +inherently inapplicable. + +An action's bundle definition (and/or `getMetadata()` return value) may include: + +* `category`: A string or array of strings identifying which category or +categories an action falls into; used to determine when an action is displayed. +Categories supported by the platform include: + * `contextual`: Actions in a context menu. + * `view-control`: Actions triggered by buttons in the top-right of Browse + view. +* `key`: A machine-readable identifier for this action. +* `name`: A human-readable name for this action (e.g. to show in a menu) +* `description`: A human-readable summary of the behavior of this action. +* `glyph`: A single character which will be rendered in Open MCT Web's custom +font set as an icon for this action. + +## Capabilities Category + +Capabilities are exposed by domain objects (e.g. via the `getCapability` method) +but most commonly originate as extensions of this category. + +Extension definitions for capabilities should include both an implementation, +and a property named key whose value should be a string used as a +machine-readable identifier for that capability, e.g. when passed as the +argument to a domain object's `getCapability(key)` call. + +A capability's implementation should have methods specific to that capability; +that is, there is no common format for capability implementations, aside from +support for invocation via the `useCapability` shorthand. + +A capability's implementation will take a single argument (in addition to any +declared dependencies), which is the domain object that will expose that +capability. + +A capability's implementation may also expose a static method `appliesTo(model)` +which should return a boolean value, and will be used by the platform to filter +down capabilities to those which should be exposed by specific domain objects, +based on their domain object models. + +## Controls Category + +Controls provide options for the `mct-control` directive. + +Six standard control types are included in the forms bundle: + +* `textfield`: An area to enter plain text. +* `select`: A drop-down list of options. +* `checkbox`: A box which may be checked/unchecked. +* `color`: A color picker. +* `button`: A button. +* `datetime`: An input for UTC date/time entry; gives result as a UNIX +timestamp, in milliseconds since start of 1970, UTC. + +New controls may be added as extensions of the controls category. Extensions of +this category have two properties: + +* `key`: The symbolic name for this control (matched against the control field +in rows of the form structure). +* `templateUrl`: The URL to the control's Angular template, relative to the +resources directory of the bundle which exposes the extension. + +Within the template for a control, the following variables will be included in +scope: + +* `ngModel`: The model where form input will be stored. Notably we also need to +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) +* `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 +control. + +## Gestures Category + +A _gesture_ is a user action which can be taken upon a representation of a +domain object. + +Examples of gestures included in the platform are: + +* `drag`: For representations that can be used to initiate drag-and-drop +composition. +* `drop`: For representations that can be drop targets for drag-and-drop +composition. +* `menu`: For representations that can be used to pop up a context menu. + +Gesture definitions have a property `key` which is used as a machine-readable +identifier for the gesture (e.g. `drag`, `drop`, `menu` above.) + +A gesture's implementation is instantiated once per representation that uses the +gesture. This class will receive the jqLite-wrapped `mct-representation` element +and the domain object being represented as arguments, and should do any +necessary "wiring" (e.g. listening for events) during its constructor call. The +gesture's implementation may also expose an optional `destroy()` method which +will be called when the gesture should be removed, to avoid memory leaks by way +of unremoved listeners. + +## Indicators Category + +An indicator is an element that should appear in the status area at the bottom +of a running Open MCT Web client instance. + +### Standard Indicators + +Indicators which wish to appear in the common form of an icon-text pair should +provide implementations with the following methods: + +* `getText()`: Provides the human-readable text that will be displayed for this +indicator. +* `getGlyph()`: Provides a single-character string that will be displayed as an +icon in Open MCT Web's custom font set. +* `getDescription()`: Provides a human-readable summary of the current state of +this indicator; will be displayed in a tooltip on hover. +* `getClass()`: Get a CSS class that will be applied to this indicator. +* `getTextClass()`: Get a CSS class that will be applied to this indicator's +text portion. +* `getGlyphClass()`: Get a CSS class that will be applied to this indicator's +icon portion. +* `configure()`: If present, a configuration icon will appear to the right of +this indicator, and clicking it will invoke this method. + +Note that all methods are optional, and are called directly from an Angular +template, so they should be appropriate to run during digest cycles. + +### Custom Indicators + +Indicators which wish to have an arbitrary appearance (instead of following the +icon-text convention commonly used) may specify a `template` property in their +extension definition. The value of this property will be used as the `key` for +an `mct-include` directive (so should refer to an extension of category + templates .) This template will be rendered to the status area. Indicators of +this variety do not need to provide an implementation. + +## Licenses Category + +The extension category `licenses` can be used to add entries into the 'Licensing +information' page, reachable from Open MCT Web's About dialog. + +Licenses may have the following properties, all of which are strings: + +* `name`: Human-readable name of the licensed component. (e.g. 'AngularJS'.) +* `version`: Human-readable version of the licensed component. (e.g. '1.2.26'.) +* `description`: Human-readable summary of the component. +* `author`: Name or names of entities to which authorship should be attributed. +* `copyright`: Copyright text to display for this component. +* `link`: URL to full license text. + +## Policies Category + +Policies are used to handle decisions made using Open MCT Web's `policyService`; +examples of these decisions are determining the applicability of certain +actions, or checking whether or not a domain object of one type can contain a +domain object of a different type. See the section on the Policies for an +overview of Open MCT Web's policy model. + +A policy's extension definition should include: + +* `category`: The machine-readable identifier for the type of policy decision +being supported here. For a list of categories supported by the platform, see +the section on Policies. Plugins may introduce and utilize additional policy +categories not in that list. +* `message`: Optional; a human-readable message describing the policy, intended +for display in situations where this specific policy has disallowed something. + +A policy's implementation should include a single method, `allow(candidate, +context)`. The specific types used for `candidate` and `context` vary by policy +category; in general, what is being asked is 'is this candidate allowed in this +context?' This method should return a boolean value. + +Open MCT Web's policy model requires consensus; a policy decision is allowed +when and only when all policies choose to allow it. As such, policies should +generally be written to reject a certain case, and allow (by returning `true`) +anything else. + +## Representations Category + +A representation is an Angular template used to display a domain object. The +`representations` extension category is used to add options for the +`mct-representation` directive. + +A representation definition should include the following properties: + +* `key`: The machine-readable name which identifies the representation. +* `templateUrl`: The path to the representation's Angular template. This path is +relative to the bundle's resources directory. +* `uses`: Optional; an array of capability names. Indicates that this +representation intends to use those capabilities of a domain object (via a +`useCapability` call), and expects to find the latest results of that +`useCapability` call in the scope of the presented template (under the same name +as the capability itself.) Note that, if `useCapability` returns a promise, this +will be resolved before being placed in the representation's scope. +* `gestures`: An array of keys identifying gestures (see the `gestures` +extension category) which should be available upon this representation. Examples +of gestures include `drag` (for representations that should act as draggable +sources for drag-drop operations) and `menu` (for representations which should +show a domain-object-specific context menu on right-click.) + +### Representation Scope + +While _representations_ do not have implementations, per se, they do refer to +Angular templates which need to interact with information (e.g. the domain +object being represented) provided by the platform. This information is passed +in through the template's scope, such that simple representations may be created +by providing only templates. (More complex representations will need controllers +which are referenced from templates. See [https://docs.angularjs.org/guide/controller ]() +for more information on controllers in Angular.) + +A representation's scope will contain: + +* `domainObject`: The represented domain object. +* `model`: The domain object's model. +* `configuration`: An object containing configuration information for this +representation (an empty object if there is no saved configuration.) The +contents of this object are managed entirely by the view/representation which +receives it. +* `representation`: An empty object, useful as a 'scratch pad' for +representation state. +* `ngModel`: An object passed through the ng-model attribute of the +`mct-representation` , if any. +* `parameters`: An object passed through the parameters attribute of the +`mct-representation`, if any. +* Any capabilities requested by the uses property of the representation +definition. + +## Representers Category + +The `representers` extension category is used to add additional behavior to the +`mct-representation` directive. This extension category is intended primarily +for use internal to the platform. + +Unlike _representations_, which describe specific ways to represent domain +objects, _representers_ are used to modify or augment the process of +representing domain objects in general. For example, support for the _gestures_ +extension category is added by a _representer_. + +A representer needs only provide an implementation. When an `mct-representation` +is linked (see [https://docs.angularjs.org/guide/directive ]() or when the +domain object being represented changes, a new _representer_ of each declared +type is instantiated. The constructor arguments for a _representer_ are the same +as the arguments to the link function in an Angular directive: `scope` the +Angular scope for this representation; `element` the jqLite-wrapped +`mct-representation` element, and `attrs` a set of key-value pairs of that +element's attributes. _Representers_ may wish to populate the scope, attach +event listeners to the element, etc. + +This implementation must provide a single method, `destroy()`, which will be +invoked when the representer is no longer needed. + +## Roots Category + +The extension category `roots` is used to provide root-level domain object +models. Root-level domain objects appear at the top-level of the tree hierarchy. +For example, the _My Items_ folder is added as an extension of this category. + +Extensions of this category should have the following properties: + +* `id`: The machine-readable identifier for the domaiwn object being exposed. +* `model`: The model, as a JSON object, for the domain object being exposed. + +## Stylesheets Category + +The stylesheets extension category is used to add CSS files to style the +application. Extension definitions for this category should include one +property: + +* `stylesheetUrl`: Path and filename, including extension, for the stylesheet to +include. This path is relative to the bundle's resources folder (by default, +`res`) + +To control the order of CSS files, use priority (see the section on Extension +Definitions above.) + +## Templates Category + +The `templates` extension category is used to expose Angular templates under +symbolic identifiers. These can then be utilized using the `mct-include` +directive, which behaves similarly to `ng-include` except that it uses these +symbolic identifiers instead of paths. + +A template's extension definition should include the following properties: + +* `key`: The machine-readable name which identifies this template, matched +against the value given to the key attribute of the `mct-include` directive. +* `templateUrl`: The path to the relevant Angular template. This path is +relative to the bundle's resources directory. + +Note that, when multiple templates are present with the same key , the one with +the highest priority will be used from `mct-include`. This behavior can be used +to override templates exposed by the platform (to change the logo which appears +in the bottom right, for instance.) + +Templates do not have implementations. + +## Types Category + +The types extension category describes types of domain objects which may +appear within Open MCT Web. + +A type's extension definition should have the following properties: + +* `key`: The machine-readable identifier for this domain object type. Will be +stored to and matched against the type property of domain object models. +* `name`: The human-readable name for this domain object type. +* `description`: A human-readable summary of this domain object type. +* `glyph`: A single character to be rendered as an icon in Open MCT Web's custom +font set. +* `model`: A domain object model, used as the initial state for created domain +objects of this type (before any properties are specified.) +* `features`: Optional; an array of strings describing features of this domain +object type. Currently, only creation is recognized by the platform; this is +used to determine that this type should appear in the Create menu. More +generally, this is used to support the `hasFeature(...)` method of the type +capability. +* `properties`: An array describing individual properties of this domain object +(as should appear in the _Create_ or the _Edit Properties_ dialog.) Each +property is described by an object containing the following properties: + * `control`: The key of the control (see `mct-control` and the `controls` + [extension category](#Controls)) to use for editing this property. + * `property`: A string which will be used as the name of the property in the + domain object's model that the value for this property should be stored + under. If this value should be stored in an object nested within the domain + object model, then property should be specified as an array of strings + identifying these nested objects and, finally, the property itself. + * other properties as appropriate for a control of this type (each + property's definition will also be passed in as the structure for its + control.) See documentation of mct-form for more detail on these + properties. + +Types do not have implementations. + +## Versions Category +The versions extension category is used to introduce line items in Open MCT +Web's About dialog. These should have the following properties: + +* `name`: The name of this line item, as should appear in the left-hand side of +the list of version information in the About dialog. +* `value`: The value which should appear to the right of the name in the About +dialog. + +To control the ordering of line items within the About dialog, use `priority`. +(See section on [Extension Definitions](#ExtensionDefinitions) above.) + +This extension category does not have implementations. + +## Views Category + +The views extension category is used to determine which options appear to the +user as available views of domain objects of specific types. A view's extension +definition has the same properties as a representation (and views can be +utilized via `mct-representation`); additionally: + +* `name`: The human-readable name for this view type. +* description : A human-readable summary of this view type. +* `glyph`: A single character to be rendered as an icon in Open MCT Web's custom +font set. +* `type`: Optional; if present, this representation is only applicable for +domain object's of this type. +* `needs`: Optional array of strings; if present, this representation is only +applicable for domain objects which have the capabilities identified by these +strings. +* `delegation`: Optional boolean, intended to be used in conjunction with +`needs`; if present, allow required capabilities to be satisfied by means of +capability delegation. (See [Delegation](#Delegation)) +* `toolbar`: Optional; a definition for the toolbar which may appear in a +toolbar when using this view in Edit mode. This should be specified as a +structure for mct-toolbar , with additional properties available for each item in +that toolbar: + * `property`: A property name. This will refer to a property in the view's + current selection; that property on the selected object will be modifiable + as the `ng-model` of the displayed control in the toolbar. If the value of + the property is a function, it will be used as a getter-setter (called with + no arguments to use as a getter, called with a value to use as a setter.) + * `method`: A method to invoke (again, on the selected object) from the + toolbar control. Useful particularly for buttons (which don't edit a single + property, necessarily.) + +### View Scope + +Views do not have implementations, but do get the same properties in scope that +are provided for `representations`. + +When a view is in Edit mode, this scope will additionally contain: + +* `commit()`: A function which can be invoked to mark any changes to the view's + configuration as ready to persist. +* `selection`: An object representing the current selection state. + +#### Selection State + +A view's selection state is, conceptually, a set of JavaScript objects. The +presence of methods/properties on these objects determine which toolbar controls +are visible, and what state they manage and/or behavior they invoke. + +This set may contain up to two different objects: The _view proxy _, which is +used to make changes to the view as a whole, and the _ selected object _, which is +used to represent some state within the view. (Future versions of Open MCT Web +may support multiple selected objects.) + +The `selection` object made available during Edit mode has the following +methods: + +* `proxy([object])`: Get (or set, if called with an argument) the current view +proxy. +* `select(object)`: Make this object the selected object. +* `deselect()`: Clear the currently selected object. +* `get()`: Get the currently selected object. Returns undefined if there is no +currently selected object. +* `selected(object)`: Check if the JavaScript object is currently in the +selection set. Returns true if the object is either the currently selected +object, or the current view proxy. +* `all()`: Get an array of all objects in the selection state. Will include +either or both of the view proxy and selected object. + +# Directives + +Open MCT Web defines several Angular directives that are intended for use both +internally within the platform, and by plugins. + +## Before Unload + +The `mct-before-unload` directive is used to listen for (and prompt for user +confirmation) of navigation changes in the browser. This includes reloading, +following links out of Open MCT Web, or changing routes. It is used to hook into +both `onbeforeunload` event handling as well as route changes from within +Angular. + +This directive is useable as an attribute. Its value should be an Angular +expression. When an action that would trigger an unload and/or route change +occurs, this Angular expression is evaluated. Its result should be a message to +display to the user to confirm their navigation change; if this expression +evaluates to a falsy value, no message will be displayed. + +## Chart + +The `mct-chart` directive is used to support drawing of simple charts. It is +present to support the Plot view, and its functionality is limited to the +functionality that is relevant for that view. + +This directive is used at the element level and takes one attribute, `draw` +which is an Angular expression which will should evaluate to a drawing object. +This drawing object should contain the following properties: + +* `dimensions`: The size, in logical coordinates, of the chart area. A +two-element array or numbers. +* `origin`: The position, in logical coordinates, of the lower-left corner of +the chart area. A two-element array or numbers. +* `lines`: An array of lines (e.g. as a plot line) to draw, where each line is +expressed as an object containing: + * `buffer`: A Float32Array containing points in the line, in logical + coordinates, in sequential x,y pairs. + * `color`: The color of the line, as a four-element RGBA array, where + each element is a number in the range of 0.0-1.0. + * `points`: The number of points in the line. +* `boxes`: An array of rectangles to draw in the chart area. Each is an object +containing: + * `start`: The first corner of the rectangle, as a two-element array of + numbers, in logical coordinates. + * `end`: The opposite corner of the rectangle, as a two-element array of + numbers, in logical coordinates. color : The color of the line, as a + four-element RGBA array, where each element is a number in the range of + 0.0-1.0. + +While `mct-chart` is intended to support plots specifically, it does perform +some useful management of canvas objects (e.g. choosing between WebGL and Canvas +2D APIs for drawing based on browser support) so its usage is recommended when +its supported drawing primitives are sufficient for other charting tasks. + +## Container + +The `mct-container` is similar to the `mct-include` directive insofar as it allows +templates to be referenced by symbolic keys instead of by URL. Unlike +`mct-include` it supports transclusion. + +Unlike `mct-include` `mct-container` accepts a key as a plain string attribute, +instead of as an Angular expression. + +## Control + +The `mct-control` directive is used to display user input elements. Several +controls are included with the platform to wrap default input types. This +directive is primarily intended for internal use by the `mct-form` and +`mct-toolbar` directives. + +When using `mct-control` the attributes `ng-model` `ng-disabled` +`ng-required` and `ng-pattern` may also be used. These have the usual meaning +(as they would for an input element) except for `ng-model`; when used, it will +actually be `ngModel[field]` (see below) that is two-way bound by this control. +This allows `mct-control` elements to more easily delegate to other +`mct-control` instances, and also facilitates usage for generated forms. + +This directive supports the following additional attributes, all specified as +Angular expressions: + +* `key`: A machine-readable identifier for the specific type of control to +display. +* `options`: A set of options to display in this control. +* `structure`: In practice, contains the definition object which describes this +form row or toolbar item. Used to pass additional control-specific parameters. +* `field`: The field in the `ngModel` under which to read/store the property +associated with this control. + +## Drag + +The `mct-drag` directive is used to support drag-based gestures on HTML +elements. Note that this is not 'drag' in the 'drag-and-drop' sense, but 'drag' +in the more general 'mouse down, mouse move, mouse up' sense. + +This takes the form of three attributes: + +* `mct-drag`: An Angular expression to evaluate during drag movement. +* `mct-drag-down`: An Angular expression to evaluate when the drag starts. +* `mct-drag-up`: An Angular expression to evaluate when the drag ends. + +In each case, a variable `delta` will be provided to the expression; this is a +two-element array or the horizontal and vertical pixel offset of the current +mouse position relative to the mouse position where dragging began. + +## Form + +The `mct-form` directive is used to generate forms using a declarative structure, +and to gather back user input. It is applicable at the element level and +supports the following attributes: + +* `ng-model`: The object which should contain the full form input. Individual +fields in this model are bound to individual controls; the names used for these +fields are provided in the form structure (see below). +* `structure`: The structure of the form; e.g. sections, rows, their names, and +so forth. The value of this attribute should be an Angular expression. +* `name`: The name in the containing scope under which to publish form +"meta-state", e.g. `$valid` `$dirty` etc. This is as the behavior of `ng-form`. +Passed as plain text in the attribute. + +### Form Structure + +Forms in Open MCT Web have a common structure to permit consistent display. A +form is broken down into sections, which will be displayed in groups; each +section is broken down into rows, each of which provides a control for a single +property. Input from this form is two-way bound to the object passed via +`ng-model`. + +A form's structure is represented by a JavaScript object in the following form: + + { + "name": ... title to display for the form, as a string ..., + "sections": [ + { + "name": ... title to display for the section ..., + "rows": [ + { + "name": ... title to display for this row ..., + "control": ... symbolic key for the control ..., + "key": ... field name in ng-model ... + "pattern": ... optional, reg exp to match against ... + "required": ... optional boolean ... + "options": [ + "name": ... name to display (e.g. in a select) ..., + "value": ... value to store in the model ... + ] + }, + ... and other rows ... + ] + }, + ... and other sections ... + ] + } + +Note that `pattern` may be specified as a string, to simplify storing for +structures as JSON when necessary. The string should be given in a form +appropriate to pass to a `RegExp` constructor. + +### Form Controls + +A few standard control types are included in the platform/forms bundle: + +* `textfield`: An area to enter plain text. +* `select`: A drop-down list of options. +* `checkbox`: A box which may be checked/unchecked. +* `color`: A color picker. +* `button`: A button. +* `datetime`: An input for UTC date/time entry; gives result as a UNIX +timestamp, in milliseconds since start of 1970, UTC. + +## Include + +The `mct-include` directive is similar to ng-include , except that it takes a +symbolic identifier for a template instead of a URL. Additionally, templates +included via mct-include will have an isolated scope. + +The directive should be used at the element level and supports the following +attributes, all of which are specified as Angular expressions: + +* `key`: Machine-readable identifier for the template (of extension category +templates ) to be displayed. +* `ng-model`: _Optional_; will be passed into the template's scope as `ngModel`. +Intended usage is for two-way bound user input. +* `parameters`: _Optional_; will be passed into the template's scope as +parameters. Intended usage is for template-specific display parameters. + +## Representation + +The `mct-representation` directive is used to include templates which +specifically represent domain objects. Usage is similar to `mct-include`. + +The directive should be used at the element level and supports the following +attributes, all of which are specified as Angular expressions: + +* `key`: Machine-readable identifier for the representation (of extension +category _representations_ or _views_ ) to be displayed. +* `mct-object`: The domain object being represented. +* `ng-model`: Optional; will be passed into the template's scope as `ngModel`. +Intended usage is for two-way bound user input. +* `parameters`: Optional; will be passed into the template's scope as +parameters . Intended usage is for template-specific display parameters. + +## Resize + +The `mct-resize` directive is used to monitor the size of an HTML element. It is +specified as an attribute whose value is an Angular expression that will be +evaluated when the size of the HTML element changes. This expression will be +provided a single variable, `bounds` which is an object containing two +properties, `width` and `height` describing the size in pixels of the element. + +When using this directive, an attribute `mct-resize-interval` may optionally be +provided. Its value is an Angular expression describing the number of +milliseconds to wait before next checking the size of the HTML element; this +expression is evaluated when the directive is linked and reevaluated whenever +the size is checked. + +## Scroll + +The `mct-scroll-x` and `mct-scroll-y` directives are used to both monitor and +control the horizontal and vertical scroll bar state of an element, +respectively. They are intended to be used as attributes whose values are +assignable Angular expressions which two-way bind to the scroll bar state. + +## Toolbar + +The `mct-toolbar` directive is used to generate toolbars using a declarative +structure, and to gather back user input. It is applicable at the element level +and supports the following attributes: + +* `ng-model`: The object which should contain the full toolbar input. Individual +fields in this model are bound to individual controls; the names used for these +fields are provided in the form structure (see below). +* `structure`: The structure of the toolbar; e.g. sections, rows, their names, and +so forth. The value of this attribute should be an Angular expression. +* `name`: The name in the containing scope under which to publish form +"meta-state", e.g. `$valid`, `$dirty` etc. This is as the behavior of +`ng-form`. Passed as plain text in the attribute. + +Toolbars support the same control options as forms. + +### Toolbar Structure + +A toolbar's structure is defined similarly to forms, except instead of rows +there are items . + + { + "name": ... title to display for the form, as a string ..., + "sections": [ + { + "name": ... title to display for the section ..., + "items": [ + { + "name": ... title to display for this row ..., + "control": ... symbolic key for the control ..., + "key": ... field name in ng-model ... + "pattern": ... optional, reg exp to match against ... + "required": ... optional boolean ... + "options": [ + "name": ... name to display (e.g. in a select) ..., + "value": ... value to store in the model ... + ], + "disabled": ... true if control should be disabled ... + "size": ... size of the control (for textfields) ... + "click": ... function to invoke (for buttons) ... + "glyph": ... glyph to display (for buttons) ... + "text": ... text within control (for buttons) ... + }, + ... and other rows ... + ] + }, + ... and other sections ... + ] + } + +# Services + +The Open MCT Web platform provides a variety of services which can be retrieved +and utilized via dependency injection. These services fall into two categories: + +* _Composite Services_ are defined by a set of components extensions; plugins may +introduce additional components with matching interfaces to extend or augment +the functionality of the composed service. (See the Framework section on +Composite Services.) +* _Other services_ which are defined as standalone service objects; these can be +utilized by plugins but are not intended to be modified or augmented. + +## Composite Type Services + +This section describes the composite services exposed by Open MCT Web, +specifically focusing on their interface and contract. + +In many cases, the platform will include a provider for a service which consumes +a specific extension category; for instance, the `actionService` depends on +`actions[]` and will expose available actions based on the rules defined for +that extension category. + +In these cases, it will usually be simpler to add a new extension of a given +category (e.g. of category `actions`) even when the same behavior could be +introduced by a service component (e.g. an extension of category `components` +where `provides` is `actionService` and `type` is `provider`.) + +Occasionally, the extension category does not provide enough expressive power to +achieve a desired result. For instance, the Create menu is populated with +`create` actions, where one such action exists for each creatable type. Since +the framework does not provide a declarative means to introduce a new action per +type declaratively, the platform implements this explicitly in an `actionService` +component of type `provider`. Plugins may use a similar approach when the normal +extension mechanism is insufficient to achieve a desired result. + +### Action Service + +The [Action Service](../architecture/platform#action-service) (`actionService`) +provides `Action` instances which are applicable in specific contexts. See Core +API for additional notes on the interface for actions. The `actionService` has +the following interface: + +* `getActions(context)`: Returns an array of Action objects which are applicable +in the specified action context. + +### Capability Service + +The [Capability Service](../architecture/platform#capability-service) (`capabilityService`) +provides constructors for capabilities which will be exposed for a given domain +object. + +The capabilityService has the following interface: + +* `getCapabilities(model)`: Returns a an object containing key-value pairs, +representing capabilities which should be exposed by the domain object with this +model. Keys in this object are the capability keys (as used in a +`getCapability(...)` call) and values are either: + * Functions, in which case they will be used as constructors, which will + receive the domain object instance to which the capability applies as their + sole argument.The resulting object will be provided as the result of a + domain object's `getCapability(...)` call. Note that these instances are cached + by each object, but may be recreated when an object is mutated. + * Other objects, which will be used directly as the result of a domain + object's `getCapability(...)` call. + +### Dialog Service + +The `dialogService` provides a means for requesting user input via a modal +dialog. It has the following interface: + +* `getUserInput(formStructure, formState)`: Prompt the user to fill out a form. +The first argument describes the form's structure (as will be passed to + mct-form ) while the second argument contains the initial state of that form. +This returns a Promise for the state of the form after the user has filled it +in; this promise will be rejected if the user cancels input. +* `getUserChoice(dialogStructure)`: Prompt the user to make a single choice from +a set of options, which (in the platform implementation) will be expressed as +buttons in the displayed dialog. Returns a Promise for the user's choice, which +will be rejected if the user cancels input. + +### Dialog Structure + +The object passed as the `dialogStructure` to `getUserChoice` should have the +following properties: + +* `title`: The title to display at the top of the dialog. +* `hint`: Short message to display below the title. +* `template`: Identifying key (as will be passed to mct-include ) for the +template which will be used to populate the inner area of the dialog. +* `model`: Model to pass in the ng-model attribute of mct-include . +* `parameters`: Parameters to pass in the parameters attribute of mct-include . +* `options`: An array of options describing each button at the bottom. Each +option may have the following properties: + * `name`: Human-readable name to display in the button. + * `key`: Machine-readable key, to pass as the result of the resolved promise + when clicked. + * `description`: Description to show in tooltip on hover. + +### Domain Object Service + +The [Object Service](../architecture/platform.md#object-service) (`objectService`) +provides domain object instances. It has the following interface: + +* `getObjects(ids)`: For the provided array of domain object identifiers, +returns a Promise for an object containing key-value pairs, where keys are +domain object identifiers and values are corresponding DomainObject instances. +Note that the result may contain a superset or subset of the objects requested. + +### Gesture Service + +The `gestureService` is used to attach gestures (see extension category gestures) +to representations. It has the following interface: + +* `attachGestures(element, domainObject, keys)`: Attach gestures specified by +the provided gesture keys (an array of strings) to this jqLite-wrapped HTML +element , which represents the specified domainObject . Returns an object with a +single method `destroy()`, to be invoked when it is time to detach these +gestures. + +### Model Service + +The [Model Service](../architecture/platform.md#model-service) (`modelService`) +provides domain object models. It has the following interface: + +* `getModels(ids)`: For the provided array of domain object identifiers, returns +a Promise for an object containing key-value pairs, where keys are domain object +identifiers and values are corresponding domain object models. Note that the +result may contain a superset or subset of the models requested. + +### Persistence Service + +The [Persistence Service](../architecture/platform.md#persistence-service) (`persistenceService`) +provides the ability to load/store JavaScript objects +(presumably serializing/deserializing to JSON in the process.) This is used +primarily to store domain object models. It has the following interface: + +* `listSpaces()`: Returns a Promise for an array of strings identifying the +different persistence spaces this service supports. Spaces are intended to be +used to distinguish between different underlying persistence stores, to allow +these to live side by side. +* `listObjects()`: Returns a Promise for an array of strings identifying all +documents stored in this persistence service. +* `createObject(space, key, value)`: Create a new document in the specified +persistence space , identified by the specified key , the contents of which shall +match the specified value . Returns a promise that will be rejected if creation +fails. +* `readObject(space, key)`: Read an existing document in the specified +persistence space , identified by the specified key . Returns a promise for the +specified document; this promise will resolve to undefined if the document does +not exist. +* `updateObject(space, key, value)`: Update an existing document in the +specified persistence space , identified by the specified key , such that its +contents match the specified value . Returns a promise that will be rejected if +the update fails. +* `deleteObject(space, key)`: Delete an existing document from the specified +persistence space , identified by the specified key . Returns a promise which will +be rejected if deletion fails. + +### Policy Service + +The [Policy Service](../architecture/platform.md#policy-service) (`policyService`) +may be used to determine whether or not certain behaviors are +allowed within the application. It has the following interface: + +* `allow(category, candidate, context, [callback])`: Check if this decision +should be allowed. Returns a boolean. Its arguments are interpreted as: + * `category`: A string identifying which kind of decision is being made. See + the [section on Categories](#PolicyCategories) for categories supported by + the platform; plugins may define and utilize policies of additional + categories, as well. + * `candidate`: An object representing the thing which shall or shall not be + allowed. Usually, this will be an instance of an extension of the category + defined above. This does need to be the case; additional policies which are + not specific to any extension may also be defined and consulted using unique + category identifiers. In this case, the type of the object delivered for the + candidate may be unique to the policy type. + * `context`: An object representing the context in which the decision is + occurring. Its contents are specific to each policy category. + * `callback`: Optional; a function to call if the policy decision is rejected. + This function will be called with the message string (which may be + undefined) of whichever individual policy caused the operation to fail. + +### Telemetry Service + +The [Telemetry Service](../architecture/platform.md#telemetry-service) (`telemetryService`) +is used to acquire telemetry data. See the section on +Telemetry in Core API for more information on how both the arguments and +responses of this service are structured. + +When acquiring telemetry for display, it is recommended that the +`telemetryHandler` service be used instead of this service. The +`telemetryHandler` has additional support for subscribing to and requesting +telemetry data associated with domain objects or groups of domain objects. See +the [Other Services](#Other-Services) section for more information. + +The `telemetryService` has the following interface: + +* `requestTelemetry(requests)`: Issue a request for telemetry, matching the +specified telemetry requests . Returns a _ Promise _ for a telemetry response +object. +* `subscribe(callback, requests)`: Subscribe to real-time updates for telemetry, +matching the specified `requests`. The specified `callback` will be invoked with +telemetry response objects as they become available. This method returns a +function which can be invoked to terminate the subscription. + +### Type Service + +The [Type Service](../architecture/platform.md#type-service) (`typeService`) exposes +domain object types. It has the following interface: + +* `listTypes()`: Returns all domain object types supported in the application, +as an array of `Type` instances. +* `getType(key)`: Returns the `Type` instance identified by the provided key, or +undefined if no such type exists. + +### View Service + +The [View Service](../architecture/platform.md#view-service) (`viewService`) exposes +definitions for views of domain objects. It has the following interface: + +* `getViews(domainObject)`: Get an array of extension definitions of category +`views` which are valid and applicable to the specified `domainObject`. + +## Other Services + +### Drag and Drop + +The `dndService` provides information about the content of an active +drag-and-drop gesture within the application. It is intended to complement the +`DataTransfer` API of HTML5 drag-and-drop, by providing access to non-serialized +JavaScript objects being dragged, as well as by permitting inspection during +drag (which is normally prohibited by browsers for security reasons.) + +The `dndService` has the following methods: + +* `setData(key, value)`: Set drag data associated with a given type, specified +by the `key` argument. +* `getData(key)`: Get drag data associated with a given type, specified by the +`key` argument. +* `removeData(key)`: Clear drag data associated with a given type, specified by +the `key` argument. + +### Navigation + +The _Navigation_ service provides information about the current navigation state +of the application; that is, which object is the user currently viewing? This +service merely tracks this state and notifies listeners; it does not take +immediate action when navigation changes, although its listeners might. + +The `navigationService` has the following methods: + +* `getNavigation()`: Get the current navigation state. Returns a `DomainObject`. +* `setNavigation(domainObject)`: Set the current navigation state. Returns a +`DomainObject`. +* `addListener(callback)`: Listen for changes in navigation state. The provided +`callback` should be a `Function` which takes a single `DomainObject` as an +argument. +* `removeListener(callback)`: Stop listening for changes in navigation state. +The provided `callback` should be a `Function` which has previously been passed +to addListener . + +### Now + +The service now is a function which acts as a simple wrapper for `Date.now()`. +It is present mainly so that this functionality may be more easily mocked in +tests for scripts which use the current time. + +### Telemetry Formatter + +The _Telemetry Formatter_ is a utility for formatting domain and range values +read from a telemetry series. + +`telemetryFormatter` has the following methods: + +* `formatDomainValue(value)`: Format the provided domain value (which will be +assumed to be a timestamp) for display; returns a string. +* `formatRangeValue(value)`: Format the provided range value (a number) for +display; returns a string. + +### Telemetry Handler + +The _Telemetry Handler_ is a utility for retrieving telemetry data associated +with domain objects; it is particularly useful for dealing with cases where the +telemetry capability is delegated to contained objects (as occurs +in _Telemetry Panels_.) + +The `telemetryHandler` has the following methods: + +* `handle(domainObject, callback, [lossless])`: Subscribe to and issue future +requests for telemetry associated with the provided `domainObject`, invoking the +provided callback function when streaming data becomes available. Returns a +`TelemetryHandle` (see below.) + +#### Telemetry Handle + +A TelemetryHandle has the following methods: + +* `getTelemetryObjects()`: Get the domain objects (as a `DomainObject[]`) that +have a telemetry capability and are being handled here. Note that these are +looked up asynchronously, so this method may return an empty array if the +initial lookup is not yet completed. +* `promiseTelemetryObjects()`: As `getTelemetryObjects()`, but returns a Promise +that will be fulfilled when the lookup is complete. +* `unsubscribe()`: Unsubscribe to streaming telemetry updates associated with +this handle. +* `getDomainValue(domainObject)`: Get the most recent domain value received via +a streaming update for the specified `domainObject`. +* `getRangeValue(domainObject)`: Get the most recent range value received via a +streaming update for the specified `domainObject`. +* `getMetadata()`: Get metadata (as reported by the `getMetadata()` method of a +telemetry capability) associated with telemetry-providing domain objects. +Returns an array, which is in the same order as getTelemetryObjects() . +* `request(request, callback)`: Issue a new request for historical telemetry +data. The provided callback will be invoked when new data becomes available, +which may occur multiple times (e.g. if there are multiple domain objects.) It +will be invoked with the DomainObject for which a new series is available, and +the TelemetrySeries itself, in that order. +* `getSeries(domainObject)`: Get the latest `TelemetrySeries` (as resulted from +a previous `request(...)` call) available for this domain object. + + +# Models +Domain object models in Open MCT Web are JavaScript objects describing the +persistent state of the domain objects they describe. Their contents include a +mix of commonly understood metadata attributes; attributes which are recognized +by and/or determine the applicability of specific extensions; and properties +specific to given types. + +## General Metadata + +Some properties of domain object models have a ubiquitous meaning through Open +MCT Web and can be utilized directly: + +* `name`: The human-readable name of the domain object. + +## Extension-specific Properties + +Other properties of domain object models have specific meaning imposed by other +extensions within the Open MCT Web platform. + +### Capability-specific Properties + +Some properties either trigger the presence/absence of certain capabilities, or +are managed by specific capabilities: + +* `composition`: An array of domain object identifiers that represents the +contents of this domain object (e.g. as will appear in the tree hierarchy.) +Understood by the composition capability; the presence or absence of this +property determines the presence or absence of that capability. +* `modified`: The timestamp (in milliseconds since the UNIX epoch) of the last +modification made to this domain object. Managed by the mutation capability. +* `persisted`: The timestamp (in milliseconds since the UNIX epoch) of the last +time when changes to this domain object were persisted. Managed by the + persistence capability. +* `relationships`: An object containing key-value pairs, where keys are symbolic +identifiers for relationship types, and values are arrays of domain object +identifiers. Used by the relationship capability; the presence or absence of +this property determines the presence or absence of that capability. +* `telemetry`: An object which serves as a template for telemetry requests +associated with this domain object (e.g. specifying `source` and `key`; see +Telemetry Requests under Core API.) Used by the telemetry capability; the +presence or absence of this property determines the presence or absence of that +capability. +* `type`: A string identifying the type of this domain object. Used by the `type` +capability. + +### View Configurations + +Persistent configurations for specific views of domain objects are stored in the +domain object model under the property configurations . This is an object +containing key-value pairs, where keys identify the view, and values are objects +containing view-specific (and view-managed) configuration properties. + +## Modifying Models +When interacting with a domain object's model, it is possible to make +modifications to it directly. __Don't!__ These changes may not be properly detected +by the platform, meaning that other representations of the domain object may not +be updated, changes may not be saved at the expected times, and generally, that +unexpected behavior may occur. Instead, use the `mutation` capability. + +# Capabilities + +Dynamic behavior associated with a domain object is expressed as capabilities. A +capability is a JavaScript object with an interface that is specific to the type +of capability in use. + +Often, there is a relationship between capabilities and services. For instance, +there is an action capability and an actionService , and there is a telemetry +capability as well as a `telemetryService`. Typically, the pattern here is that +the capability will utilize the service for the specific domain object. + +When interacting with domain objects, it is generally preferable to use a +capability instead of a service when the option is available. Capability +interfaces are typically easier to use and/or more powerful in these situations. +Additionally, this usage provides a more robust substitutability mechanism; for +instance, one could configure a plugin such that it provided a totally new +implementation of a given capability which might not invoke the underlying +service, while user code which interacts with capabilities remains indifferent +to this detail. + +## Action Capability + +The `action` capability is present for all domain objects. It allows applicable +`Action` instances to be retrieved and performed for specific domain objects. + +For example: + `domainObject.getCapability("action").perform("navigate"); ` + ...will initiate a navigate action upon the domain object, if an action with + key "navigate" is defined. + +This capability has the following interface: +* `getActions(context)`: Get the actions that are applicable in the specified +action `context`; the capability will fill in the `domainObject` field of this +context if necessary. If context is specified as a string, they will instead be +used as the `key` of the action context. Returns an array of `Action` instances. +* `perform(context)`: Perform an action. This will find and perform the first +matching action available for the specified action context , filling in the +`domainObject` field as necessary. If `context` is specified as a string, they +will instead be used as the `key` of the action context. Returns a `Promise` for +the result of the action that was performed, or `undefined` if no matching action +was found. + +## Composition Capability + +The `composition` capability provides access to domain objects that are +contained by this domain object. While the `composition` property of a domain +object's model describes these contents (by their identifiers), the +`composition` capability provides a means to load the corresponding +`DomainObject` instances in the same order. The absence of this property in the +model will result in the absence of this capability in the domain object. + +This capability has the following interface: + +* `invoke()`: Returns a `Promise` for an array of `DomainObject` instances. + +## Delegation Capability + +The delegation capability is used to communicate the intent of a domain object +to delegate responsibilities, which would normally handled by other +capabilities, to the domain objects in its composition. + +This capability has the following interface: + +* `getDelegates(key)`: Returns a Promise for an array of DomainObject instances, +to which this domain object wishes to delegate the capability with the specified +key . +* `invoke(key)`: Alias of getDelegates(key) . +* `doesDelegate(key)`: Returns true if the domain object does delegate the +capability with the specified key . + +The platform implementation of the delegation capability inspects the domain +object's type definition for a property delegates , whose value is an array of +strings describing which capabilities domain objects of that type wish to +delegate. If this property is not present, the delegation capability will not be +present in domain objects of that type. + +## Editor Capability + +The editor capability is meant primarily for internal use by Edit mode, and +helps to manage the behavior associated with exiting _Edit_ mode via _Save_ or +_Cancel_. Its interface is not intended for general use. However, +`domainObject.hasCapability(editor)` is a useful way of determining whether or +not we are looking at an object in _Edit_ mode. + +## Mutation Capability + +The `mutation` capability provides a means by which the contents of a domain +object's model can be modified. This capability is provided by the platform for +all domain objects, and has the following interface: + +* `mutate(mutator, [timestamp])`: Modify the domain object's model using the +specified `mutator` function. After changes are made, the `modified` property of +the model will be updated with the specified `timestamp` if one was provided, +or with the current system time. +* `invoke(...)`: Alias of `mutate`. + +Changes to domain object models should only be made via the `mutation` +capability; other platform behavior is likely to break (either by exhibiting +undesired behavior, or failing to exhibit desired behavior) if models are +modified by other means. + +### Mutator Function + +The mutator argument above is a function which will receive a cloned copy of the +domain object's model as a single argument. It may return: + +* A `Promise` in which case the resolved value of the promise will be used to +determine which of the following forms is used. +* Boolean `false` in which case the mutation is cancelled. +* A JavaScript object, in which case this object will be used as the new model +for this domain object. +* No value (or, equivalently, `undefined`), in which case the cloned copy +(including any changes made in place by the mutator function) will be used as +the new domain object model. + +## Persistence Capability + +The persistence capability provides a mean for interacting with the underlying +persistence service which stores this domain object's model. It has the +following interface: + +* `persist()`: Store the local version of this domain object, including any +changes, to the persistence store. Returns a Promise for a boolean value, which +will be true when the object was successfully persisted. +* `refresh()`: Replace this domain object's model with the most recent version +from persistence. Returns a Promise which will resolve when the change has +completed. +* `getSpace()`: Return the string which identifies the persistence space which +stores this domain object. + +## Relationship Capability + +The relationship capability provides a means for accessing other domain objects +with which this domain object has some typed relationship. It has the following +interface: + +* `listRelationships()`: List all types of relationships exposed by this object. +Returns an array of strings identifying the types of relationships. +* `getRelatedObjects(relationship)`: Get all domain objects to which this domain +object has the specified type of relationship, which is a string identifier +(as above.) Returns a `Promise` for an array of `DomainObject` instances. + +The platform implementation of the `relationship` capability is present for domain +objects which has a `relationships` property in their model, whose value is an +object containing key-value pairs, where keys are strings identifying +relationship types, and values are arrays of domain object identifiers. + +## Telemetry Capability + +The telemetry capability provides a means for accessing telemetry data +associated with a domain object. It has the following interface: + +* `requestData([request])`: Request telemetry data for this specific domain +object, using telemetry request parameters from the specified request if +provided. This capability will fill in telemetry request properties as-needed +for this domain object. Returns a `Promise` for a `TelemetrySeries`. +* `subscribe(callback, [request])`: Subscribe to telemetry data updates for +this specific domain object, using telemetry request parameters from the +specified request if provided. This capability will fill in telemetry request +properties as-needed for this domain object. The specified callback will be +invoked with TelemetrySeries instances as they arrive. Returns a function which +can be invoked to terminate the subscription, or undefined if no subscription +could be obtained. +* `getMetadata()`: Get metadata associated with this domain object's telemetry. + +The platform implementation of the `telemetry` capability is present for domain +objects which has a `telemetry` property in their model and/or type definition; +this object will serve as a template for telemetry requests made using this +object, and will also be returned by `getMetadata()` above. + +## Type Capability +The `type` capability exposes information about the domain object's type. It has +the same interface as `Type`; see Core API. + +## View Capability + +The `view` capability exposes views which are applicable to a given domain +object. It has the following interface: + +* `invoke()`: Returns an array of extension definitions for views which are +applicable for this domain object. + +# Actions + +Actions are reusable processes/behaviors performed by users within the system, +typically upon domain objects. + +## Action Categories + +The platform understands the following action categories (specifiable as the +`category` parameter of an action's extension definition.) + +* `contextual`: Appears in context menus. +* `view-control`: Appears in top-right area of view (as buttons) in Browse mode + +## Platform Actions +The platform defines certain actions which can be utilized by way of a domain +object's `action` capability. Unless otherwise specified, these act upon (and +modify) the object described by the `domainObject` property of the action's +context. + +* `cancel`: Cancel the current editing action (invoked from Edit mode.) +* `compose`: Place an object in another object's composition. The object to be +added should be provided as the `selectedObject` of the action context. +* `edit`: Start editing an object (enter Edit mode.) +* `fullscreen`: Enter full screen mode. +* `navigate`: Make this object the focus of navigation (e.g. highlight it within +the tree, display a view of it to the right.) +* `properties`: Show the 'Edit Properties' dialog. +* `remove`: Remove this domain object from its parent's composition. (The +parent, in this case, is whichever other domain object exposed this object by +way of its `composition` capability.) +* `save`: Save changes (invoked from Edit mode.) +* `window`: Open this object in a new window. + +# Policies + +Policies are consulted to determine when certain behavior in Open MCT Web is +allowed. Policy questions are assigned to certain categories, which broadly +describe the type of decision being made; within each category, policies have a +candidate (the thing which may or may not be allowed) and, optionally, a context +(describing, generally, the context in which the decision is occurring.) + +The types of objects passed for 'candidate' and 'context' vary by category; +these types are documented below. + +## Policy Categories + +The platform understands the following policy categories (specifiable as the +`category` parameter of an policy's extension definition.) + +* `action`: Determines whether or not a given action is allowable. The candidate +argument here is an Action; the context is its action context object. +* `composition`: Determines whether or not domain objects of a given type are +allowed to contain domain objects of another type. The candidate argument here +is the container's `Type`; the context argument is the `Type` of the object to be +contained. +* `view`: Determines whether or not a view is applicable for a domain object. +The candidate argument is the view's extension definition; the context argument +is the `DomainObject` to be viewed. + +# Build-Test-Deploy +Open MCT Web is designed to support a broad variety of build and deployment +options. The sources can be deployed in the same directory structure used during +development. A few utilities are included to support development processes. + +## Command-line Build +Open MCT Web includes a script for building via command line using Maven 3.0.4 +[https://maven.apache.org/](). + +Invoking mvn clean install will: + +* Check code style using JSLint. The build will fail if JSLint raises any warnings. +* Run the test suite (see below.) The build will fail if any tests fail. +* Populate version info (e.g. commit hash, build time.) +* Produce a web archive (`.war`) artifact in the `target` directory. + +The produced artifact contains a subset of the repository's own folder +hierarchy, omitting tests and example bundles. + +Note that an internet connection is required to run this build, in order to +download build dependencies. + +## Test Suite + +Open MCT Web uses Jasmine [http://jasmine.github.io/]() for automated testing. +The file `test.html` included at the top level of the source repository, can be +run from the browser to perform tests for all active bundles, as defined in +`bundle.json`. + +To define tests for a bundle: + +* Include a directory named `test` within that bundle. +* In the `test` directory, include a file named `suite.json`. This will identify +which scripts will be tested. +* The file `suite.json` must contain a JSON array of strings, where each string +is the name of a script to be tested. These names should include any directory +paths to the script after (but not including) the `src` folder, and should not +include the file's `.js` extension. (Note that while Open MCT Web's framework +allows a different name to be chosen for the src directory, the test runner +does not: This directory must be named `src` for the test runner to find it.) +* For each script to be tested, a corresponding test script should be located in +the bundle's `test` directory. This should include the suffix Spec at the end of +the filename (but before the `.js` extension.) This test script should be an AMD +module which uses the Jasmine API to declare its test behavior. It should +declare an AMD dependency on the script to be tested, using a relative path. + +For example, if writing tests for a bundle at example/foo with two scripts: +* `example/foo/src/controllers/FooController.js` +* `example/foo/src/directives/FooDirective.js` + +First, these scripts should be identified in `example/foo/test/suite.json` e.g. +with contents:`[ "controllers/FooController", "directives/FooDirective" ]` + +Then, scripts which describe these tests should be written. For example, test +`example/foo/test/controllers/FooControllerSpec.js` could look like: + + /*global define,Promise,describe,it,expect,beforeEach*/ + + define( + ["../../src/controllers/FooController"], + function (FooController) { + "use strict"; + + + describe("The foo controller", function () { + it("does something", function () { + var controller = new FooController(); + expect(controller.foo()).toEqual("foo"); + }); + }); + } + ); + + +## Code Coverage + +In addition to running tests, the test runner will also capture code coverage +information using [Blanket.JS](http://blanketjs.org/) and display this at the +bottom of the screen. Currently, only statement coverage is displayed. + +## Deployment +Open MCT Web is built to be flexible in terms of the deployment strategies it +supports. In order to run in the browser, Open MCT Web needs: + +1. HTTP access to sources/resources for the framework, platform, and all active +bundles. +2. Access to any external services utilized by active bundles. (This means that +external services need to support HTTP or some other web-accessible interface, +like WebSockets.) + +Any HTTP server capable of serving flat files is sufficient for the first point. +The command-line build also packages Open MCT Web into a `.war` file for easier +deployment on containers such as Apache Tomcat. + +The second point may be less flexible, as it depends upon the specific services +to be utilized by Open MCT Web. Because of this, it is often the set of external +services (and the manner in which they are exposed) that determine how to deploy +Open MCT Web. + +One important constraint to consider in this context is the browser's same +origin policy. If external services are not on the same apparent host and port +as the client (from the perspective of the browser) then access may be +disallowed. There are two workarounds if this occurs: + +* Make the external service appear to be on the same host/port, either by +actually deploying it there, or by proxying requests to it. +* Enable CORS (cross-origin resource sharing) on the external service. This is +only possible if the external service can be configured to support CORS. Care +should be exercised if choosing this option to ensure that the chosen +configuration does not create a security vulnerability. + +Examples of deployment strategies (and the conditions under which they make the +most sense) include: + +* If the external services that Open MCT Web will utilize are all running on +Apache Tomcat [https://tomcat.apache.org/](), then it makes sense to run Open +MCT Web from the same Tomcat instance as a separate web application. The +`.war` artifact produced by the command line build facilitates this deployment +option. (See [https://tomcat.apache.org/tomcat-8.0-doc/deployer-howto.html() for +general information on deploying in Tomcat.) +* If a variety of external services will be running from a variety of +hosts/ports, then it may make sense to use a web server that supports proxying, +such as the Apache HTTP Server [http://httpd.apache.org/](). In this +configuration, the HTTP server would be configured to proxy (or reverse proxy) +requests at specific paths to the various external services, while providing +Open MCT Web as flat files from a different path. +* If a single server component is being developed to handle all server-side +needs of an Open MCT Web instance, it can make sense to serve Open MCT Web (as +flat files) from the same component using an embedded HTTP server such as Nancy +[http://nancyfx.org/](). +* If no external services are needed (or if the 'external services' will just +be generating flat files to read) it makes sense to utilize a lightweight flat +file HTTP server such as Lighttpd [http://www.lighttpd.net/](). In this +configuration, Open MCT Web sources/resources would be placed at one path, while +the files generated by the external service are placed at another path. +* If all external services support CORS, it may make sense to have an HTTP +server that is solely responsible for making Open MCT Web sources/resources +available, and to have Open MCT Web contact these external services directly. +Again, lightweight HTTP servers such as Lighttpd [http://www.lighttpd.net/]() +are useful in this circumstance. The downside of this option is that additional +configuration effort is required, both to enable CORS on the external services, +and to ensure that Open MCT Web can correctly locate these services. + +Another important consideration is authentication. By design, Open MCT Web does +not handle user authentication. Instead, this should typically be treated as a +deployment-time concern, where authentication is handled by the HTTP server +which provides Open MCT Web, or an external access management system. + +### Configuration +In most of the deployment options above, some level of configuration is likely +to be needed or desirable to make sure that bundles can reach the external +services they need to reach. Most commonly this means providing the path or URL +to an external service. + +Configurable parameters within Open MCT Web are specified via constants +(literally, as extensions of the `constants` category) and accessed via +dependency injection by the scripts which need them. Reasonable defaults for +these constants are provided in the bundle where they are used. Plugins are +encouraged to follow the same pattern. + +Constants may be specified in any bundle; if multiple constants are specified +with the same `key` the highest-priority one will be used. This allows default +values to be overridden by specifying constants with higher priority. + +This permits at least three configuration approaches: + +* Modify the constants defined in their original bundles when deploying. This is +generally undesirable due to the amount of manual work required and potential +for error, but is viable if there are a small number of constants to change. +* Add a separate configuration bundle which overrides the values of these +constants. This is particularly appropriate when multiple configurations (e.g. +development, test, production) need to be managed easily; these can be swapped +quickly by changing the set of active bundles in bundles.json. +* Deploy Open MCT Web and its external services in such a fashion that the +default paths to reach external services are all correct. + +### Configuration Constants + +The following configuration constants are recognized by Open MCT Web bundles: +* CouchDB adapter - `platform/persistence/couch` + * `COUCHDB_PATH`: URL or path to the CouchDB database to be used for domain + object persistence. Should not include a trailing slash. +* ElasticSearch adapter - `platform/persistence/elastic` + * `ELASTIC_ROOT`: URL or path to the ElasticSearch instance to be used for + domain object persistence. Should not include a trailing slash. + * `ELASTIC_PATH`: Path relative to the ElasticSearch instance where domain + object models should be persisted. Should take the form `/`. \ No newline at end of file diff --git a/docs/src/index.html b/docs/src/index.html index e84b405234..e80a6138b2 100644 --- a/docs/src/index.html +++ b/docs/src/index.html @@ -29,8 +29,9 @@ Sections: diff --git a/docs/src/tutorials/images/add-task.png b/docs/src/tutorials/images/add-task.png new file mode 100644 index 0000000000..7780365c5a Binary files /dev/null and b/docs/src/tutorials/images/add-task.png differ diff --git a/docs/src/tutorials/images/bar-plot-2.png b/docs/src/tutorials/images/bar-plot-2.png new file mode 100644 index 0000000000..a32c2b76f1 Binary files /dev/null and b/docs/src/tutorials/images/bar-plot-2.png differ diff --git a/docs/src/tutorials/images/bar-plot-3.png b/docs/src/tutorials/images/bar-plot-3.png new file mode 100644 index 0000000000..0899984a33 Binary files /dev/null and b/docs/src/tutorials/images/bar-plot-3.png differ diff --git a/docs/src/tutorials/images/bar-plot-4.png b/docs/src/tutorials/images/bar-plot-4.png new file mode 100644 index 0000000000..50a9091fa7 Binary files /dev/null and b/docs/src/tutorials/images/bar-plot-4.png differ diff --git a/docs/src/tutorials/images/bar-plot.png b/docs/src/tutorials/images/bar-plot.png new file mode 100644 index 0000000000..2f113d4c6d Binary files /dev/null and b/docs/src/tutorials/images/bar-plot.png differ diff --git a/docs/src/tutorials/images/chrome.png b/docs/src/tutorials/images/chrome.png new file mode 100644 index 0000000000..1b9b7b80d2 Binary files /dev/null and b/docs/src/tutorials/images/chrome.png differ diff --git a/docs/src/tutorials/images/remove-task.png b/docs/src/tutorials/images/remove-task.png new file mode 100644 index 0000000000..015ec95ac4 Binary files /dev/null and b/docs/src/tutorials/images/remove-task.png differ diff --git a/docs/src/tutorials/images/telemetry-1.png b/docs/src/tutorials/images/telemetry-1.png new file mode 100644 index 0000000000..2a606e83c7 Binary files /dev/null and b/docs/src/tutorials/images/telemetry-1.png differ diff --git a/docs/src/tutorials/images/telemetry-2.png b/docs/src/tutorials/images/telemetry-2.png new file mode 100644 index 0000000000..0b34dd90f5 Binary files /dev/null and b/docs/src/tutorials/images/telemetry-2.png differ diff --git a/docs/src/tutorials/images/telemetry-3.png b/docs/src/tutorials/images/telemetry-3.png new file mode 100644 index 0000000000..c235b1d543 Binary files /dev/null and b/docs/src/tutorials/images/telemetry-3.png differ diff --git a/docs/src/tutorials/images/todo-edit.png b/docs/src/tutorials/images/todo-edit.png new file mode 100644 index 0000000000..3c1ba3f5cc Binary files /dev/null and b/docs/src/tutorials/images/todo-edit.png differ diff --git a/docs/src/tutorials/images/todo-list.png b/docs/src/tutorials/images/todo-list.png new file mode 100644 index 0000000000..48c84c63e4 Binary files /dev/null and b/docs/src/tutorials/images/todo-list.png differ diff --git a/docs/src/tutorials/images/todo-restyled.png b/docs/src/tutorials/images/todo-restyled.png new file mode 100644 index 0000000000..9fd7008c2f Binary files /dev/null and b/docs/src/tutorials/images/todo-restyled.png differ diff --git a/docs/src/tutorials/images/todo-selection.png b/docs/src/tutorials/images/todo-selection.png new file mode 100644 index 0000000000..a0ff87514d Binary files /dev/null and b/docs/src/tutorials/images/todo-selection.png differ diff --git a/docs/src/tutorials/images/todo.png b/docs/src/tutorials/images/todo.png new file mode 100644 index 0000000000..44a7b7b2ec Binary files /dev/null and b/docs/src/tutorials/images/todo.png differ diff --git a/docs/src/tutorials/index.md b/docs/src/tutorials/index.md new file mode 100644 index 0000000000..d07466abac --- /dev/null +++ b/docs/src/tutorials/index.md @@ -0,0 +1,3055 @@ +# Open MCT Web Tutorials + +Victor Woeltjen +victor.woeltjen@nasa.gov + +October 14, 2015 +Document Version 2.2 + +Date | Version | Summary of Changes | Author +---------------- | ------- | --------------------------------- | --------------- +May 12, 2015 | 0 | Initial Draft | Victor Woeltjen +June 4, 2015 | 1.0 | Name changes | Victor Woeltjen +July 28, 2015 | 2.0 | Telemetry adapter tutorial | Victor Woeltjen +July 31, 2015 | 2.1 | Clarify telemetry adapter details | Victor Woeltjen +October 14, 2015 | 2.2 | Conversion to markdown | Andrew Henry + +# Introduction + +## This document +This document contains a number of code examples in formatted code blocks. In +many cases these code blocks are repeated in order to highlight code that has +been added or removed as part of the tutorial. In these cases, any lines added +will be indicated with a '+' at the start of the line. Any lines removed will +be indicated with a '-'. + +## Setting Up Open MCT Web + +In this section, we will cover the steps necessary to get a minimal Open MCT Web +developer environment up and running. Once we have this, we will be able to +proceed with writing plugins as described in this tutorial. + +### Prerequisites + +This tutorial assumes you have the following software installed. Version numbers +record what was used in writing this tutorial; the same steps should work with +more recent versions, but this cannot be guaranteed. + +* Node.js v0.12.2: https://nodejs.org/ +* git v1.8.3.4: http://git-scm.com/ +* Google Chrome v42: https://www.google.com/chrome/ +* A text editor. + +Open MCT Web can be run without any of these tools, provided suitable +alternatives are taken; see the [Open MCT Web Developer Guide](../guide/index.md) +for a more general overview of how to run and deploy a Open MCT Web application. + +### Check out Open MCT Web Sources + +First step is to check out Open MCT Web from the source repository. + +`git clone https://github.com/nasa/openmctweb.git openmctweb` + +This will create a copy of the Open MCT Web source code repository in the folder +`openmctweb` (relative to the path from which you ran the command.) +If you have a repository URL, use that as the “path to repo” above. Alternately, +if you received Open MCT Web as a git bundle, the path to that bundle on the +local filesystem can be used instead. +At this point, it will also be useful to branch off of Open MCT Web v0.6.2 +(which was used when writing these tutorials) to begin adding plugins. + + cd openmctweb + git branch open-v0.6.2 + git checkout + +### Configuring Persistence + +In its default configuration, Open MCT Web will try to use ElasticSearch +(expected to be deployed at /elastic on the same HTTP server running Open MCT +Web) to persist user-created domain objects. We don’t need that for these +tutorials, so we will replace the ElasticSearch plugin with the example +persistence plugin. This doesn’t actually persist, so anything we create within +Open MCT Web will be lost on reload, but that’s fine for purposes of these +tutorials. + +To change this configuration, edit bundles.json (at the top level of the Open +MCT Web repository) and replace platform/persistence/elastic with +example/persistence. + +#### Bundle Before + + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + -- "platform/persistence/elastic", + "platform/policy", + + "example/generator" + ] +__bundles.json__ + +#### Bundle After + + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + "platform/policy", + + ++ "example/persistence", + "example/generator" + ] +__bundles.json__ + +### Run a Web Server + +The next step is to run a web server so that you can view the Open MCT Web +client (including the plugins you add to it) in browser. Any web server can +be used for hosting OpenMCTWeb, and a trivial web server is provided in this +package for the purposes of running the tutorials. The provided web server +should not be used in a production environment + +To run the tutorial web server + + node app.js + +### Viewing in Browser + +Once running, you should be able to view Open MCT Web from your browser at +[http://localhost:8080/]() (assuming the web server is running on port 8080, +and OpenMCTWeb is installed at the server's root path). +[Google Chrome](https://www.google.com/chrome/) is recommended for these +tutorials, as Chrome is Open MCT Web’s “test-to” browser. The browser cache +can sometimes interfere with development (masking changes by +using older versions of sources); to avoid this, it is easiest to run Chrome +with Developer Tools expanded, and “Disable cache” selected from the Network +tab, as shown below. + +![Chrome Developer Tools](images/chrome.png) + +# Tutorials + +These tutorials cover three of the common tasks in Open MCT Web: + +* The “to-do list” tutorial illustrates how to add a new application feature. +* The “bar graph” tutorial illustrates how to add a new telemetry visualization. +* The “data set reader” tutorial illustrates how to integrate with a telemetry +backend. + +## To-do List + +The goal of this tutorial is to add a new application feature to Open MCT Web: +To-do lists. Users should be able to create and manage these to track items that +they need to do. This is modelled after the to-do lists at [http://todomvc.com/](). + +### Step 1-Create the Plugin + +The first step to adding a new feature to Open MCT Web is to create the plugin +which will expose that feature. A plugin in Open MCT Web is represented by what +is called a bundle; a bundle, in turn, is a directory which contains a file +bundle.json, which in turn describes where other relevant sources & resources +will be. The syntax of this file is described in more detail in the Open MCT Web +Developer Guide. + +We will create this file in the directory tutorials/todo (we can hereafter refer +to this plugin as tutorials/todo as well.) We will start with an “empty bundle”, +one which exposes no extensions - which looks like: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + + } + } + +__tutorials/todo/bundle.json__ + +We will also include this in our list of active bundles. + +#### Before + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + "platform/policy", + + "example/persistence", + "example/generator" + ] +__bundles.json__ + +#### After + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + "platform/policy", + + "example/persistence", + "example/generator", + + ++ "tutorials/todo" + ] + +__bundles.json__ + +At this point, we can reload Open MCT Web. We haven’t introduced any new +functionality, so we don’t see anything different, but if we run with logging +enabled ([http://localhost:8080/?log=info]()) and check the browser console, we +should see: + +`Resolving extensions for bundle tutorials/todo(To-do Plugin)` + +...which shows that our plugin has loaded. + +### Step 2-Add a Domain Object Type + +Features in a Open MCT Web application are most commonly expressed as domain +objects and/or views thereof. A domain object is some thing that is relevant to +the work that the Open MCT Web application is meant to support. Domain objects +can be created, organized, edited, placed in layouts, and so forth. (For a +deeper explanation of domain objects, see the Open MCT Web Developer Guide.) + +In the case of our to-do list feature, the to-do list itself is the thing we’ll +want users to be able to create and edit. So, we will add that as a new type in +our bundle definition: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + ++ "types": [ + ++ { + ++ "key": "example.todo", + ++ "name": "To-Do List", + ++ "glyph": "j", + ++ "description": "A list of things that need to be done.", + ++ "features": ["creation"] + ++ } + ] + } + } +__tutorials/todo/bundle.json__ + +What have we done here? We’ve stated that this bundle includes extensions of the +category _types_, which is used to describe domain object types. Then, we’ve +included a definition for one such extension, which is the to-do list object. + +Going through the properties we’ve defined: + +* The `key` of `example.todo` will be stored as the machine-readable name for +domain objects of this type. +* The `name` of “To-Do List” is the human-readable name for this type, and will +be shown to users. +* The `glyph` refers to a special character in Open MCT Web’s custom font set; +this will be used as an icon. +* The `description` is also human-readable, and will be used whenever a longer +explanation of what this type is should be shown. +* Finally, the `features` property describes some special features of objects of +this type. Including `creation` here means that we want users to be able to +create this (in other cases, we may wish to expose things as domain objects +which aren’t user-created, in which case we would omit this.) + +If we reload Open MCT Web, we see that our new domain object type appears in the +Create menu: + +![To-Do List](images/todo.png) + +At this point, our to-do list doesn’t do much of anything; we can create them +and give them names, but they don’t have any specific functionality attached, +because we haven’t defined any yet. + +### Step 3-Add a View + +In order to allow a to-do list to be used, we need to define and display its +contents. In Open MCT Web, the pattern that the user expects is that they’ll +click on an object in the left-hand tree, and see a visualization of it to the +right; in Open MCT Web, these visualizations are called views. +A view in Open MCT Web is defined by an Angular template. We’ll add that in the +directory `tutorials/todo/res/templates` (`res` is, by default, the directory +where bundle-related resources are kept, and `templates` is where HTML templates +are stored by convention.) + + + +
    +
  • + + {{task.description}} +
  • +
+__tutorials/todo/res/templates/todo.html__ + +A summary of what’s included: + +* At the top, we have some buttons that we will later wire in to allow the user +to filter down to either complete or incomplete tasks. +* After that, we have a list of tasks. The scope variable `model` is the model +of the domain object being viewed; this contains all of the persistent state +associated with that object. This model is effectively just a JSON document, so +we can choose what goes into it (so long as we take care not to collide with +platform-defined properties; see the Open MCT Web Developer Guide.) Here, we +assume that all tasks will be stored in a property `tasks`, and that each will be +an object containing a `description` (the readable summary of the task) and a +boolean `completed` flag. + +To expose this view in Open MCT Web, we need to declare it in our bundle +definition: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"] + } + ], + ++ "views": [ + ++ { + ++ "key": "example.todo", + ++ "type": "example.todo", + ++ "glyph": "j", + ++ "name": "List", + ++ "templateUrl": "templates/todo.html" + ++ } + ++ ] + } + } +__tutorials/todo/bundle.json__ + +Here, we’ve added another extension, this time belonging to category `views`. It +contains the following properties: + +* Its `key` is its machine-readable name; we’ve given it the same name here as +the domain object type, but could have chosen any unique name. + +* The `type` property tells Open MCT Web that this view is only applicable to +domain objects of that type. This means that we’ll see this view for To-do Lists +that we create, but not for other domain objects (such as Folders.) + +* The `glyph` and `name` properties describe the icon and human-readable name +for this view to display in the UI where needed (if multiple views are available +for To-do Lists, the user will be able to choose one.) + +* Finally, the `templateUrl` points to the Angular template we wrote; this path is +relative to the bundle’s `res` folder. + +This template looks like it should display tasks, but we don’t have any way for +the user to create these yet. As a temporary workaround to test the view, we +will specify an initial state for To-do List domain object models in the +definition of that type. + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"], + ++ "model": { + ++ "tasks": [ + ++ { "description": "Add a type", "completed": true }, + ++ { "description": "Add a view" } + ++ ] + } + } + ], + "views": [ + { + "key": "example.todo", + "type": "example.todo", + "glyph": "j", + "name": "List", + "templateUrl": "templates/todo.html" + } + ] + } + } +__tutorials/todo/bundle.json__ + +Now, when To-do List objects are created in Open MCT Web, they will initially +have the state described by that model property. + +If we reload Open MCT Web, create a To-do List, and navigate to it in the tree, +we should now see: + +![To-Do List](images/todo-list.png) + +This looks roughly like what we want. We’ll handle styling later, so let’s work +on adding functionality. Currently, the filter choices do nothing, and while the +checkboxes can be checked/unchecked, we’re not actually making the changes in +the domain object - if we click over to My Items and come back to our +To-Do List, for instance, we’ll see that those check boxes have returned to +their initial state. + +### Step 4-Add a Controller + +We need to do some scripting to add dynamic behavior to that view. In +particular, we want to: + +* Filter by complete/incomplete status. +* Change the completion state of tasks in the model. + +To do this, we will support this by adding an Angular controller. (See +[https://docs.angularjs.org/guide/controller]() for an overview of controllers.) +We will define that in an AMD module (see [http://requirejs.org/docs/whyamd.html]()) +in the directory `tutorials/todo/src/controllers` (`src` is, by default, the +directory where bundle-related source code is kept, and controllers is where +Angular controllers are stored by convention.) + + define(function () { + function TodoController($scope) { + var showAll = true, + showCompleted; + + // Persist changes made to a domain object's model + function persist() { + var persistence = + $scope.domainObject.getCapability('persistence'); + return persistence && persistence.persist(); + } + + // Change which tasks are visible + $scope.setVisibility = function (all, completed) { + showAll = all; + showCompleted = completed; + }; + + // Toggle the completion state of a task + $scope.toggleCompletion = function (taskIndex) { + $scope.domainObject.useCapability('mutation', function (model) { + var task = model.tasks[taskIndex]; + task.completed = !task.completed; + }); + persist(); + }; + + // Check whether a task should be visible + $scope.showTask = function (task) { + return showAll || (showCompleted === !!(task.completed)); + }; + } + + return TodoController; + }); +__tutorials/todo/src/controllers/TodoController.js__ + +Here, we’ve defined three new functions and placed them in our `$scope`, which +will make them available from the template: + +* `setVisibility` changes which tasks are meant to be visible. The first argument +is a boolean, which, if true, means we want to show everything; the second +argument is the completion state we want to show (which is only relevant if the +first argument is falsy.) + +* `toggleCompletion` changes whether or not a task is complete. We make the +change via the domain object’s `mutation` capability, and then persist the +change via its `persistence` capability. See the Open MCT Web Developer Guide +for more information on these capabilities. + +* `showTask` is meant to be used to help decide if a task should be shown, based +on the current visibility settings. It is true when we have decided to show +everything, or when the completion state matches the state we’ve chosen. (Note +the use of the double-not !! to coerce the completed flag to a boolean, for +equality testing.) + +Note that these functions make reference to `$scope.domainObject;` this is the +domain object being viewed, which is passed into the scope by Open MCT Web +prior to our template being utilized. + +On its own, this controller merely exposes these functions; the next step is to +use them from our template: + + ++
+
+ ++ All + ++ Incomplete + ++ Complete +
+ +
    +
  • + + {{task.description}} +
  • +
+ ++
+__tutorials/todo/res/templates/todo.html__ + +Summary of changes here: + +* First, we surround everything in a `div` which we use to utilize our +`TodoController`. This `div` will also come in handy later for styling. +* From our filters at the top, we change the visibility settings when a different +option is clicked. +* When showing tasks, we check with `showTask` to see if the task matches current +filter settings. +* Finally, when the checkbox for a task is clicked, we make the change in the +model via `toggleCompletion`. + +If we were to try to run at this point, we’d run into problems because the +`TodoController` has not been registered with Angular. We need to first declare +it in our bundle definition, as an extension of category `controllers`: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"], + "model": { + "tasks": [ + { "description": "Add a type", "completed": true }, + { "description": "Add a view" } + ] + } + } + ], + "views": [ + { + "key": "example.todo", + "type": "example.todo", + "glyph": "j", + "name": "List", + "templateUrl": "templates/todo.html" + } + ], + + "controllers": [ + + { + + "key": "TodoController", + + "implementation": "controllers/TodoController.js", + + "depends": [ "$scope" ] + + } + + ] + } + } +__tutorials/todo/bundle.json__ + +In this extension definition we have: + +* A `key`, which again is a machine-readable identifier. This is the name that +templates will reference. +* An `implementation`, which refers to an AMD module. The path is relative to the +`src` directory within the bundle. +* The `depends` property declares the dependencies of this controller. Here, we +want Angular to inject `$scope`, the current Angular scope (which, going back +to our controller, is expected as our first argument.) + +If we reload the browser now, our To-do List looks much the same, but now we are +able to filter down the visible list, and the changes we make will stick around +if we go to My Items and come back. + + +### Step 5-Support Editing + +We now have a somewhat-functional view of our To-Do List, but we’re still +missing some important functionality: Adding and removing tasks! + +This is a good place to discuss the user interface style of Open MCT Web. Open +MCT Web draws a distinction between “using” and “editing” a domain object; in +general, you can only make changes to a domain object while in Edit mode, which +is reachable from the button with a pencil icon. This distinction helps users +keep these tasks separate. + +The distinction between “using” and “editing” may vary depending on what domain +objects or views are being used. While it may be convenient for a developer to +think of “editing” as “any changes made to a domain object,” in practice some of +these activities will be thought of as “using.” + +For this tutorial we’ll consider checking/unchecking tasks as “using” To-Do +Lists, and adding/removing tasks as “editing.” We’ve already implemented the +“using” part, in this case, so let’s focus on editing. + +There are two new pieces of functionality we’ll want out of this step: + +* The ability to add new tasks. +* The ability to remove existing tasks. + +An Editing user interface is typically handled in a tool bar associated with a +view. The contents of this tool bar are defined declaratively in a view’s +extension definition. + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"], + "model": { + "tasks": [ + { "description": "Add a type", "completed": true }, + { "description": "Add a view" } + ] + } + } + ], + "views": [ + { + "key": "example.todo", + "type": "example.todo", + "glyph": "j", + "name": "List", + "templateUrl": "templates/todo.html", + + "toolbar": { + + "sections": [ + + { + + "items": [ + + { + + "text": "Add Task", + + "glyph": "+", + + "method": "addTask", + + "control": "button" + + } + + ] + + }, + + { + + "items": [ + + { + + "glyph": "Z", + + "method": "removeTask", + + "control": "button" + + } + + ] + + } + + ] + + } + } + ], + "controllers": [ + { + "key": "TodoController", + "implementation": "controllers/TodoController.js", + "depends": [ "$scope" ] + } + ] + } + } +__tutorials/todo/bundle.json__ + +What we’ve stated here is that the To-Do List’s view will have a toolbar which +contains two sections (which will be visually separated by a divider), each of +which contains one button. The first is a button labelled “Add Task” that will +invoke an `addTask` method; the second is a button with a glyph (which will appear +as a trash can in Open MCT Web’s custom font set) which will invoke a `removeTask` +method. For more information on forms and tool bars in Open MCT Web, see the +Open MCT Web Developer Guide. + +If we reload and run Open MCT Web, we won’t see any tool bar when we switch over +to Edit mode. This is because the aforementioned methods are expected to be +found on currently-selected elements; we haven’t done anything with selections +in our view yet, so the Open MCT Web platform will filter this tool bar down to +all the applicable controls, which means no controls at all. + +To support selection, we will need to make some changes to our controller: + + define(function () { + + // Form to display when adding new tasks + + var NEW_TASK_FORM = { + + name: "Add a Task", + + sections: [{ + + rows: [{ + + name: 'Description', + + key: 'description', + + control: 'textfield', + + required: true + + }] + + }] + + }; + + + function TodoController($scope, dialogService) { + var showAll = true, + showCompleted; + + // Persist changes made to a domain object's model + function persist() { + var persistence = + $scope.domainObject.getCapability('persistence'); + return persistence && persistence.persist(); + } + + + // Remove a task + + function removeTaskAtIndex(taskIndex) { + + $scope.domainObject.useCapability('mutation', function + + (model) { + + model.tasks.splice(taskIndex, 1); + + }); + + persist(); + + } + + + // Add a task + + function addNewTask(task) { + + $scope.domainObject.useCapability('mutation', function + + (model) { + + model.tasks.push(task); + + }); + + persist(); + + } + + // Change which tasks are visible + $scope.setVisibility = function (all, completed) { + showAll = all; + showCompleted = completed; + }; + + // Toggle the completion state of a task + $scope.toggleCompletion = function (taskIndex) { + $scope.domainObject.useCapability('mutation', function (model) { + var task = model.tasks[taskIndex]; + task.completed = !task.completed; + }); + persist(); + }; + + // Check whether a task should be visible + $scope.showTask = function (task) { + return showAll || (showCompleted === !!(task.completed)); + }; + + // Handle selection state in edit mode + + if ($scope.selection) { + + // Expose the ability to select tasks + + $scope.selectTask = function (taskIndex) { + + $scope.selection.select({ + + removeTask: function () { + + removeTaskAtIndex(taskIndex); + + $scope.selection.deselect(); + + } + + }); + + }; + + + // Expose a view-level selection proxy + + $scope.selection.proxy({ + + addTask: function () { + + dialogService.getUserInput(NEW_TASK_FORM, {}) + + .then(addNewTask); + + } + + }); + + } + } + + return TodoController; + }); +__tutorials/todo/src/controllers/TodoController.js__ + +There are a few changes to pay attention to here. Let’s review them: + +* At the top, we describe the form that should be shown to the user when they +click the _Add Task_ button. This form is described declaratively, and populates +an object that has the same format as tasks in the `tasks` array of our +To-Do List’s model. +* We’ve added an argument to the `TodoController`: The `dialogService`, which is +exposed by the Open MCT Web platform to handle showing dialogs. +* Some utility functions for handling the actual adding and removing of tasks. +These use the `mutation` capability to modify the tasks in the To-Do List’s +model. +* Finally, we check for the presence of a `selection` object in our scope. This +object is provided by Edit mode to manage current selections for editing. When +it is present, we expose a `selectTask` function to our scope to allow selecting +individual tasks; when this occurs, we expose an object to `selection` which has +a `removeTask` method, as expected by the tool bar we’ve defined. We additionally +expose a view proxy, to handle view-level changes (e.g. not associated with any +specific selected object); this has an `addTask` method, which again is expected +by the tool bar we’ve defined. + +Additionally, we need to make changes to our template to select specific tasks +in response to some user gesture. Here, we will select tasks when a user clicks +the description. + +
+ + +
    +
  • + + + + {{task.description}} + + +
  • +
+
+__tutorials/todo/res/templates/todo.html__ + +Finally, the `TodoController` uses the `dialogService` now, so we need to +declare that dependency in its extension definition: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"], + "model": { + "tasks": [ + { "description": "Add a type", "completed": true }, + { "description": "Add a view" } + ] + } + } + ], + "views": [ + { + "key": "example.todo", + "type": "example.todo", + "glyph": "j", + "name": "List", + "templateUrl": "templates/todo.html", + "toolbar": { + "sections": [ + { + "items": [ + { + "text": "Add Task", + "glyph": "+", + "method": "addTask", + "control": "button" + } + ] + }, + { + "items": [ + { + "glyph": "Z", + "method": "removeTask", + "control": "button" + } + ] + } + ] + } + } + ], + "controllers": [ + { + "key": "TodoController", + "implementation": "controllers/TodoController.js", + + "depends": [ "$scope", "dialogService" ] + } + ] + } + } +__tutorials/todo/bundle.json__ + +If we now reload Open MCT Web, we’ll be able to see the new functionality we’ve +added. If we Create a new To-Do List, navigate to it, and click the button with +the Pencil icon in the top-right, we’ll be in edit mode. We see, first, that our +“Add Task” button appears in the tool bar: + +![Edit](images/todo-edit.png) + +If we click on this, we’ll get a dialog allowing us to add a new task: + +![Add task](images/add-task.png) + +Finally, if we click on the description of a specific task, we’ll see a new +button appear, which we can then click on to remove that task: + +![Remove task](images/remove-task.png) + +As always in Edit mode, the user will be able to Save or Cancel any changes they have made. +In terms of functionality, our To-Do List can do all the things we want, but the appearance is still lacking. In particular, we can’t distinguish our current filter choice or our current selection state. + +### Step 6-Customizing Look and Feel + +In this section, our goal is to: + +* Display the current filter choice. +* Display the current task selection (when in Edit mode.) +* Tweak the general aesthetics to our liking. +* Get rid of those default tasks (we can create our own now.) + +To support the first two, we’ll need to expose some methods for checking these +states in the controller: + + + define(function () { + // Form to display when adding new tasks + var NEW_TASK_FORM = { + name: "Add a Task", + sections: [{ + rows: [{ + name: 'Description', + key: 'description', + control: 'textfield', + required: true + }] + }] + }; + + function TodoController($scope, dialogService) { + var showAll = true, + showCompleted; + + // Persist changes made to a domain object's model + function persist() { + var persistence = + $scope.domainObject.getCapability('persistence'); + return persistence && persistence.persist(); + } + + // Remove a task + function removeTaskAtIndex(taskIndex) { + $scope.domainObject.useCapability('mutation', function (model) { + model.tasks.splice(taskIndex, 1); + }); + persist(); + } + + // Add a task + function addNewTask(task) { + $scope.domainObject.useCapability('mutation', function (model) { + model.tasks.push(task); + }); + persist(); + } + + // Change which tasks are visible + $scope.setVisibility = function (all, completed) { + showAll = all; + showCompleted = completed; + }; + + + // Check if current visibility settings match + + $scope.checkVisibility = function (all, completed) { + + return showAll ? all : (completed === showCompleted); + + }; + + // Toggle the completion state of a task + $scope.toggleCompletion = function (taskIndex) { + $scope.domainObject.useCapability('mutation', function (model) { + var task = model.tasks[taskIndex]; + task.completed = !task.completed; + }); + persist(); + }; + + // Check whether a task should be visible + $scope.showTask = function (task) { + return showAll || (showCompleted === !!(task.completed)); + }; + + // Handle selection state in edit mode + if ($scope.selection) { + // Expose the ability to select tasks + $scope.selectTask = function (taskIndex) { + $scope.selection.select({ + removeTask: function () { + removeTaskAtIndex(taskIndex); + $scope.selection.deselect(); + }, + + taskIndex: taskIndex + }); + }; + + + // Expose a check for current selection state + + $scope.isSelected = function (taskIndex) { + + return ($scope.selection.get() || {}).taskIndex === + + taskIndex; + + }; + + // Expose a view-level selection proxy + $scope.selection.proxy({ + addTask: function () { + dialogService.getUserInput(NEW_TASK_FORM, {}) + .then(addNewTask); + } + }); + } + } + + return TodoController; + }); +__tutorials/todo/src/controllers/TodoController.js__ + +A summary of these changes: + +* `checkVisibility` has the same arguments as `setVisibility`, but instead of +making a change, it simply returns a boolean true/false indicating whether those +settings are in effect. The logic reflects the fact that the second parameter is +ignored when showing all. +* To support checking for selection, the index of the currently-selected task is +tracked as part of the selection object. +* Finally, an isSelected function is exposed which checks if the indicated task +is currently selected, using the index from above. + +Additionally, we will want to define some CSS rules in order to reflect these +states visually, and to generally improve the appearance of our view. We add +another file to the res directory of our bundle; this time, it is `css/todo.css` +(with the `css` directory again being a convention.) + + .example-todo div.example-button-group { + margin-top: 12px; + margin-bottom: 12px; + } + + .example-todo .example-button-group a { + padding: 3px; + margin: 3px; + } + + .example-todo .example-button-group a.selected { + border: 1px gray solid; + border-radius: 3px; + background: #444; + } + + .example-todo .example-task-completed .example-task-description { + text-decoration: line-through; + opacity: 0.75; + } + + .example-todo .example-task-description.selected { + background: #46A; + border-radius: 3px; + } + + .example-todo .example-message { + font-style: italic; + } +__tutorials/todo/res/css/todo.css__ + +Here, we have defined classes and appearances for: + +* Our filter choosers (`example-button-group`). +* Our selected and/or completed tasks (`example-task-description`). +* A message, which we will add next, to display when there are no tasks +(`example-message`). + +To include this CSS file in our running instance of Open MCT Web, we need to +declare it in our bundle definition, this time as an extension of category +`stylesheets`: + + { + "name": "To-do Plugin", + "description": "Allows creating and editing to-do lists.", + "extensions": { + "types": [ + { + "key": "example.todo", + "name": "To-Do List", + "glyph": "j", + "description": "A list of things that need to be done.", + "features": ["creation"], + "model": { + "tasks": [] + } + } + ], + "views": [ + { + "key": "example.todo", + "type": "example.todo", + "glyph": "j", + "name": "List", + "templateUrl": "templates/todo.html", + "toolbar": { + "sections": [ + { + "items": [ + { + "text": "Add Task", + "glyph": "+", + "method": "addTask", + "control": "button" + } + ] + }, + { + "items": [ + { + "glyph": "Z", + "method": "removeTask", + "control": "button" + } + ] + } + ] + } + } + ], + "controllers": [ + { + "key": "TodoController", + "implementation": "controllers/TodoController.js", + "depends": [ "$scope", "dialogService" ] + } + ], + + "stylesheets": [ + + { + + "stylesheetUrl": "css/todo.css" + + } + + ] + } + } +__tutorials/todo/bundle.json__ + +Note that we’ve also removed our placeholder tasks from the `model` of the +To-Do List’s type above; now To-Do Lists will start off empty. + +Finally, let’s utilize these changes from our view’s template: + + +
+ +
+ + All + + Incomplete + + Complete +
+ +
    +
  • + + + {{task.description}} + +
  • +
+ +
+ + There are no tasks to show. + +
+ +
+__tutorials/todo/res/templates/todo.html__ + +Now, if we reload our page and create a new To-Do List, we will initially see: + +![Todo Restyled](images/todo-restyled.png) + +If we then go into Edit mode, add some tasks, and select one, it will now be +much clearer what the current selection is (e.g. before we hit the remove button +in the toolbar): + +![Todo Restyled](images/todo-selection.png) + +## Bar Graph + +In this tutorial, we will look at creating a bar graph plugin for visualizing +telemetry data. Specifically, we want some bars that raise and lower to match +the observed state of real-time telemetry; this is particularly useful for +monitoring things like battery charge levels. +It is recommended that the reader completes (or is familiar with) the To-Do +List tutorial before completing this tutorial, as certain concepts discussed +there will be addressed in more brevity here. + +### Step 1-Define the View + +Since the goal is to introduce a new view and expose it from a plugin, we will +want to create a new bundle which declares an extension of category `views`. +We’ll also be defining some custom styles, so we’ll include that extension as +well. We’ll be creating this plugin in `tutorials/bargraph`, so our initial +bundle definition looks like: + + { + "name": "Bar Graph", + "description": "Provides the Bar Graph view of telemetry elements.", + "extensions": { + "views": [ + { + "name": "Bar Graph", + "key": "example.bargraph", + "glyph": "H", + "templateUrl": "templates/bargraph.html", + "needs": [ "telemetry" ], + "delegation": true + } + ], + "stylesheets": [ + { + "stylesheetUrl": "css/bargraph.css" + } + ] + } + } +__tutorials/bargraph/bundle.json__ + +The view definition should look familiar after the To-Do List tutorial, with +some additions: + +* The `needs` property indicates that this view is only applicable to domain +objects with a `telemetry` capability. This ensures that this view is available +for telemetry points, but not for other objects (like folders.) +* The `delegation` property indicates that the above constraint can be satisfied +via capability delegation; that is, by domain objects which delegate the +`telemetry` capability to their contained objects. This allows this view to be +used for Telemetry Panel objects as well as for individual telemetry-providing +domain objects. + +For this tutorial, we’ll assume that we’ve sketched out our template and CSS +file ahead of time to describe the general look we want for the view. These +look like: + +
+
+
High
+
Middle
+
Low
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ Label A +
+
+ Label B +
+
+ Label C +
+
+
+__tutorials/bargraph/res/templates/bargraph.html__ + +Here, three regions are defined. The first will be for tick labels along the +vertical axis, showing the numeric value that certain heights correspond to. The +second will be for the actual bar graphs themselves; three are included here. +The third is for labels along the horizontal axis, which will indicate which +bar corresponds to which telemetry point. Inline `style` attributes are used +wherever dynamic positioning (handled by a script) is anticipated. +The corresponding CSS file which styles and positions these elements: + + .example-bargraph { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + mid-width: 160px; + min-height: 160px; + } + + .example-bargraph .example-tick-labels { + position: absolute; + left: 0; + top: 24px; + bottom: 32px; + width: 72px; + font-size: 75%; + } + + .example-bargraph .example-tick-label { + position: absolute; + right: 0; + height: 1em; + margin-bottom: -0.5em; + padding-right: 6px; + text-align: right; + } + + .example-bargraph .example-graph-area { + position: absolute; + border: 1px gray solid; + left: 72px; + top: 24px; + bottom: 32px; + right: 0; + } + + .example-bargraph .example-bar-labels { + position: absolute; + left: 72px; + bottom: 0; + right: 0; + height: 32px; + } + + .example-bargraph .example-bar-holder { + position: absolute; + top: 0; + bottom: 0; + } + + .example-bargraph .example-graph-tick { + position: absolute; + width: 100%; + height: 1px; + border-bottom: 1px gray dashed; + } + + .example-bargraph .example-bar { + position: absolute; + background: darkcyan; + right: 4px; + left: 4px; + } + + .example-bargraph .example-label { + text-align: center; + font-size: 85%; + padding-top: 6px; + } +__tutorials/bargraph/res/css/bargraph.css__ + +This is already enough that, if we add `“tutorials/bargraph”` to `bundles.json`, +we should be able to run Open MCT Web and see our Bar Graph as an available view +for domain objects which provide telemetry (such as the example +_Sine Wave Generator_) as well as for _Telemetry Panel_ objects: + +![Bar Plot](images/bar-plot.png) + +This means that our remaining work will be to populate and position these +elements based on the actual contents of the domain object. + +### Step 2-Add a Controller + +Our next step will be to begin dynamically populating this template’s contents. +Specifically, our goals for this step will be to: + +* Show one bar per telemetry-providing domain object (for which we’ll be getting +actual telemetry data in subsequent steps.) +* Show correct labels for these objects at the bottom. +* Show numeric labels on the left-hand side. + +Notably, we will not try to show telemetry data after this step. + +To support this, we will add a new controller which supports our Bar Graph view: + + define(function () { + function BarGraphController($scope, telemetryHandler) { + var handle; + + // Add min/max defaults + $scope.low = -1; + $scope.middle = 0; + $scope.high = 1; + + // Convert value to a percent between 0-100, keeping values in points + $scope.toPercent = function (value) { + var pct = 100 * (value - $scope.low) / ($scope.high - $scope.low); + return Math.min(100, Math.max(0, pct)); + }; + + // Use the telemetryHandler to get telemetry objects here + handle = telemetryHandler.handle($scope.domainObject, function () { + $scope.telemetryObjects = handle.getTelemetryObjects(); + $scope.barWidth = + 100 / Math.max(($scope.telemetryObjects).length, 1); + }); + + // Release subscriptions when scope is destroyed + $scope.$on('$destroy', handle.unsubscribe); + } + + return BarGraphController; + }); +__tutorials/bargraph/src/controllers/BarGraphController.js__ + +A summary of what we’ve done here: + +* We’re exposing some numeric values that will correspond to the _low_, _middle_, +and _high_ end of the graph. (The `medium` attribute will be useful for +positioning the middle line, which are graphs will ultimately descend down or +push up from.) +* Add a utility function which converts from numeric values to percentages. This +will help support some positioning in the template. +* Utilize the `telemetryHandler`, provided by the platform, to start listening +to real-time telemetry updates. This will deal with most of the complexity of +dealing with telemetry (e.g. differentiating between individual telemetry points +and telemetry panels, monitoring latest values) and provide us with a useful +interface for populating our view. The the Open MCT Web Developer Guide for more +information on dealing with telemetry. + +Whenever the telemetry handler invokes its callbacks, we update the set of +telemetry objects in view, as well as the width for each bar. + +We will also utilize this from our template: + +
+
+ +
+ + {{value}} + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ + + + + +
+
+
+__tutorials/bargraph/res/templates/bargraph.html__ + +Summarizing these changes: + +* Utilize the exposed `low`, `middle`, and `high` values to populate our labels +along the vertical axis. Additionally, use the `toPercent` function to position +these from the bottom. +* Replace our three hard-coded bars with a repeater that looks at the +`telemetryObjects` exposed by the controller and adds one bar each. +* Position the dashed tick-line using the `middle` value and the `toPercent` +function, lining it up with its label to the left. +* At the bottom, repeat a set of labels for the telemetry-providing domain +objects, with matching alignment to the bars above. We use an existing +representation, `label`, to make this easier. + +Finally, we expose our controller from our bundle definition. Note that the +depends declaration includes both `$scope` as well as the `telemetryHandler` +service we made use of. + + { + "name": "Bar Graph", + "description": "Provides the Bar Graph view of telemetry elements.", + "extensions": { + "views": [ + { + "name": "Bar Graph", + "key": "example.bargraph", + "glyph": "H", + "templateUrl": "templates/bargraph.html", + "needs": [ "telemetry" ], + "delegation": true + } + ], + "stylesheets": [ + { + "stylesheetUrl": "css/bargraph.css" + } + ], + + "controllers": [ + + { + + "key": "BarGraphController", + + "implementation": "controllers/BarGraphController.js", + + "depends": [ "$scope", "telemetryHandler" ] + + } + + ] + } + } +__tutorials/bargraph/bundle.json__ + +When we reload Open MCT Web, we are now able to see that our bar graph view +correctly labels one bar per telemetry-providing domain object, as shown for +this Telemetry Panel containing four Sine Wave Generators. + +![Bar Plot](images/bar-plot-2.png) + +### Step 3-Using Telemetry Data + +Now that our bar graph is labeled correctly, it’s time to start putting data +into the view. + +First, let’s add expose some more functionality from our controller. To make it +simple, we’ll expose the top and bottom for a bar graph for a given +telemetry-providing domain object, as percentages. + + + define(function () { + function BarGraphController($scope, telemetryHandler) { + var handle; + + // Add min/max defaults + $scope.low = -1; + $scope.middle = 0; + $scope.high = 1; + + // Convert value to a percent between 0-100, keeping values in points + $scope.toPercent = function (value) { + var pct = 100 * (value - $scope.low) / ($scope.high - $scope.low); + return Math.min(100, Math.max(0, pct)); + }; + + // Get bottom and top (as percentages) for current value + + $scope.getBottom = function (telemetryObject) { + + var value = handle.getRangeValue(telemetryObject); + + return $scope.toPercent(Math.min($scope.middle, value)); + + } + + $scope.getTop = function (telemetryObject) { + + var value = handle.getRangeValue(telemetryObject); + + return 100 - $scope.toPercent(Math.max($scope.middle, value)); + + } + + // Use the telemetryHandler to get telemetry objects here + handle = telemetryHandler.handle($scope.domainObject, function () { + $scope.telemetryObjects = handle.getTelemetryObjects(); + $scope.barWidth = + 100 / Math.max(($scope.telemetryObjects).length, 1); + }); + + // Release subscriptions when scope is destroyed + $scope.$on('$destroy', handle.unsubscribe); + } + + return BarGraphController; + }); +__tutorials/bargraph/src/controllers/BarGraphController.js__ + +The `telemetryHandler` exposes a method to provide us with our latest data value +(the `getRangeValue` method), and we already have a function to convert from a +numeric value to a percentage within the view, so we just use those. The only +slight complication is that we want our bar to move up or down from the middle +value, so either of our top or bottom position for the bar itself could be +either the middle line, or the data value. We let `Math.min` and `Math.max` +decide this. + +Next, we utilize this functionality from the template: + +
+
+
+ {{value}} +
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+__tutorials/bargraph/res/templates/bargraph.html__ + +Here, we utilize the functions we just provided from the controller to position +the bar, using an ng-style attribute. + +When we reload Open MCT Web, our bar graph view now looks like: + +![Bar Plot](images/bar-plot-3.png) + +### Step 4-View Configuration + +The default minimum and maximum values we’ve provided happen to make sense for +sine waves, but what about other values? We want to provide the user with a +means of configuring these boundaries. + +This is normally done via Edit mode. Since view configuration is a common +problem, the Open MCT Web platform exposes a configuration object - called +`configuration` - into our view’s scope. We can populate it as we please, and +when we return to our view later, those changes will be persisted. + +First, let’s add a tool bar for changing these three values in Edit mode: + + { + "name": "Bar Graph", + "description": "Provides the Bar Graph view of telemetry elements.", + "extensions": { + "views": [ + { + "name": "Bar Graph", + "key": "example.bargraph", + "glyph": "H", + "templateUrl": "templates/bargraph.html", + "needs": [ "telemetry" ], + "delegation": true, + + "toolbar": { + + "sections": [ + + { + + "items": [ + + { + + "name": "Low", + + "property": "low", + + "required": true, + + "control": "textfield", + + "size": 4 + + }, + + { + + "name": "Middle", + + "property": "middle", + + "required": true, + + "control": "textfield", + + "size": 4 + + }, + + { + + "name": "High", + + "property": "high", + + "required": true, + + "control": "textfield", + + "size": 4 + + } + + ] + + } + ] + } + } + ], + "stylesheets": [ + { + "stylesheetUrl": "css/bargraph.css" + } + ], + "controllers": [ + { + "key": "BarGraphController", + "implementation": "controllers/BarGraphController.js", + "depends": [ "$scope", "telemetryHandler" ] + } + ] + } + } +__tutorials/bargraph/bundle.json__ + +As we saw in to To-Do List plugin, a tool bar needs either a selected object or +a view proxy to work from. We will add this to our controller, and additionally +will start reading/writing those properties to the view’s `configuration` +object. + + define(function () { + function BarGraphController($scope, telemetryHandler) { + var handle; + + + // Expose configuration constants directly in scope + + function exposeConfiguration() { + + $scope.low = $scope.configuration.low; + + $scope.middle = $scope.configuration.middle; + + $scope.high = $scope.configuration.high; + + } + + + // Populate a default value in the configuration + + function setDefault(key, value) { + + if ($scope.configuration[key] === undefined) { + + $scope.configuration[key] = value; + + } + + } + + + // Getter-setter for configuration properties (for view proxy) + + function getterSetter(property) { + + return function (value) { + + value = parseFloat(value); + + if (!isNaN(value)) { + + $scope.configuration[property] = value; + + exposeConfiguration(); + + } + + return $scope.configuration[property]; + + }; + } + + + // Add min/max defaults + + setDefault('low', -1); + + setDefault('middle', 0); + + setDefault('high', 1); + + exposeConfiguration($scope.configuration); + + + // Expose view configuration options + + if ($scope.selection) { + + $scope.selection.proxy({ + + low: getterSetter('low'), + + middle: getterSetter('middle'), + + high: getterSetter('high') + + }); + + } + + // Convert value to a percent between 0-100 + $scope.toPercent = function (value) { + var pct = 100 * (value - $scope.low) / + ($scope.high - $scope.low); + return Math.min(100, Math.max(0, pct)); + }; + + // Get bottom and top (as percentages) for current value + $scope.getBottom = function (telemetryObject) { + var value = handle.getRangeValue(telemetryObject); + return $scope.toPercent(Math.min($scope.middle, value)); + } + $scope.getTop = function (telemetryObject) { + var value = handle.getRangeValue(telemetryObject); + return 100 - $scope.toPercent(Math.max($scope.middle, value)); + } + + // Use the telemetryHandler to get telemetry objects here + handle = telemetryHandler.handle($scope.domainObject, function () { + $scope.telemetryObjects = handle.getTelemetryObjects(); + $scope.barWidth = + 100 / Math.max(($scope.telemetryObjects).length, 1); + }); + + // Release subscriptions when scope is destroyed + $scope.$on('$destroy', handle.unsubscribe); + } + + return BarGraphController; + }); +__tutorials/bargraph/src/controllers/BarGraphController.js__ + +A summary of these changes: + +* First, read `low`, `middle`, and `high` from the view configuration instead of +initializing them to explicit values. This is placed into its own function, +since it will be called a lot. +* The function `setDefault` is included; it will be used to set the default +values for `low`, `middle`, and `high` in the view configuration, but only if +they aren’t present. +* The tool bar will treat properties in a view proxy as getter-setters if +they are functions; that is, they will be called with an argument to be used +as a setter, and with no argument to use as a getter. We provide ourselves a +function for making these getter-setters (since we’ll need three) that +additionally handles some checking to ensure that these are actually numbers. +* After that, we actually initialize both the view `configuration` object with +defaults (if needed), and expose its state into the scope. +* Finally, we expose a view proxy which will handle changes to `low`, `middle`, +and `high` as entered by the user from the tool bar. This uses the +getter-setters we defined previously. + +If we reload Open MCT Web and go to a Bar Graph view in Edit mode, we now see +that we can change these bounds from the tool bar. + +![Bar plot](images/bar-plot-4.png) + +## Telemetry Adapter + +The goal of this tutorial is to demonstrate how to integrate Open MCT Web +with an existing telemetry system. + +A summary of the steps we will take: + +* Expose the telemetry dictionary within the user interface. +* Support subscription/unsubscription to real-time streaming data. +* Support historical retrieval of telemetry data. + +### Step 0-Expose Your Telemetry + +As a precondition to integrating telemetry data into Open MCT Web, this +information needs to be available over web-based interfaces. In practice, +this will most likely mean exposing data over HTTP, or over WebSockets. +For purposes of this tutorial, a simple node server is provided to stand +in place of this existing telemetry system. It generates real-time data +and exposes it over a WebSocket connection. + + + /*global require,process,console*/ + + var CONFIG = { + port: 8081, + dictionary: "dictionary.json", + interval: 1000 + }; + + (function () { + "use strict"; + + var WebSocketServer = require('ws').Server, + fs = require('fs'), + wss = new WebSocketServer({ port: CONFIG.port }), + dictionary = JSON.parse(fs.readFileSync(CONFIG.dictionary, "utf8")), + spacecraft = { + "prop.fuel": 77, + "prop.thrusters": "OFF", + "comms.recd": 0, + "comms.sent": 0, + "pwr.temp": 245, + "pwr.c": 8.15, + "pwr.v": 30 + }, + histories = {}, + listeners = []; + + function updateSpacecraft() { + spacecraft["prop.fuel"] = Math.max( + 0, + spacecraft["prop.fuel"] - + (spacecraft["prop.thrusters"] === "ON" ? 0.5 : 0) + ); + spacecraft["pwr.temp"] = spacecraft["pwr.temp"] * 0.985 + + Math.random() * 0.25 + Math.sin(Date.now()); + spacecraft["pwr.c"] = spacecraft["pwr.c"] * 0.985; + spacecraft["pwr.v"] = 30 + Math.pow(Math.random(), 3); + } + + function generateTelemetry() { + var timestamp = Date.now(), sent = 0; + Object.keys(spacecraft).forEach(function (id) { + var state = { timestamp: timestamp, value: spacecraft[id] }; + histories[id] = histories[id] || []; // Initialize + histories[id].push(state); + spacecraft["comms.sent"] += JSON.stringify(state).length; + }); + listeners.forEach(function (listener) { + listener(); + }); + } + + function update() { + updateSpacecraft(); + generateTelemetry(); + } + + function handleConnection(ws) { + var subscriptions = {}, // Active subscriptions for this connection + handlers = { // Handlers for specific requests + dictionary: function () { + ws.send(JSON.stringify({ + type: "dictionary", + value: dictionary + })); + }, + subscribe: function (id) { + subscriptions[id] = true; + }, + unsubscribe: function (id) { + delete subscriptions[id]; + }, + history: function (id) { + ws.send(JSON.stringify({ + type: "history", + id: id, + value: histories[id] + })); + } + }; + + function notifySubscribers() { + Object.keys(subscriptions).forEach(function (id) { + var history = histories[id]; + if (history) { + ws.send(JSON.stringify({ + type: "data", + id: id, + value: history[history.length - 1] + })); + } + }); + } + + // Listen for requests + ws.on('message', function (message) { + var parts = message.split(' '), + handler = handlers[parts[0]]; + if (handler) { + handler.apply(handlers, parts.slice(1)); + } + }); + + // Stop sending telemetry updates for this connection when closed + ws.on('close', function () { + listeners = listeners.filter(function (listener) { + return listener !== notifySubscribers; + }); + }); + + // Notify subscribers when telemetry is updated + listeners.push(notifySubscribers); + } + + update(); + setInterval(update, CONFIG.interval); + + wss.on('connection', handleConnection); + + console.log("Example spacecraft running on port "); + console.log("Press Enter to toggle thruster state."); + process.stdin.on('data', function (data) { + spacecraft['prop.thrusters'] = + (spacecraft['prop.thrusters'] === "OFF") ? "ON" : "OFF"; + console.log("Thrusters " + spacecraft["prop.thrusters"]); + }); + }()); +__tutorial-server/app.js__ + +For purposes of this tutorial, how this server has been implemented is +not important; it has just enough functionality to resemble a WebSocket +interface to a real telemetry system, and niceties such as error-handling +have been omitted. (For more information on using WebSockets, both in the +client and on the server, +[https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API]() is an +excellent starting point.) + +What does matter for this tutorial is the interfaces that are exposed. Once a +WebSocket connection has been established to this server, it accepts plain text +messages in the following formats, and issues JSON-formatted responses. + +The requests it handles are: + +* `dictionary`: Responds with a JSON response with the following fields: + * `type`: “dictionary” + * `value`: … the telemetry dictionary (see below) … +* `subscribe `: Subscribe to new telemetry data for the measurement with +the provided identifier. The server will begin sending messages of the +following form: + * `type`: “data” + * `id`: The identifier for the measurement. + * `value`: An object containing the actual measurement, in two fields: + * `timestamp`: A UNIX timestamp (in milliseconds) for the “measurement” + * `value`: The data value for the measurement (either a number, or a + string) +* `unsubscribe `: Stop receiving new data for the identified measurement. +* `history `: Request a history of all telemetry data for the identified +measurement. + * `type`: “history” + * `id`: The identifier for the measurement. + * `value`: An array of objects containing the actual measurement, each of + which having two fields: + * `timestamp`: A UNIX timestamp (in milliseconds) for the “measurement” + * `value`: The data value for the measurement (either a number, or + a string) + +(Note that the term “measurement” is used to describe a distinct data series +within this system; in other systems, these have been called channels, +mnemonics, telemetry points, or other names. No preference is made here; +Open MCT Web is easily adapted to use the terminology appropriate to your +system.) +Additionally, while running the server from the terminal we can toggle the +state of the “spacecraft” by hitting enter; this will turn the “thrusters” +on and off, having observable changes in telemetry. + +The telemetry dictionary referenced previously is contained in a separate file, +used by the server. It uses a custom format and, for purposes of example, +contains three “subsystems” containing a mix of numeric and string-based +telemetry. + + { + "name": "Example Spacecraft", + "identifier": "sc", + "subsystems": [ + { + "name": "Propulsion", + "identifier": "prop", + "measurements": [ + { + "name": "Fuel", + "identifier": "prop.fuel", + "units": "kilograms", + "type": "float" + }, + { + "name": "Thrusters", + "identifier": "prop.thrusters", + "units": "None", + "type": "string" + } + ] + }, + { + "name": "Communications", + "identifier": "comms", + "measurements": [ + { + "name": "Received", + "identifier": "comms.recd", + "units": "bytes", + "type": "integer" + }, + { + "name": "Sent", + "identifier": "comms.sent", + "units": "bytes", + "type": "integer" + } + ] + }, + { + "name": "Power", + "identifier": "pwr", + "measurements": [ + { + "name": "Generator Temperature", + "identifier": "pwr.temp", + "units": "\u0080C", + "type": "float" + }, + { + "name": "Generator Current", + "identifier": "pwr.c", + "units": "A", + "type": "float" + }, + { + "name": "Generator Voltage", + "identifier": "pwr.v", + "units": "V", + "type": "float" + } + ] + } + ] + } +__tutorial-server/dictionary.json__ + +It should be noted that neither the interface for the example server nor the +dictionary format are expected by Open MCT Web; rather, these are intended to +stand in for some existing source of telemetry data to which we wish to adapt +Open MCT Web. + +We can run this example server by: + + cd tutorial-server + npm install ws + node app.js + +To verify that this is running and try out its interface, we can use a tool +like [https://www.npmjs.com/package/wscat](): + + wscat -c ws://localhost:8081 + connected (press CTRL+C to quit) + > dictionary + < {"type":"dictionary","value":{"name":"Example Spacecraft","identifier":"sc","subsystems":[{"name":"Propulsion","identifier":"prop","measurements":[{"name":"Fuel","identifier":"prop.fuel","units":"kilograms","type":"float"},{"name":"Thrusters","identifier":"prop.thrusters","units":"None","type":"string"}]},{"name":"Communications","identifier":"comms","measurements":[{"name":"Received","identifier":"comms.recd","units":"bytes","type":"integer"},{"name":"Sent","identifier":"comms.sent","units":"bytes","type":"integer"}]},{"name":"Power","identifier":"pwr","measurements":[{"name":"Generator Temperature","identifier":"pwr.temp","units":"€C","type":"float"},{"name":"Generator Current","identifier":"pwr.c","units":"A","type":"float"},{"name":"Generator Voltage","identifier":"pwr.v","units":"V","type":"float"}]}]}} + +Now that the example server’s interface is reasonably well-understood, a plugin +can be written to adapt Open MCT Web to utilize it. + +### Step 1-Add a Top-level Object + +Since Open MCT Web uses an “object-first” approach to accessing data, before +we’ll be able to do anything with this new data source, we’ll need to have a +way to explore the available measurements in the tree. In this step, we will +add a top-level object which will serve as a container; in the next step, we +will populate this with the contents of the telemetry dictionary (which we +will retrieve from the server.) + + { + "name": "Example Telemetry Adapter", + "extensions": { + "types": [ + { + "name": "Spacecraft", + "key": "example.spacecraft", + "glyph": "o" + } + ], + "roots": [ + { + "id": "example:sc", + "priority": "preferred", + "model": { + "type": "example.spacecraft", + "name": "My Spacecraft", + "composition": [] + } + } + ] + } + } +__tutorials/telemetry/bundle.json__ + +Here, we’ve created our initial telemetry plugin. This exposes a new domain +object type (the “Spacecraft”, which will be represented by the contents of the +telemetry dictionary) and also adds one instance of it as a root-level object +(by declaring an extension of category roots.) We have also set priority to +preferred so that this shows up near the top, instead of below My Items. + +If we include this in our set of active bundles: + + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + "platform/policy", + + "example/persistence", + "example/generator" + ] + [ + "platform/framework", + "platform/core", + "platform/representation", + "platform/commonUI/about", + "platform/commonUI/browse", + "platform/commonUI/edit", + "platform/commonUI/dialog", + "platform/commonUI/general", + "platform/containment", + "platform/telemetry", + "platform/features/layout", + "platform/features/pages", + "platform/features/plot", + "platform/features/scrolling", + "platform/forms", + "platform/persistence/queue", + "platform/policy", + + "example/persistence", + "example/generator", + + + "tutorials/telemetry" + ] +__bundles.json__ + +...we will be able to reload Open MCT Web and see that it is present: + +![Telemetry](images/telemetry-1.png) + +Now, we have somewhere in the UI to put the contents of our telemetry +dictionary. + +### Step 2-Expose the Telemetry Dictionary + +In order to expose the telemetry dictionary, we first need to read it from the +server. Our first step will be to add a service that will handle interactions +with the server; this will not be used by Open MCT Web directly, but will be +used by subsequent components we add. + + /*global define,WebSocket*/ + + define( + [], + function () { + "use strict"; + + function ExampleTelemetryServerAdapter($q, wsUrl) { + var ws = new WebSocket(wsUrl), + dictionary = $q.defer(); + + // Handle an incoming message from the server + ws.onmessage = function (event) { + var message = JSON.parse(event.data); + + switch (message.type) { + case "dictionary": + dictionary.resolve(message.value); + break; + } + }; + + // Request dictionary once connection is established + ws.onopen = function () { + ws.send("dictionary"); + }; + + return { + dictionary: function () { + return dictionary.promise; + } + }; + } + + return ExampleTelemetryServerAdapter; + } + ); +__tutorials/telemetry/src/ExampleTelemetryServerAdapter.js__ + +When created, this service initiates a connection to the server, and begins +loading the dictionary. This will occur asynchronously, so the `dictionary()` +method it exposes returns a `Promise` for the loaded dictionary +(`dictionary.json` from above), using Angular’s `$q` +(see [https://docs.angularjs.org/api/ng/service/$q]().) Note that error- and +close-handling for this WebSocket connection have been omitted for brevity. + +Once the dictionary has been loaded, we will want to represent its contents +as domain objects. Specifically, we want subsystems to appear as objects +under My Spacecraft, and measurements to appear as objects within those +subsystems. This means that we need to convert the data from the dictionary +into domain object models, and expose these to Open MCT Web via a +`modelService`. + + /*global define*/ + + define( + function () { + "use strict"; + + var PREFIX = "example_tlm:", + FORMAT_MAPPINGS = { + float: "number", + integer: "number", + string: "string" + }; + + function ExampleTelemetryModelProvider(adapter, $q) { + var modelPromise, empty = $q.when({}); + + // Check if this model is in our dictionary (by prefix) + function isRelevant(id) { + return id.indexOf(PREFIX) === 0; + } + + // Build a domain object identifier by adding a prefix + function makeId(element) { + return PREFIX + element.identifier; + } + + // Create domain object models from this dictionary + function buildTaxonomy(dictionary) { + var models = {}; + + // Create & store a domain object model for a measurement + function addMeasurement(measurement) { + var format = FORMAT_MAPPINGS[measurement.type]; + models[makeId(measurement)] = { + type: "example.measurement", + name: measurement.name, + telemetry: { + key: measurement.identifier, + ranges: [{ + key: "value", + name: "Value", + units: measurement.units, + format: format + }] + } + }; + } + + // Create & store a domain object model for a subsystem + function addSubsystem(subsystem) { + var measurements = + (subsystem.measurements || []); + models[makeId(subsystem)] = { + type: "example.subsystem", + name: subsystem.name, + composition: measurements.map(makeId) + }; + measurements.forEach(addMeasurement); + } + + (dictionary.subsystems || []).forEach(addSubsystem); + + return models; + } + + // Begin generating models once the dictionary is available + modelPromise = adapter.dictionary().then(buildTaxonomy); + + return { + getModels: function (ids) { + // Return models for the dictionary only when they + // are relevant to the request. + return ids.some(isRelevant) ? modelPromise : empty; + } + }; + } + + return ExampleTelemetryModelProvider; + } + ); +__tutorials/telemetry/src/ExampleTelemetryModelProvider.js__ + +This script implements a `provider` for `modelService`; the `modelService` is a +composite service, meaning that multiple such services can exist side by side. +(For example, there is another `provider` for `modelService` that reads domain +object models from the persistence store.) + +Here, we read the dictionary using the server adapter from above; since this +will be loaded asynchronously, we use promise-chaining (see +[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then#Chaining]()) +to take that result and build up an object mapping identifiers to new domain +object models. This is returned from our `modelService`, but only when the +request actually calls for identifiers that look like they’re from the +dictionary. This means that loading other models is not blocked by loading the +dictionary. (Note that the `modelService` contract allows us to return either a +sub- or superset of the requested models, so it is fine to always return the +whole dictionary.) + +Some notable points to call out here: + +* Every subsystem and every measurement from the dictionary has an `identifier` +field declared. We use this as part of the domain object identifier, but we +also prefix it with `example_tlm`:. This accomplishes a few things: + * We can easily tell whether an identifier is expected to be in the + dictionary or not. + * We avoid naming collisions with other model providers. + * Finally, Open MCT Web uses the colon prefix as a hint that this domain + object will not be in the persistence store. +* A couple of new types are introduced here (in the `type` field of the domain +object models we create); we will need to define these as extensions as well in +order for them to display correctly. +* The `composition` field of each subsystem contained the Open MCT Web +identifiers of all the measurements in that subsystem. This `composition` field +will be used by Open MCT Web to determine what domain objects contain other +domain objects (e.g. to populate the tree.) +* The `telemetry` field of each measurement will be used by Open MCT Web to +understand how to request and interpret telemetry data for this object. The +`key` is the machine-readable identifier for this measurement within the +telemetry system; the `ranges` provide metadata about the values for this data. +(A separate field, `domains`, provides metadata about timestamps or other +ordering properties of the data, but this will be the same for all +measurements, so we will define that later at the type level.) + * This field (whose contents will be merged atop the telemetry property we +define at the type-level) will serve as a template for later `telemetry` +requests to the `telemetryService`, so we’ll see the properties we define here +again later in Steps 3 and 4. + +This allows our telemetry dictionary to be expressed as domain object models +(and, in turn, as domain objects), but these objects still aren’t reachable. To +fix this, we will need another script which will add these subsystems to the +root-level object we added in Step 1. + + /*global define*/ + + define( + function () { + "use strict"; + + var TAXONOMY_ID = "example:sc", + PREFIX = "example_tlm:"; + + function ExampleTelemetryInitializer(adapter, objectService) { + // Generate a domain object identifier for a dictionary element + function makeId(element) { + return PREFIX + element.identifier; + } + + // When the dictionary is available, add all subsystems + // to the composition of My Spacecraft + function initializeTaxonomy(dictionary) { + // Get the top-level container for dictionary objects + // from a group of domain objects. + function getTaxonomyObject(domainObjects) { + return domainObjects[TAXONOMY_ID]; + } + + // Populate + function populateModel(taxonomyObject) { + return taxonomyObject.useCapability( + "mutation", + function (model) { + model.name = + dictionary.name; + model.composition = + dictionary.subsystems.map(makeId); + } + ); + } + + // Look up My Spacecraft, and populate it accordingly. + objectService.getObjects([TAXONOMY_ID]) + .then(getTaxonomyObject) + .then(populateModel); + } + + adapter.dictionary().then(initializeTaxonomy); + } + + return ExampleTelemetryInitializer; + } + ); +__tutorials/telemetry/src/ExampleTelemetryInitializer.js__ + +At the conclusion of Step 1, the top-level My Spacecraft object was empty. This +script will wait for the dictionary to be loaded, then load My Spacecraft (by +its identifier), and “mutate” it. The `mutation` capability allows changes to be +made to a domain object’s model. Here, we take this top-level object, update its +name to match what was in the dictionary, and set its `composition` to an array +of domain object identifiers for all subsystems contained in the dictionary +(using the same identifier prefix as before.) + +Finally, we wire in these changes by modifying our plugin’s `bundle.json` to +provide metadata about how these pieces interact (both with each other, and +with the platform): + + { + "name": "Example Telemetry Adapter", + "extensions": { + "types": [ + { + "name": "Spacecraft", + "key": "example.spacecraft", + "glyph": "o" + }, + { + + "name": "Subsystem", + + "key": "example.subsystem", + + "glyph": "o", + + "model": { "composition": [] } + + }, + + { + + "name": "Measurement", + + "key": "example.measurement", + + "glyph": "T", + + "model": { "telemetry": {} }, + + "telemetry": { + + "source": "example.source", + + "domains": [ + + { + + "name": "Time", + + "key": "timestamp" + + } + + ] + + } + + } + ], + "roots": [ + { + "id": "example:sc", + "priority": "preferred", + "model": { + "type": "example.spacecraft", + "name": "My Spacecraft", + "composition": [] + } + } + ], + + "services": [ + + { + + "key": "example.adapter", + + "implementation": "ExampleTelemetryServerAdapter.js", + + "depends": [ "$q", "EXAMPLE_WS_URL" ] + + } + + ], + + "constants": [ + + { + + "key": "EXAMPLE_WS_URL", + + "priority": "fallback", + + "value": "ws://localhost:8081" + + } + + ], + + "runs": [ + + { + + "implementation": "ExampleTelemetryInitializer.js", + + "depends": [ "example.adapter", "objectService" ] + + } + + ], + + "components": [ + + { + + "provides": "modelService", + + "type": "provider", + + "implementation": "ExampleTelemetryModelProvider.js", + + "depends": [ "example.adapter", "$q" ] + + } + + ] + } + } +__tutorials/telemetry/bundle.json__ + +A summary of what we’ve added here: + +* New type definitions have been added to represent Subsystems and Measurements, +respectively. + * Measurements have a `telemetry` field; this is similar to the `telemetry` + field added in the model, but contains properties that will be common among + all Measurements. In particular, the `source` field will be used later as a + symbolic identifier for the telemetry data source. + * We have also added some “initial models” for these two types using the + `model` field. While domain objects of these types cannot be created via the + Create menu, some policies will look at initial models to predict what + capabilities domain objects of certain types would have, so we want to + ensure that Subsystems and Measurements will be recognized as having + `composition` and `telemetry` capabilities, respectively. +* The adapter to the WebSocket server has been added as a service with the +symbolic name `example.adapter`; it is depended-upon elsewhere within this +plugin. +* A constant, `EXAMPLE_WS_URL`, is defined, and depended-upon by +`example.server`. Setting `priority` to `fallback` means this constant will be +overridden if defined anywhere else, allowing configuration bundles to specify +different URLs for the WebSocket connection. +* The initializer script is registered using the `runs` category of extension, +to ensure that this executes (and populates the contents of the top-level My +Spacecraft object) once Open MCT Web is started. + * This depends upon the `example.adapter` service we exposed above, as well + as Angular’s `$q`; these services will be made available in the constructor + call. +* Finally, the `modelService` provider which presents dictionary elements as +domain object models is exposed. Since `modelService` is a composite service, +this is registered under the extension category `components`. + * As with the initializer, this depends upon the `example.adapter` service + we exposed above, as well as Angular’s `$q`; these services will be made + available in the constructor call. + +Now if we run Open MCT Web (assuming our example telemetry server is also +running) and expand our top-level node completely, we see the contents of our +dictionary: + +![Telemetry 2](images/telemetry-2.png) + + +Note that “My Spacecraft” has changed its name to “Example Spacecraft”, which +is the name it had in the dictionary. + +### Step 3-Historical Telemetry + +After Step 2, we are able to see our dictionary in the user interface and click +around our different measurements, but we don’t see any data. We need to give +ourselves the ability to retrieve this data from the server. In this step, we +will do so for the server’s historical telemetry. + +Our first step will be to add a method to our server adapter which allows us to +send history requests to the server: + + /*global define,WebSocket*/ + + define( + [], + function () { + "use strict"; + + function ExampleTelemetryServerAdapter($q, wsUrl) { + var ws = new WebSocket(wsUrl), + + histories = {}, + dictionary = $q.defer(); + + // Handle an incoming message from the server + ws.onmessage = function (event) { + var message = JSON.parse(event.data); + + switch (message.type) { + case "dictionary": + dictionary.resolve(message.value); + break; + + case "history": + + histories[message.id].resolve(message); + + delete histories[message.id]; + + break; + } + }; + + // Request dictionary once connection is established + ws.onopen = function () { + ws.send("dictionary"); + }; + + return { + dictionary: function () { + return dictionary.promise; + }, + + history: function (id) { + + histories[id] = histories[id] || $q.defer(); + + ws.send("history " + id); + + return histories[id].promise; + + } + }; + } + + return ExampleTelemetryServerAdapter; + } + ); + +__tutorials/telemetry/src/ExampleTelemetryServerAdapter.js__ + +When the `history` method is called, a new request is issued to the server for +historical telemetry, _unless_ a request for the same historical telemetry is +still pending. Similarly, when historical telemetry arrives for a given +identifier, the pending promise is resolved. + +This `history` method will be used by a `telemetryService` provider which we +will implement: + + /*global define*/ + + define( + ['./ExampleTelemetrySeries'], + function (ExampleTelemetrySeries) { + "use strict"; + + var SOURCE = "example.source"; + + function ExampleTelemetryProvider(adapter, $q) { + // Used to filter out requests for telemetry + // from some other source + function matchesSource(request) { + return (request.source === SOURCE); + } + + return { + requestTelemetry: function (requests) { + var packaged = {}, + relevantReqs = requests.filter(matchesSource); + + // Package historical telemetry that has been received + function addToPackage(history) { + packaged[SOURCE][history.id] = + new ExampleTelemetrySeries(history.value); + } + + // Retrieve telemetry for a specific measurement + function handleRequest(request) { + var key = request.key; + return adapter.history(key).then(addToPackage); + } + + packaged[SOURCE] = {}; + return $q.all(relevantReqs.map(handleRequest)) + .then(function () { return packaged; }); + }, + subscribe: function (callback, requests) { + return function () {}; + } + }; + } + + return ExampleTelemetryProvider; + } + ); +__tutorials/telemetry/src/ExampleTelemetryProvider.js__ + +The `requestTelemetry` method of a `telemetryService` is expected to take an +array of requests (each with `source` and `key` parameters, identifying the +general source of data and the specific element within that source, respectively) and +return a Promise for any telemetry data it knows of which satisfies those +requests, packaged in a specific way. This packaging is as an object containing +key-value pairs, where keys correspond to `source` properties of requests and +values are key-value pairs, where keys correspond to `key` properties of requests +and values are `TelemetrySeries` objects. (We will see our implementation +below.) + +To do this, we create a container for our telemetry source, and consult the +adapter to get telemetry histories for any relevant requests, then package +them as they come in. The `$q.all` method is used to return a single Promise +that will resolve only when all histories have been packaged. Promise-chaining +is used to ensure that the resolved value will be the fully-packaged data. + +It is worth mentioning here that the `requests` we receive should look a little +familiar. When Open MCT Web generates a `request` object associated with a +domain object, it does so by merging together three JavaScript objects: + +* First, the `telemetry` property from that domain object’s type definition. +* Second, the `telemetry` property from that domain object’s model. +* Finally, the `request` object that was passed in via that domain object’s +`telemetry` capability. + +As such, the `source` and `key` properties we observe here will come from the +type definition and domain object model, respectively, as we specified them +during Step 2. (Or, they might come from somewhere else entirely, if we have +other telemetry-providing domain objects in our system; that is something we +check for using the `source` property.) + +Finally, note that we also have a `subscribe` method, to satisfy the interface of +`telemetryService`, but this `subscribe` method currently does nothing. + +This script uses an `ExampleTelemetrySeries` class, which looks like: + + /*global define*/ + + define( + function () { + "use strict"; + + function ExampleTelemetrySeries(data) { + return { + getPointCount: function () { + return data.length; + }, + getDomainValue: function (index) { + return (data[index] || {}).timestamp; + }, + getRangeValue: function (index) { + return (data[index] || {}).value; + } + }; + } + + return ExampleTelemetrySeries; + } + ); +__tutorials/telemetry/src/ExampleTelemetrySeries.js__ + +This takes the array of telemetry values (as returned by the server) and wraps +it with the interface expected by the platform (the methods shown.) + +Finally, we expose this `telemetryService` provider declaratively: + + { + "name": "Example Telemetry Adapter", + "extensions": { + "types": [ + { + "name": "Spacecraft", + "key": "example.spacecraft", + "glyph": "o" + }, + { + "name": "Subsystem", + "key": "example.subsystem", + "glyph": "o", + "model": { "composition": [] } + }, + { + "name": "Measurement", + "key": "example.measurement", + "glyph": "T", + "model": { "telemetry": {} }, + "telemetry": { + "source": "example.source", + "domains": [ + { + "name": "Time", + "key": "timestamp" + } + ] + } + } + ], + "roots": [ + { + "id": "example:sc", + "priority": "preferred", + "model": { + "type": "example.spacecraft", + "name": "My Spacecraft", + "composition": [] + } + } + ], + "services": [ + { + "key": "example.adapter", + "implementation": "ExampleTelemetryServerAdapter.js", + "depends": [ "$q", "EXAMPLE_WS_URL" ] + } + ], + "constants": [ + { + "key": "EXAMPLE_WS_URL", + "priority": "fallback", + "value": "ws://localhost:8081" + } + ], + "runs": [ + { + "implementation": "ExampleTelemetryInitializer.js", + "depends": [ "example.adapter", "objectService" ] + } + ], + "components": [ + { + "provides": "modelService", + "type": "provider", + "implementation": "ExampleTelemetryModelProvider.js", + "depends": [ "example.adapter", "$q" ] + }, + + { + + "provides": "telemetryService", + + "type": "provider", + + "implementation": "ExampleTelemetryProvider.js", + + "depends": [ "example.adapter", "$q" ] + + } + ] + } + } +__tutorials/telemetry/bundle.json__ + +Now, if we navigate to one of our numeric measurements, we should see a plot of +its historical telemetry: + +![Telemetry](images/telemetry-3.png) + +We can now visualize our data, but it doesn’t update over time - we know the +server is continually producing new data, but we have to click away and come +back to see it. We can fix this by adding support for telemetry subscriptions. + +### Step 4-Real-time Telemetry + +Finally, we want to utilize the server’s ability to subscribe to telemetry +from Open MCT Web. To do this, first we want to expose some new methods for +this from our server adapter: + + /*global define,WebSocket*/ + + define( + [], + function () { + "use strict"; + + function ExampleTelemetryServerAdapter($q, wsUrl) { + var ws = new WebSocket(wsUrl), + histories = {}, + + listeners = [], + dictionary = $q.defer(); + + // Handle an incoming message from the server + ws.onmessage = function (event) { + var message = JSON.parse(event.data); + + switch (message.type) { + case "dictionary": + dictionary.resolve(message.value); + break; + case "history": + histories[message.id].resolve(message); + delete histories[message.id]; + break; + + case "data": + + listeners.forEach(function (listener) { + + listener(message); + + }); + + break; + } + }; + + // Request dictionary once connection is established + ws.onopen = function () { + ws.send("dictionary"); + }; + + return { + dictionary: function () { + return dictionary.promise; + }, + history: function (id) { + histories[id] = histories[id] || $q.defer(); + ws.send("history " + id); + return histories[id].promise; + }, + + subscribe: function (id) { + + ws.send("subscribe " + id); + + }, + + unsubscribe: function (id) { + + ws.send("unsubscribe " + id); + + }, + + listen: function (callback) { + + listeners.push(callback); + + } + }; + } + + return ExampleTelemetryServerAdapter; + } + ); +__tutorials/telemetry/src/ExampleTelemetryServerAdapter.js__ + +Here, we have added `subscribe` and `unsubscribe` methods which issue the +corresponding requests to the server. Seperately, we introduce the ability to +listen for `data` messages as they come in: These will contain the data associated +with these subscriptions. + +We then need only to utilize these methods from our `telemetryService`: + + /*global define*/ + + define( + ['./ExampleTelemetrySeries'], + function (ExampleTelemetrySeries) { + "use strict"; + + var SOURCE = "example.source"; + + function ExampleTelemetryProvider(adapter, $q) { + + var subscribers = {}; + + // Used to filter out requests for telemetry + // from some other source + function matchesSource(request) { + return (request.source === SOURCE); + } + + + // Listen for data, notify subscribers + + adapter.listen(function (message) { + + var packaged = {}; + + packaged[SOURCE] = {}; + + packaged[SOURCE][message.id] = + + new ExampleTelemetrySeries([message.value]); + + (subscribers[message.id] || []).forEach(function (cb) { + + cb(packaged); + + }); + + }); + + return { + requestTelemetry: function (requests) { + var packaged = {}, + relevantReqs = requests.filter(matchesSource); + + // Package historical telemetry that has been received + function addToPackage(history) { + packaged[SOURCE][history.id] = + new ExampleTelemetrySeries(history.value); + } + + // Retrieve telemetry for a specific measurement + function handleRequest(request) { + var key = request.key; + return adapter.history(key).then(addToPackage); + } + + packaged[SOURCE] = {}; + return $q.all(relevantReqs.map(handleRequest)) + .then(function () { return packaged; }); + }, + subscribe: function (callback, requests) { + + var keys = requests.filter(matchesSource) + + .map(function (req) { return req.key; }); + + + + function notCallback(cb) { + + return cb !== callback; + + } + + + + function unsubscribe(key) { + + subscribers[key] = + + (subscribers[key] || []).filter(notCallback); + + if (subscribers[key].length < 1) { + + adapter.unsubscribe(key); + + } + + } + + + + keys.forEach(function (key) { + + subscribers[key] = subscribers[key] || []; + + adapter.subscribe(key); + + subscribers[key].push(callback); + + }); + + + + return function () { + + keys.forEach(unsubscribe); + + }; + } + }; + } + + return ExampleTelemetryProvider; + } + ); +__tutorials/telemetry/src/ExampleTelemetryProvider.js__ + +A quick summary of these changes: + +* First, we maintain current subscribers (callbacks) in an object containing +key-value pairs, where keys are request key properties, and values are callback +arrays. +* We listen to new data coming in from the server adapter, and invoke any +relevant callbacks when this happens. We package the data in the same manner +that historical telemetry is packaged (even though in this case we are +providing single-element series objects.) +* Finally, in our `subscribe` method we add callbacks to the lists of active +subscribers. This method is expected to return a function which terminates the +subscription when called, so we do some work to remove subscribers in this +situations. When our subscriber count for a given measurement drops to zero, +we issue an unsubscribe request. (We don’t take any care to avoid issuing +multiple subscribe requests to the server, because we happen to know that the +server can handle this.) + +Running Open MCT Web again, we can still plot our historical telemetry - but +now we also see that it updates in real-time as more data comes in from the +server. diff --git a/example/generator/bundle.json b/example/generator/bundle.json index cdb4736957..7cf1c7b6f2 100644 --- a/example/generator/bundle.json +++ b/example/generator/bundle.json @@ -16,6 +16,23 @@ "implementation": "SinewaveLimitCapability.js" } ], + "formats": [ + { + "key": "example.delta", + "implementation": "SinewaveDeltaFormat.js" + } + ], + "constants": [ + { + "key": "TIME_CONDUCTOR_DOMAINS", + "value": [ + { "key": "time", "name": "Time" }, + { "key": "yesterday", "name": "Yesterday" }, + { "key": "delta", "name": "Delta", "format": "example.delta" } + ], + "priority": -1 + } + ], "types": [ { "key": "generator", @@ -38,6 +55,11 @@ { "key": "yesterday", "name": "Yesterday" + }, + { + "key": "delta", + "name": "Delta", + "format": "example.delta" } ], "ranges": [ diff --git a/platform/commonUI/general/res/sass/_hide-non-functional.scss b/example/generator/src/SinewaveConstants.js similarity index 80% rename from platform/commonUI/general/res/sass/_hide-non-functional.scss rename to example/generator/src/SinewaveConstants.js index 14860ec8ce..29136ebb99 100644 --- a/platform/commonUI/general/res/sass/_hide-non-functional.scss +++ b/example/generator/src/SinewaveConstants.js @@ -19,18 +19,8 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -// Styles to temporarily hide non-functional elements +/*global define,Promise*/ -/******************************** BROWSE */ -.browse-mode { - .browse { - &.top-bar { - display: none; - } - } - - .browse-area.holder { - // When .browse.top-bar is hidden, set the top of the browse-area holder - top: $bodyMargin; - } -} \ No newline at end of file +define({ + START_TIME: Date.now() - 24 * 60 * 60 * 1000 // Now minus a day. +}); diff --git a/example/generator/src/SinewaveDeltaFormat.js b/example/generator/src/SinewaveDeltaFormat.js new file mode 100644 index 0000000000..19f3e631f9 --- /dev/null +++ b/example/generator/src/SinewaveDeltaFormat.js @@ -0,0 +1,68 @@ +/***************************************************************************** + * 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*/ + +define( + ['./SinewaveConstants', 'moment'], + function (SinewaveConstants, moment) { + "use strict"; + + var START_TIME = SinewaveConstants.START_TIME, + FORMAT_REGEX = /^-?\d+:\d+:\d+$/, + SECOND = 1000, + MINUTE = SECOND * 60, + HOUR = MINUTE * 60; + + function SinewaveDeltaFormat() { + } + + function twoDigit(v) { + return v >= 10 ? String(v) : ('0' + v); + } + + SinewaveDeltaFormat.prototype.format = function (value) { + var delta = Math.abs(value - START_TIME), + negative = value < START_TIME, + seconds = Math.floor(delta / SECOND) % 60, + minutes = Math.floor(delta / MINUTE) % 60, + hours = Math.floor(delta / HOUR); + return (negative ? "-" : "") + + [ hours, minutes, seconds ].map(twoDigit).join(":"); + }; + + SinewaveDeltaFormat.prototype.validate = function (text) { + return FORMAT_REGEX.test(text); + }; + + SinewaveDeltaFormat.prototype.parse = function (text) { + var negative = text[0] === "-", + parts = text.replace("-", "").split(":"); + return [ HOUR, MINUTE, SECOND ].map(function (sz, i) { + return parseInt(parts[i], 10) * sz; + }).reduce(function (a, b) { + return a + b; + }, 0) * (negative ? -1 : 1) + START_TIME; + }; + + return SinewaveDeltaFormat; + } +); diff --git a/example/generator/src/SinewaveLimitCapability.js b/example/generator/src/SinewaveLimitCapability.js index 30d222b0c7..ac4f4718a2 100644 --- a/example/generator/src/SinewaveLimitCapability.js +++ b/example/generator/src/SinewaveLimitCapability.js @@ -30,25 +30,25 @@ define( YELLOW = 0.5, LIMITS = { rh: { - cssClass: "s-limit-upr-red", + cssClass: "s-limit-upr s-limit-red", low: RED, high: Number.POSITIVE_INFINITY, name: "Red High" }, rl: { - cssClass: "s-limit-lwr-red", + cssClass: "s-limit-lwr s-limit-red", high: -RED, low: Number.NEGATIVE_INFINITY, name: "Red Low" }, yh: { - cssClass: "s-limit-upr-yellow", + cssClass: "s-limit-upr s-limit-yellow", low: YELLOW, high: RED, name: "Yellow High" }, yl: { - cssClass: "s-limit-lwr-yellow", + cssClass: "s-limit-lwr s-limit-yellow", low: -RED, high: -YELLOW, name: "Yellow Low" diff --git a/example/generator/src/SinewaveTelemetrySeries.js b/example/generator/src/SinewaveTelemetrySeries.js index 1e84034766..fa47f8f59a 100644 --- a/example/generator/src/SinewaveTelemetrySeries.js +++ b/example/generator/src/SinewaveTelemetrySeries.js @@ -25,12 +25,12 @@ * Module defining SinewaveTelemetry. Created by vwoeltje on 11/12/14. */ define( - [], - function () { + ['./SinewaveConstants'], + function (SinewaveConstants) { "use strict"; var ONE_DAY = 60 * 60 * 24, - firstObservedTime = Math.floor(Date.now() / 1000) - ONE_DAY; + firstObservedTime = Math.floor(SinewaveConstants.START_TIME / 1000); /** * @@ -58,6 +58,9 @@ define( }; generatorData.getDomainValue = function (i, domain) { + // delta uses the same numeric values as the default domain, + // so it's not checked for here, just formatted for display + // differently. return (i + offset) * 1000 + firstTime * 1000 - (domain === 'yesterday' ? ONE_DAY : 0); }; diff --git a/example/notifications/bundle.json b/example/notifications/bundle.json new file mode 100644 index 0000000000..bb2d464d64 --- /dev/null +++ b/example/notifications/bundle.json @@ -0,0 +1,47 @@ +{ + "extensions": { + "templates": [ + { + "key": "dialogLaunchTemplate", + "templateUrl": "dialog-launch.html" + }, + { + "key": "notificationLaunchTemplate", + "templateUrl": "notification-launch.html" + } + ], + "controllers": [ + { + "key": "DialogLaunchController", + "implementation": "DialogLaunchController.js", + "depends": [ + "$scope", + "$timeout", + "$log", + "dialogService", + "notificationService" + ] + }, + { + "key": "NotificationLaunchController", + "implementation": "NotificationLaunchController.js", + "depends": [ + "$scope", + "$timeout", + "$log", + "notificationService" + ] + } + ], + "indicators": [ + { + "implementation": "DialogLaunchIndicator.js", + "priority": "fallback" + }, + { + "implementation": "NotificationLaunchIndicator.js", + "priority": "fallback" + } + ] + } +} diff --git a/example/notifications/res/dialog-launch.html b/example/notifications/res/dialog-launch.html new file mode 100644 index 0000000000..bc56e6b4f2 --- /dev/null +++ b/example/notifications/res/dialog-launch.html @@ -0,0 +1,10 @@ + + + + Known | + Unknown | + Error | + Info + + Dialogs + \ No newline at end of file diff --git a/example/notifications/res/notification-launch.html b/example/notifications/res/notification-launch.html new file mode 100644 index 0000000000..1e077bf3be --- /dev/null +++ b/example/notifications/res/notification-launch.html @@ -0,0 +1,10 @@ + + + + Success | + Error | + Alert | + Progress + + Notifications + \ No newline at end of file diff --git a/example/notifications/src/DialogLaunchController.js b/example/notifications/src/DialogLaunchController.js new file mode 100644 index 0000000000..f35d008cd0 --- /dev/null +++ b/example/notifications/src/DialogLaunchController.js @@ -0,0 +1,150 @@ +/***************************************************************************** + * 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 controller for the dialog launch view. This view allows manual + * launching of dialogs for demonstration and testing purposes. It + * also demonstrates the use of the DialogService. + * @param $scope + * @param $timeout + * @param $log + * @param dialogService + * @param notificationService + * @constructor + */ + function DialogLaunchController($scope, $timeout, $log, dialogService, notificationService) { + + /* + Demonstrates launching a progress dialog and updating it + periodically with the progress of an ongoing process. + */ + $scope.launchProgress = function (knownProgress) { + var model = { + title: "Progress Dialog Example", + progress: 0, + hint: "Do not navigate away from this page or close this browser tab while this operation is in progress.", + actionText: "Calculating...", + unknownProgress: !knownProgress, + unknownDuration: false, + severity: "info", + options: [ + { + label: "Cancel Operation", + callback: function () { + $log.debug("Operation cancelled"); + dialogService.dismiss(); + } + }, + { + label: "Do something else...", + callback: function () { + $log.debug("Something else pressed"); + } + } + ] + }; + + function incrementProgress() { + model.progress = Math.min(100, Math.floor(model.progress + Math.random() * 30)); + model.progressText = ["Estimated time remaining: about ", 60 - Math.floor((model.progress / 100) * 60), " seconds"].join(" "); + if (model.progress < 100) { + $timeout(incrementProgress, 1000); + } + } + + if (dialogService.showBlockingMessage(model)) { + //Do processing here + model.actionText = "Processing 100 objects..."; + if (knownProgress) { + $timeout(incrementProgress, 1000); + } + } else { + $log.error("Could not display modal dialog"); + } + }; + + + /* + Demonstrates launching an error dialog + */ + $scope.launchError = function () { + var model = { + title: "Error Dialog Example", + actionText: "Something happened, and it was not good.", + severity: "error", + options: [ + { + label: "Try Again", + callback: function () { + $log.debug("Try Again Pressed"); + dialogService.dismiss(); + } + }, + { + label: "Cancel", + callback: function () { + $log.debug("Cancel Pressed"); + dialogService.dismiss(); + } + } + ] + }; + + if (!dialogService.showBlockingMessage(model)) { + $log.error("Could not display modal dialog"); + } + }; + + /* + Demonstrates launching an error dialog + */ + $scope.launchInfo = function () { + var model = { + title: "Info Dialog Example", + actionText: "This is an example of a blocking info" + + " dialog. This dialog can be used to draw the user's" + + " attention to an event.", + severity: "info", + primaryOption: { + label: "OK", + callback: function () { + $log.debug("OK Pressed"); + dialogService.dismiss(); + } + } + }; + + if (!dialogService.showBlockingMessage(model)) { + $log.error("Could not display modal dialog"); + } + }; + + } + return DialogLaunchController; + } +); diff --git a/example/notifications/src/DialogLaunchIndicator.js b/example/notifications/src/DialogLaunchIndicator.js new file mode 100644 index 0000000000..9330ce2194 --- /dev/null +++ b/example/notifications/src/DialogLaunchIndicator.js @@ -0,0 +1,56 @@ +/***************************************************************************** + * 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,window*/ + +define( + [], + function () { + "use strict"; + + /** + * A tool for manually invoking dialogs. When included this + * indicator will allow for dialogs of different types to be + * launched for demonstration and testing purposes. + * @constructor + */ + function DialogLaunchIndicator() { + + } + + DialogLaunchIndicator.template = 'dialogLaunchTemplate'; + + DialogLaunchIndicator.prototype.getGlyph = function () { + return "i"; + }; + DialogLaunchIndicator.prototype.getGlyphClass = function () { + return 'caution'; + }; + DialogLaunchIndicator.prototype.getText = function () { + return "Launch test dialog"; + }; + DialogLaunchIndicator.prototype.getDescription = function () { + return "Launch test dialog"; + }; + + return DialogLaunchIndicator; + } +); diff --git a/example/notifications/src/NotificationLaunchController.js b/example/notifications/src/NotificationLaunchController.js new file mode 100644 index 0000000000..851e0575f5 --- /dev/null +++ b/example/notifications/src/NotificationLaunchController.js @@ -0,0 +1,172 @@ +/***************************************************************************** + * 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"; + + /** + * Allows launching of notification messages for the purposes of + * demonstration and testing. Also demonstrates use of + * the NotificationService. Notifications are non-blocking messages that + * appear at the bottom of the screen to inform the user of events + * in a non-intrusive way. For more information see the + * {@link NotificationService} + * @param $scope + * @param $timeout + * @param $log + * @param notificationService + * @constructor + */ + function NotificationLaunchController($scope, $timeout, $log, notificationService) { + var messageCounter = 1; + + function getExampleActionText() { + var actionTexts = [ + "Adipiscing turpis mauris in enim elementu hac, enim aliquam etiam.", + "Eros turpis, pulvinar turpis eros eu", + "Lundium nascetur a, lectus montes ac, parturient in natoque, duis risus risus pulvinar pid rhoncus, habitasse auctor natoque!" + ]; + return actionTexts[Math.floor(Math.random()*3)]; + } + + function getExampleActions() { + var actions = [ + { + label: "Try Again", + callback: function () { + $log.debug("Try Again pressed"); + } + }, + { + label: "Remove", + callback: function () { + $log.debug("Remove pressed"); + } + }, + { + label: "Cancel", + callback: function () { + $log.debug("Cancel pressed"); + } + } + ]; + + // Randomly remove some actions off the top; leave at least one + actions.splice(0,Math.floor(Math.random() * actions.length)); + + return actions; + } + + function getExampleSeverity() { + var severities = [ + "info", + "alert", + "error" + ]; + return severities[Math.floor(Math.random() * severities.length)]; + } + + /** + * Launch a new notification with a severity level of 'Error'. + */ + $scope.newError = function(){ + + notificationService.notify({ + title: "Example error notification " + messageCounter++, + hint: "An error has occurred", + severity: "error", + primaryOption: { + label: 'Retry', + callback: function() { + $log.info('Retry clicked'); + } + }, + options: getExampleActions()}); + }; + /** + * Launch a new notification with a severity of 'Alert'. + */ + $scope.newAlert = function(){ + + notificationService.notify({ + title: "Alert notification " + (messageCounter++), + hint: "This is an alert message", + severity: "alert", + primaryOption: { + label: 'Retry', + callback: function() { + $log.info('Retry clicked'); + } + }, + options: getExampleActions()}); + }; + + + /** + * Launch a new notification with a progress bar that is updated + * periodically, tracking an ongoing process. + */ + $scope.newProgress = function(){ + + var notificationModel = { + title: "Progress notification example", + severity: "info", + progress: 0, + actionText: getExampleActionText(), + unknownProgress: false + }; + + /** + * Simulate an ongoing process and update the progress bar. + * @param notification + */ + function incrementProgress(notificationModel) { + notificationModel.progress = Math.min(100, Math.floor(notificationModel.progress + Math.random() * 30)); + notificationModel.progressText = ["Estimated time" + + " remaining:" + + " about ", 60 - Math.floor((notificationModel.progress / 100) * 60), " seconds"].join(" "); + if (notificationModel.progress < 100) { + $timeout(function(){incrementProgress(notificationModel);}, 1000); + } + } + + notificationService.notify(notificationModel); + incrementProgress(notificationModel); + }; + + /** + * Launch a new notification with severity level of INFO. + */ + $scope.newInfo = function(){ + + notificationService.info({ + title: "Example Info notification " + messageCounter++ + }); + }; + + } + return NotificationLaunchController; + } +); diff --git a/example/notifications/src/NotificationLaunchIndicator.js b/example/notifications/src/NotificationLaunchIndicator.js new file mode 100644 index 0000000000..174810d721 --- /dev/null +++ b/example/notifications/src/NotificationLaunchIndicator.js @@ -0,0 +1,50 @@ +/***************************************************************************** + * 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,window*/ + +define( + [], + function () { + "use strict"; + + function NotificationLaunchIndicator() { + + } + + NotificationLaunchIndicator.template = 'notificationLaunchTemplate'; + + NotificationLaunchIndicator.prototype.getGlyph = function () { + return "i"; + }; + NotificationLaunchIndicator.prototype.getGlyphClass = function () { + return 'caution'; + }; + NotificationLaunchIndicator.prototype.getText = function () { + return "Launch notification"; + }; + NotificationLaunchIndicator.prototype.getDescription = function () { + return "Launch notification"; + }; + + return NotificationLaunchIndicator; + } +); diff --git a/package.json b/package.json index a1ac01f437..c96642129c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "split": "^1.0.0", "mkdirp": "^0.5.1", "nomnoml": "^0.0.3", - "canvas": "^1.2.7" + "canvas": "^1.2.7", + "markdown-toc": "^0.11.7" }, "scripts": { "start": "node app.js", diff --git a/platform/commonUI/browse/bundle.json b/platform/commonUI/browse/bundle.json index e3284d99b5..9e7772c66d 100644 --- a/platform/commonUI/browse/bundle.json +++ b/platform/commonUI/browse/bundle.json @@ -1,4 +1,9 @@ { + "configuration": { + "paths": { + "uuid": "uuid" + } + }, "extensions": { "routes": [ { @@ -26,10 +31,10 @@ ] }, { - "key": "BrowseTreeController", - "implementation": "BrowseTreeController.js", + "key": "PaneController", + "implementation": "PaneController.js", "priority": "preferred", - "depends": [ "$scope", "agentService" ] + "depends": [ "$scope", "agentService","$window" ] }, { "key": "BrowseObjectController", diff --git a/platform/commonUI/browse/res/templates/browse-object.html b/platform/commonUI/browse/res/templates/browse-object.html index 3bd6138da6..9dc1c8f83a 100644 --- a/platform/commonUI/browse/res/templates/browse-object.html +++ b/platform/commonUI/browse/res/templates/browse-object.html @@ -41,7 +41,6 @@ - diff --git a/platform/commonUI/browse/res/templates/browse.html b/platform/commonUI/browse/res/templates/browse.html index 9a1be7e771..cd6d01036d 100644 --- a/platform/commonUI/browse/res/templates/browse.html +++ b/platform/commonUI/browse/res/templates/browse.html @@ -22,22 +22,27 @@
-
- +
+
-
+ + - + -
-
- - +
+ + +
+ + +
+ + +
+ + + +
+ + + +
+
-
m
diff --git a/platform/commonUI/browse/res/templates/browse/object-header.html b/platform/commonUI/browse/res/templates/browse/object-header.html index 7ea43a6b9f..79fca1b76c 100644 --- a/platform/commonUI/browse/res/templates/browse/object-header.html +++ b/platform/commonUI/browse/res/templates/browse/object-header.html @@ -19,7 +19,7 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> -
+
{{type.getGlyph()}} diff --git a/platform/commonUI/browse/res/templates/create/create-button.html b/platform/commonUI/browse/res/templates/create/create-button.html index a7b4ad96e5..663f8e3172 100644 --- a/platform/commonUI/browse/res/templates/create/create-button.html +++ b/platform/commonUI/browse/res/templates/create/create-button.html @@ -19,13 +19,12 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> - \ No newline at end of file + \ No newline at end of file diff --git a/platform/commonUI/browse/res/templates/items/grid-item.html b/platform/commonUI/browse/res/templates/items/grid-item.html index 647d8e26da..426f354774 100644 --- a/platform/commonUI/browse/res/templates/items/grid-item.html +++ b/platform/commonUI/browse/res/templates/items/grid-item.html @@ -26,14 +26,8 @@
O
-
-
- {{type.getGlyph()}} - -
+
+ {{type.getGlyph()}}
}
diff --git a/platform/commonUI/browse/src/BrowseController.js b/platform/commonUI/browse/src/BrowseController.js index e0c00d9521..8c032f7de3 100644 --- a/platform/commonUI/browse/src/BrowseController.js +++ b/platform/commonUI/browse/src/BrowseController.js @@ -153,7 +153,6 @@ define( $scope.$on("$destroy", function () { navigationService.removeListener(setNavigation); }); - } return BrowseController; diff --git a/platform/commonUI/browse/src/BrowseTreeController.js b/platform/commonUI/browse/src/PaneController.js similarity index 83% rename from platform/commonUI/browse/src/BrowseTreeController.js rename to platform/commonUI/browse/src/PaneController.js index 31875de4fa..6a59baa0e0 100644 --- a/platform/commonUI/browse/src/BrowseTreeController.js +++ b/platform/commonUI/browse/src/PaneController.js @@ -33,10 +33,12 @@ define( * @constructor * @memberof platform/commonUI/browse */ - function BrowseTreeController($scope, agentService) { + function PaneController($scope, agentService, $window) { var self = this; this.agentService = agentService; - this.state = true; + + // Fast and cheap: if this has been opened in a new window, hide panes by default + this.state = !$window.opener; /** * Callback to invoke when any selection occurs in the tree. @@ -44,7 +46,7 @@ define( * to the tree representation. * * @property {Function} callback - * @memberof platform/commonUI/browse.BrowseTreeController# + * @memberof platform/commonUI/browse.PaneController# */ this.callback = function () { // Note that, since this is a callback to pass, this is not @@ -59,20 +61,20 @@ define( } /** - * Toggle the visibility of the tree. + * Toggle the visibility of the pane. */ - BrowseTreeController.prototype.toggle = function () { + PaneController.prototype.toggle = function () { this.state = !this.state; }; /** - * Get the desired visibility state of the tree. + * Get the desired visibility state of the pane. * @returns {boolean} true when visible */ - BrowseTreeController.prototype.visible = function () { + PaneController.prototype.visible = function () { return this.state; }; - return BrowseTreeController; + return PaneController; } ); diff --git a/platform/commonUI/browse/src/creation/CreationService.js b/platform/commonUI/browse/src/creation/CreationService.js index 667863ef20..2b059724b3 100644 --- a/platform/commonUI/browse/src/creation/CreationService.js +++ b/platform/commonUI/browse/src/creation/CreationService.js @@ -25,7 +25,7 @@ * Module defining CreateService. Created by vwoeltje on 11/10/14. */ define( - ["../../lib/uuid"], + ["uuid"], function (uuid) { "use strict"; diff --git a/platform/commonUI/browse/test/BrowseTreeControllerSpec.js b/platform/commonUI/browse/test/PaneControllerSpec.js similarity index 91% rename from platform/commonUI/browse/test/BrowseTreeControllerSpec.js rename to platform/commonUI/browse/test/PaneControllerSpec.js index 077855febf..f02da713a4 100644 --- a/platform/commonUI/browse/test/BrowseTreeControllerSpec.js +++ b/platform/commonUI/browse/test/PaneControllerSpec.js @@ -22,22 +22,24 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../src/BrowseTreeController"], - function (BrowseTreeController) { + ["../src/PaneController"], + function (PaneController) { 'use strict'; - describe("The BrowseTreeController", function () { + describe("The PaneController", function () { var mockScope, mockAgentService, mockDomainObjects, + mockWindow, controller; // We want to reinstantiate for each test case // because device state can influence constructor-time behavior function instantiateController() { - return new BrowseTreeController( + return new PaneController( mockScope, - mockAgentService + mockAgentService, + mockWindow ); } @@ -58,6 +60,7 @@ define( "agentService", [ "isMobile", "isPhone", "isTablet", "isPortrait", "isLandscape" ] ); + mockWindow = jasmine.createSpyObj("$window", ["open"]); }); it("is initially visible", function () { diff --git a/platform/commonUI/browse/test/suite.json b/platform/commonUI/browse/test/suite.json index aa73dd358d..b9292b6ef1 100644 --- a/platform/commonUI/browse/test/suite.json +++ b/platform/commonUI/browse/test/suite.json @@ -1,7 +1,7 @@ [ "BrowseController", "BrowseObjectController", - "BrowseTreeController", + "PaneController", "MenuArrowController", "creation/CreateAction", "creation/CreateActionProvider", diff --git a/platform/commonUI/dialog/bundle.json b/platform/commonUI/dialog/bundle.json index 9a2d541419..80cd456c20 100644 --- a/platform/commonUI/dialog/bundle.json +++ b/platform/commonUI/dialog/bundle.json @@ -24,6 +24,18 @@ { "key": "form-dialog", "templateUrl": "templates/dialog.html" + }, + { + "key": "overlay-blocking-message", + "templateUrl": "templates/overlay-blocking-message.html" + }, + { + "key": "message", + "templateUrl": "templates/message.html" + }, + { + "key": "overlay-message-list", + "templateUrl": "templates/overlay-message-list.html" } ], "containers": [ diff --git a/platform/commonUI/dialog/res/templates/dialog.html b/platform/commonUI/dialog/res/templates/dialog.html index e3379b034e..2fe2411875 100644 --- a/platform/commonUI/dialog/res/templates/dialog.html +++ b/platform/commonUI/dialog/res/templates/dialog.html @@ -21,17 +21,13 @@ -->
{{ngModel.title}}
-
- All fields marked * are required. -
+
All fields marked * are required.
-
-
- - -
+
+ +
\ No newline at end of file diff --git a/platform/commonUI/dialog/res/templates/overlay-blocking-message.html b/platform/commonUI/dialog/res/templates/overlay-blocking-message.html new file mode 100644 index 0000000000..17fdcf152c --- /dev/null +++ b/platform/commonUI/dialog/res/templates/overlay-blocking-message.html @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/platform/commonUI/dialog/res/templates/overlay-message-list.html b/platform/commonUI/dialog/res/templates/overlay-message-list.html new file mode 100644 index 0000000000..026ef1cbb1 --- /dev/null +++ b/platform/commonUI/dialog/res/templates/overlay-message-list.html @@ -0,0 +1,19 @@ + +
+
+
{{ngModel.dialog.title}}
+
Displaying {{ngModel.dialog.messages.length}} messages +
+
+
+ +
+ +
+
\ No newline at end of file diff --git a/platform/commonUI/dialog/res/templates/overlay-options.html b/platform/commonUI/dialog/res/templates/overlay-options.html index 2f572e20a5..7de75df31c 100644 --- a/platform/commonUI/dialog/res/templates/overlay-options.html +++ b/platform/commonUI/dialog/res/templates/overlay-options.html @@ -24,13 +24,11 @@
{{ngModel.dialog.title}}
{{ngModel.dialog.hint}}
-
-
- - -
+
+ +
- - x - -
- -
+ class="clk-icon icon ui-symbol close">x +
\ No newline at end of file diff --git a/platform/commonUI/dialog/src/DialogService.js b/platform/commonUI/dialog/src/DialogService.js index 25e8943c06..27ffa9ae8b 100644 --- a/platform/commonUI/dialog/src/DialogService.js +++ b/platform/commonUI/dialog/src/DialogService.js @@ -55,7 +55,7 @@ define( this.dialogVisible = false; }; - DialogService.prototype.getDialogResponse = function (key, model, resultGetter) { + DialogService.prototype.getDialogResponse = function (key, model, resultGetter, typeClass) { // We will return this result as a promise, because user // input is asynchronous. var deferred = this.$q.defer(), @@ -84,27 +84,20 @@ define( model.confirm = confirm; model.cancel = cancel; - if (this.dialogVisible) { - // Only one dialog should be shown at a time. - // The application design should be such that - // we never even try to do this. - this.$log.warn([ - "Dialog already showing; ", - "unable to show ", - model.name - ].join("")); - deferred.reject(); - } else { + if (this.canShowDialog(model)) { // Add the overlay using the OverlayService, which // will handle actual insertion into the DOM this.overlay = this.overlayService.createOverlay( key, - model + model, + typeClass || "t-dialog" ); // Track that a dialog is already visible, to // avoid spawning multiple dialogs at once. this.dialogVisible = true; + } else { + deferred.reject(); } return deferred.promise; @@ -157,6 +150,99 @@ define( ); }; + /** + * Tests if a dialog can be displayed. A modal dialog may only be + * displayed if one is not already visible. + * Will log a warning message if it can't display a dialog. + * @returns {boolean} true if dialog is currently visible, false + * otherwise + */ + DialogService.prototype.canShowDialog = function(dialogModel){ + if (this.dialogVisible){ + // Only one dialog should be shown at a time. + // The application design should be such that + // we never even try to do this. + this.$log.warn([ + "Dialog already showing; ", + "unable to show ", + dialogModel.title + ].join("")); + + return false; + } else { + return true; + } + }; + + /** + * A user action that can be performed from a blocking dialog. These + * actions will be rendered as buttons within a blocking dialog. + * + * @typedef DialogOption + * @property {string} label a label to be displayed as the button + * text for this action + * @property {function} action a function to be called when the + * button is clicked + */ + + /** + * A description of the model options that may be passed to the + * showBlockingMessage method. Note that the DialogModel desribed + * here is shared with the Notifications framework. + * @see NotificationService + * + * @typedef DialogModel + * @property {string} title the title to use for the dialog + * @property {string} severity the severity level of this message. + * These are defined in a bundle constant with key 'dialogSeverity' + * @property {string} hint the 'hint' message to show below the title + * @property {string} actionText text that indicates a current action, + * shown above a progress bar to indicate what's happening. + * @property {number} progress a percentage value (1-100) + * indicating the completion of the blocking task + * @property {string} progressText the message to show below a + * progress bar to indicate progress. For example, this might be + * used to indicate time remaining, or items still to process. + * @property {boolean} unknownProgress some tasks may be + * impossible to provide an estimate for. Providing a true value for + * this attribute will indicate to the user that the progress and + * duration cannot be estimated. + * @property {DialogOption} primaryOption an action that will + * be added to the dialog as a button. The primary action can be + * used as the suggested course of action for the user. Making it + * distinct from other actions allows it to be styled differently, + * and treated preferentially in banner mode. + * @property {DialogOption[]} options a list of actions that will + * be added to the dialog as buttons. + */ + + /** + * Displays a blocking (modal) dialog. This dialog can be used for + * displaying messages that require the user's + * immediate attention. The message may include an indication of + * progress, as well as a series of actions that + * the user can take if necessary + * @param {DialogModel} dialogModel defines options for the dialog + * @param {typeClass} string tells overlayService that this overlay should use appropriate CSS class + * @returns {boolean} + */ + DialogService.prototype.showBlockingMessage = function(dialogModel) { + if (this.canShowDialog(dialogModel)) { + // Add the overlay using the OverlayService, which + // will handle actual insertion into the DOM + this.overlay = this.overlayService.createOverlay( + "overlay-blocking-message", + dialogModel, + "t-dialog-sm" + ); + // Track that a dialog is already visible, to + // avoid spawning multiple dialogs at once. + this.dialogVisible = true; + return true; + } else { + return false; + } + }; return DialogService; } diff --git a/platform/commonUI/dialog/src/OverlayService.js b/platform/commonUI/dialog/src/OverlayService.js index 5faba5dcf6..5e9703cb42 100644 --- a/platform/commonUI/dialog/src/OverlayService.js +++ b/platform/commonUI/dialog/src/OverlayService.js @@ -28,7 +28,7 @@ define( // Template to inject into the DOM to show the dialog; really just points to // the a specific template that can be included via mct-include - var TEMPLATE = ''; + var TEMPLATE = ''; /** @@ -71,8 +71,11 @@ define( * @param {object} overlayModel the model to pass to the * included overlay template (this will be passed * in via ng-model) + * @param {string} typeClass the element class to use in rendering + * the overlay. Can be specified to provide custom styling of + * overlays */ - OverlayService.prototype.createOverlay = function (key, overlayModel) { + OverlayService.prototype.createOverlay = function (key, overlayModel, typeClass) { // Create a new scope for this overlay var scope = this.newScope(), element; @@ -90,6 +93,7 @@ define( // Populate the scope; will be passed directly to the template scope.overlay = overlayModel; scope.key = key; + scope.typeClass = typeClass || 't-dialog'; // Create the overlay element and add it to the document's body element = this.$compile(TEMPLATE)(scope); diff --git a/platform/commonUI/dialog/test/DialogServiceSpec.js b/platform/commonUI/dialog/test/DialogServiceSpec.js index 689979f129..2dc109ac44 100644 --- a/platform/commonUI/dialog/test/DialogServiceSpec.js +++ b/platform/commonUI/dialog/test/DialogServiceSpec.js @@ -116,10 +116,22 @@ define( dialog: dialogModel, confirm: jasmine.any(Function), cancel: jasmine.any(Function) - } + }, + 't-dialog' + ); + }); + + it("invokes the overlay service with the correct parameters when" + + " a blocking dialog is requested", function() { + var dialogModel = {}; + expect(dialogService.showBlockingMessage(dialogModel)).toBe(true); + expect(mockOverlayService.createOverlay).toHaveBeenCalledWith( + "overlay-blocking-message", + dialogModel, + "t-dialog-sm" ); }); }); } -); \ No newline at end of file +); diff --git a/platform/commonUI/formats/bundle.json b/platform/commonUI/formats/bundle.json new file mode 100644 index 0000000000..99925657b2 --- /dev/null +++ b/platform/commonUI/formats/bundle.json @@ -0,0 +1,26 @@ +{ + "name": "Time services bundle", + "description": "Defines interfaces and provides default implementations for handling different time systems.", + "extensions": { + "components": [ + { + "provides": "formatService", + "type": "provider", + "implementation": "FormatProvider.js", + "depends": [ "formats[]" ] + } + ], + "formats": [ + { + "key": "utc", + "implementation": "UTCTimeFormat.js" + } + ], + "constants": [ + { + "key": "DEFAULT_TIME_FORMAT", + "value": "utc" + } + ] + } +} diff --git a/platform/commonUI/formats/src/FormatProvider.js b/platform/commonUI/formats/src/FormatProvider.js new file mode 100644 index 0000000000..e6d38fbcee --- /dev/null +++ b/platform/commonUI/formats/src/FormatProvider.js @@ -0,0 +1,114 @@ +/***************************************************************************** + * 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"; + + /** + * An object used to convert between numeric values and text values, + * typically used to display these values to the user and to convert + * user input to a numeric format, particularly for time formats. + * @interface {Format} + */ + + /** + * Parse text (typically user input) to a numeric value. + * Behavior is undefined when the text cannot be parsed; + * `validate` should be called first if the text may be invalid. + * @method parse + * @memberof Format# + * @param {string} text the text to parse + * @returns {number} the parsed numeric value + */ + + /** + * Determine whether or not some text (typically user input) can + * be parsed to a numeric value by this format. + * @method validate + * @memberof Format# + * @param {string} text the text to parse + * @returns {boolean} true if the text can be parsed + */ + + /** + * Convert a numeric value to a text value for display using + * this format. + * @method format + * @memberof Format# + * @param {number} value the numeric value to format + * @returns {string} the text representation of the value + */ + + /** + * Provides access to `Format` objects which can be used to + * convert values between human-readable text and numeric + * representations. + * @interface FormatService + */ + + /** + * Look up a format by its symbolic identifier. + * @method getFormat + * @memberof FormatService# + * @param {string} key the identifier for this format + * @returns {Format} the format + * @throws {Error} errors when the requested format is unrecognized + */ + + /** + * Provides formats from the `formats` extension category. + * @constructor + * @implements {FormatService} + * @memberof platform/commonUI/formats + * @param {Array.} format constructors, + * from the `formats` extension category. + */ + function FormatProvider(formats) { + var formatMap = {}; + + function addToMap(Format) { + var key = Format.key; + if (key && !formatMap[key]) { + formatMap[key] = new Format(); + } + } + + formats.forEach(addToMap); + this.formatMap = formatMap; + } + + FormatProvider.prototype.getFormat = function (key) { + var format = this.formatMap[key]; + if (!format) { + throw new Error("FormatProvider: No format found for " + key); + } + return format; + }; + + return FormatProvider; + +}); diff --git a/platform/commonUI/formats/src/UTCTimeFormat.js b/platform/commonUI/formats/src/UTCTimeFormat.js new file mode 100644 index 0000000000..b035fed99f --- /dev/null +++ b/platform/commonUI/formats/src/UTCTimeFormat.js @@ -0,0 +1,63 @@ +/***************************************************************************** + * 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([ + 'moment' +], function ( + moment +) { + "use strict"; + + var DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", + DATE_FORMATS = [ + DATE_FORMAT, + "YYYY-MM-DD HH:mm", + "YYYY-MM-DD" + ]; + + + /** + * Formatter for UTC timestamps. Interprets numeric values as + * milliseconds since the start of 1970. + * + * @implements {Format} + * @constructor + * @memberof platform/commonUI/formats + */ + function UTCTimeFormat() { + } + + UTCTimeFormat.prototype.format = function (value) { + return moment.utc(value).format(DATE_FORMAT); + }; + + UTCTimeFormat.prototype.parse = function (text) { + return moment.utc(text, DATE_FORMATS).valueOf(); + }; + + UTCTimeFormat.prototype.validate = function (text) { + return moment.utc(text, DATE_FORMATS).isValid(); + }; + + return UTCTimeFormat; +}); diff --git a/platform/commonUI/formats/test/FormatProviderSpec.js b/platform/commonUI/formats/test/FormatProviderSpec.js new file mode 100644 index 0000000000..4f68c106f7 --- /dev/null +++ b/platform/commonUI/formats/test/FormatProviderSpec.js @@ -0,0 +1,68 @@ +/***************************************************************************** + * 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/FormatProvider'], + function (FormatProvider) { + 'use strict'; + + var KEYS = [ 'a', 'b', 'c' ]; + + describe("The FormatProvider", function () { + var mockFormats, + mockLog, + mockFormatInstances, + provider; + + beforeEach(function () { + mockFormatInstances = KEYS.map(function (k) { + return jasmine.createSpyObj( + 'format-' + k, + [ 'parse', 'validate', 'format' ] + ); + }); + // Return constructors + mockFormats = KEYS.map(function (k, i) { + function MockFormat() { return mockFormatInstances[i]; } + MockFormat.key = k; + return MockFormat; + }); + provider = new FormatProvider(mockFormats); + }); + + it("looks up formats by key", function () { + KEYS.forEach(function (k, i) { + expect(provider.getFormat(k)) + .toEqual(mockFormatInstances[i]); + }); + }); + + it("throws an error about unknown formats", function () { + expect(function () { + provider.getFormat('some-unknown-format'); + }).toThrow(); + }); + + }); + } +); diff --git a/platform/commonUI/formats/test/UTCTimeFormatSpec.js b/platform/commonUI/formats/test/UTCTimeFormatSpec.js new file mode 100644 index 0000000000..d55a8a9507 --- /dev/null +++ b/platform/commonUI/formats/test/UTCTimeFormatSpec.js @@ -0,0 +1,56 @@ +/***************************************************************************** + * 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/UTCTimeFormat', 'moment'], + function (UTCTimeFormat, moment) { + 'use strict'; + + describe("The UTCTimeFormat", function () { + var format; + + beforeEach(function () { + format = new UTCTimeFormat(); + }); + + it("formats UTC timestamps", function () { + var timestamp = 12345670000, + formatted = format.format(timestamp); + expect(formatted).toEqual(jasmine.any(String)); + expect(moment.utc(formatted).valueOf()).toEqual(timestamp); + }); + + it("validates time inputs", function () { + expect(format.validate("1977-05-25 11:21:22")).toBe(true); + expect(format.validate("garbage text")).toBe(false); + }); + + it("parses valid input", function () { + var text = "1977-05-25 11:21:22", + parsed = format.parse(text); + expect(parsed).toEqual(jasmine.any(Number)); + expect(parsed).toEqual(moment.utc(text).valueOf()); + }); + }); + } +); diff --git a/platform/commonUI/formats/test/suite.json b/platform/commonUI/formats/test/suite.json new file mode 100644 index 0000000000..06c88fac8b --- /dev/null +++ b/platform/commonUI/formats/test/suite.json @@ -0,0 +1,4 @@ +[ + "FormatProvider", + "UTCTimeFormat" +] diff --git a/platform/commonUI/general/bundle.json b/platform/commonUI/general/bundle.json index 1aa0b1dfc1..edaa8ec103 100644 --- a/platform/commonUI/general/bundle.json +++ b/platform/commonUI/general/bundle.json @@ -44,6 +44,14 @@ "key": "indicator", "templateUrl": "templates/indicator.html" }, + { + "key": "message-banner", + "templateUrl": "templates/message-banner.html" + }, + { + "key": "progress-bar", + "templateUrl": "templates/progress-bar.html" + }, { "key": "time-controller", "templateUrl": "templates/controls/time-controller.html" @@ -53,13 +61,18 @@ { "key": "TimeRangeController", "implementation": "controllers/TimeRangeController.js", - "depends": [ "$scope", "now" ] + "depends": [ "$scope", "formatService", "DEFAULT_TIME_FORMAT", "now" ] }, { "key": "DateTimePickerController", "implementation": "controllers/DateTimePickerController.js", "depends": [ "$scope", "now" ] }, + { + "key": "DateTimeFieldController", + "implementation": "controllers/DateTimeFieldController.js", + "depends": [ "$scope", "formatService", "DEFAULT_TIME_FORMAT" ] + }, { "key": "TreeNodeController", "implementation": "controllers/TreeNodeController.js", @@ -107,6 +120,16 @@ "key": "SelectorController", "implementation": "controllers/SelectorController.js", "depends": [ "objectService", "$scope" ] + }, + { + "key": "ObjectInspectorController", + "implementation": "controllers/ObjectInspectorController.js", + "depends": [ "$scope", "objectService" ] + }, + { + "key": "BannerController", + "implementation": "controllers/BannerController.js", + "depends": ["$scope", "notificationService", "dialogService"] } ], "directives": [ @@ -232,6 +255,10 @@ "key": "switcher", "templateUrl": "templates/controls/switcher.html", "uses": [ "view" ] + }, + { + "key": "object-inspector", + "templateUrl": "templates/object-inspector.html" } ], "controls": [ @@ -242,6 +269,10 @@ { "key": "datetime-picker", "templateUrl": "templates/controls/datetime-picker.html" + }, + { + "key": "datetime-field", + "templateUrl": "templates/controls/datetime-field.html" } ], "licenses": [ diff --git a/platform/commonUI/general/res/sass/_constants.scss b/platform/commonUI/general/res/sass/_constants.scss index 918ace8c0f..a3305cf0a4 100644 --- a/platform/commonUI/general/res/sass/_constants.scss +++ b/platform/commonUI/general/res/sass/_constants.scss @@ -42,13 +42,17 @@ $ueFooterH: 25px; $ueColMargin: 1.5%; $ueAppLogoW: 105px; $ueEditToolBarH: 25px; -$ueBrowseLeftPaneW: 25%; +$ueBrowseLeftPaneTreeW: 25%; +$ueBrowseRightPaneInspectW: 20%; +$ueCollapsedPaneEdgeM: 20px; +$uePaneMiniTabH: $ueTopBarH; +$uePaneMiniTabW: 9px; $ueEditLeftPaneW: 75%; $treeSearchInputBarH: 25px; $ueTimeControlH: (33px, 20px, 20px); // Overlay -$ovrTopBarH: 60px; -$ovrFooterH: 30px; +$ovrTopBarH: 45px; +$ovrFooterH: 24px; $overlayMargin: 25px; // Items $ueBrowseGridItemLg: 200px; @@ -57,7 +61,8 @@ $ueBrowseGridItemBottomBarH: 30px; $itemPadLR: 5px; // Tree $treeVCW: 10px; -$treeTypeIconH: 16px; +$treeTypeIconH: 1.4em; // was 16px +$treeTypeIconHPx: 16px; $treeTypeIconW: 20px; $treeContextTriggerW: 20px; // Tabular @@ -106,3 +111,8 @@ $dirImgs: $dirCommonRes + 'images/'; /************************** TIMINGS */ $controlFadeMs: 100ms; + +/************************** LIMITS */ +$glyphLimit: '\e603'; +$glyphLimitUpr: '\0000eb'; +$glyphLimitLwr: '\0000ee'; diff --git a/platform/commonUI/general/res/sass/_global.scss b/platform/commonUI/general/res/sass/_global.scss index 45b868922b..5b2011f5bb 100644 --- a/platform/commonUI/general/res/sass/_global.scss +++ b/platform/commonUI/general/res/sass/_global.scss @@ -34,10 +34,6 @@ font-style: normal; } -.ui-symbol { - font-family: 'symbolsfont'; -} - /************************** HTML ENTITIES */ a { color: $colorA; @@ -55,7 +51,7 @@ body, html { color: $colorBodyFg; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 100%; - //font-weight: 500; + font-weight: 200; height: 100%; width: 100%; overflow: hidden; diff --git a/platform/commonUI/general/res/sass/_icons.scss b/platform/commonUI/general/res/sass/_icons.scss index 1208b3e7cf..3b41b31011 100644 --- a/platform/commonUI/general/res/sass/_icons.scss +++ b/platform/commonUI/general/res/sass/_icons.scss @@ -29,11 +29,14 @@ } .ui-symbol { + font-family: 'symbolsfont'; &.type-icon { color: $colorObjHdrIc; } &.icon { color: $colorKey; + //position: relative; + font-size: inherit; &.alert { color: $colorAlert; &:hover { @@ -69,18 +72,32 @@ position: absolute; } -//.tree-item .type-icon { -// font-size: 16px; // 16px is crisp size -//} - -.l-icon-link:before { - content: "\f4"; -} - .l-icon-alert { display: none !important; // Remove this when alerts are enabled &:before { color: $colorAlert; content: "!"; } +} + +// NEW!! +.t-item-icon { + // Used in grid-item.html, tree-item, inspector location, more? + @extend .ui-symbol; + @extend .icon; + display: inline-block; + line-height: normal; // This is Ok for the symbolsfont + position: relative; + &.l-icon-link { + &:before { + color: $colorIconLink; + content: "\f4"; + height: auto; width: auto; + position: absolute; + left: 0; top: 0; right: 0; bottom: 10%; + @include transform-origin(bottom, left); + @include transform(scale(0.3)); + z-index: 2; + } + } } \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/_inspector.scss b/platform/commonUI/general/res/sass/_inspector.scss new file mode 100644 index 0000000000..9f67ff3b73 --- /dev/null +++ b/platform/commonUI/general/res/sass/_inspector.scss @@ -0,0 +1,107 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ +/* Styles for the Inspector pane */ + +.l-inspect, +.l-inspect table tr td { + font-size: 0.7rem; +} + +.l-inspect { + @extend .abs; + background: $colorInspectorBg; + color: $colorInspectorFg; + line-height: 140%; + .pane-header { + color: pushBack($colorInspectorFg, 20%); + font-size: 0.8rem; + &:before { + color: pushBack($colorInspectorFg, 10%); + content:'\e615'; // e615 Crosshair symbol + display: inline; + font-family: symbolsfont; + margin-right: $interiorMargin; + vertical-align: bottom; + } + } + + ul li, + em { + display: block; + position: relative; + } + + ul li { + margin-bottom: $interiorMarginLg; + } + + em { + @include border-radius($basicCr); + background-color: $colorInspectorSectionHeaderBg; + color: $colorInspectorSectionHeaderFg; + margin-bottom: $interiorMargin; + padding: $formTBPad $formLRPad; + text-transform: uppercase; + } + + .inspector-properties { + &:not(.first) { + border-top: 1px solid $colorInspectorSectionHeaderBg; + } + padding: $interiorMarginSm 0; + .label { + color: $colorInspectorPropName; + text-transform: uppercase; + } + .value { + color: $colorInspectorPropVal; + word-break: break-all; + } + } + + .inspector-location { + //line-height: 180%; + .location-item { + cursor: pointer; + display: inline-block; + position: relative; + padding: 2px 4px; + &:hover { + background: $colorItemTreeHoverBg; + color: $colorItemTreeHoverFg; + .icon { + color: $colorItemTreeIconHover; + } + } + } + &:not(.last) .t-object-label .t-title-label:after { + color: pushBack($colorInspectorFg, 15%); + content: '\3e'; + display: inline-block; + font-family: symbolsfont; + font-size: 8px; + line-height: inherit; + margin-left: $interiorMarginSm; + width: 4px; + } + } +} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/_limits.scss b/platform/commonUI/general/res/sass/_limits.scss index 12411554cd..51f9a1c863 100644 --- a/platform/commonUI/general/res/sass/_limits.scss +++ b/platform/commonUI/general/res/sass/_limits.scss @@ -1,26 +1,39 @@ -@mixin limit($bg, $ic, $glyph) { - background: $bg !important; - //color: $fg !important; - &:before { - //@include pulse(1000ms); - color: $ic; - content: $glyph; - } +@mixin limitGlyph($iconColor, $glyph: $glyphLimit) { + &:before { + color: $iconColor; + content: $glyph; + font-family: symbolsfont; + font-size: 0.8em; + display: inline; + margin-right: $interiorMarginSm; + } + } -[class*="s-limit"] { - //white-space: nowrap; - &:before { - display: inline-block; - font-family: symbolsfont; - font-size: 0.75em; - font-style: normal !important; - margin-right: $interiorMarginSm; - vertical-align: middle; - } +.s-limit-red { background: $colorLimitRedBg !important; } +.s-limit-yellow { background: $colorLimitYellowBg !important; } + +// Handle limit when applied to a tr +tr[class*="s-limit"] { + &.s-limit-red td:first-child { + @include limitGlyph($colorLimitRedIc); + } + &.s-limit-yellow td:first-child { + @include limitGlyph($colorLimitYellowIc); + } + &.s-limit-upr td:first-child:before { content:$glyphLimitUpr; } + &.s-limit-lwr td:first-child:before { content:$glyphLimitLwr; } } -.s-limit-upr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000eb"); }; -.s-limit-upr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ed"); }; -.s-limit-lwr-yellow { @include limit($colorLimitYellowBg, $colorLimitYellowIc, "\0000ec"); }; -.s-limit-lwr-red { @include limit($colorLimitRedBg, $colorLimitRedIc, "\0000ee"); }; \ No newline at end of file +// Handle limit when applied directly to a non-tr element +// Assume this is applied to the element that displays the limit value +:not(tr)[class*="s-limit"] { + &.s-limit-red { + @include limitGlyph($colorLimitRedIc); + } + &.s-limit-yellow { + @include limitGlyph($colorLimitYellowIc); + } + &.s-limit-upr:before { content:$glyphLimitUpr; } + &.s-limit-lwr:before { content:$glyphLimitLwr; } +} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/_main.scss b/platform/commonUI/general/res/sass/_main.scss index 80023f55ac..0dab445e84 100644 --- a/platform/commonUI/general/res/sass/_main.scss +++ b/platform/commonUI/general/res/sass/_main.scss @@ -29,8 +29,7 @@ @import "helpers/bubbles"; @import "helpers/splitter"; @import "helpers/wait-spinner"; -@import "messages"; -@import "properties"; +@import "inspector"; /********************************* CONTROLS */ @import "controls/breadcrumb"; @@ -39,6 +38,7 @@ @import "controls/controls"; @import "controls/lists"; @import "controls/menus"; +@import "controls/messages"; @import "controls/time-controller"; @import "mobile/controls/menus"; @@ -62,7 +62,6 @@ @import "mobile/tree"; @import "user-environ/frame"; @import "user-environ/top-bar"; -@import "user-environ/bottom-bar"; @import "user-environ/tool-bar"; /********************************* VIEWS */ @@ -70,7 +69,6 @@ @import "lists/tabular"; @import "plots/plots-main"; @import "iframe"; -@import "hide-non-functional"; @import "views"; @import "items/item"; @import "mobile/item"; diff --git a/platform/commonUI/general/res/sass/_messages.scss b/platform/commonUI/general/res/sass/_messages.scss deleted file mode 100644 index db4de4c946..0000000000 --- a/platform/commonUI/general/res/sass/_messages.scss +++ /dev/null @@ -1,12 +0,0 @@ -/* Styles for messages */ - -.message { - &.block { - @include border-radius($basicCr); - padding: $interiorMarginLg; - } - &.error { - background-color: rgba($colorAlert,0.3); - color: lighten($colorAlert, 20%); - } -} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/_mixins.scss b/platform/commonUI/general/res/sass/_mixins.scss index 070bf29e73..f5a21cf9dd 100644 --- a/platform/commonUI/general/res/sass/_mixins.scss +++ b/platform/commonUI/general/res/sass/_mixins.scss @@ -41,36 +41,41 @@ width: $d; } -@mixin trans-prop-nice($props, $t: 500ms) { - @if $t == 0 { +@mixin trans-prop-nice($props, $dur: 500ms, $delay: 0) { + // Multiple $props must be in parans like this: (left, right) + @if $dur == 0 { @include transition-property(none); } @else { @include transition-property($props); - @include transition-duration($t); + @include transition-duration($dur); @include transition-timing-function(ease-in-out); + @include transition-delay($delay); } } -@mixin trans-prop-nice-fade($t: 0.5s) { - @if $t == 0 { +@mixin trans-prop-nice-fade($dur: 500ms, $delay: 0) { + @if $dur == 0 { @include transition-property(none); } @else { - @include transition-property(visibility, opacity, background-color, border-color); - @include transition-duration($t); + @include transition-property(opacity, background-color, border-color, color); + @include transition-duration($dur); @include transition-timing-function(ease-in-out); + @include transition-delay($delay); } } -@mixin trans-prop-nice-resize-h($t: 0.5s) { +@mixin trans-prop-nice-resize-h($dur: 500ms, $delay: 0) { @include transition-property(height, bottom, top); - @include transition-duration($t); + @include transition-duration($dur); @include transition-timing-function(ease-in-out); + @include transition-delay($delay); } -@mixin trans-prop-nice-resize-w($t: 0.5s) { +@mixin trans-prop-nice-resize-w($dur: 500ms, $delay: 0) { @include transition-property(width, left, right); - @include transition-duration($t); + @include transition-duration($dur); @include transition-timing-function(ease-in-out); + @include transition-delay($delay); } @mixin triangle-right($size, $color) { @@ -94,7 +99,6 @@ } @mixin triangle($dir: "left", $size: 5px, $ratio: 1, $color: red) { - //$size: $size*2; width: 0; height: 0; $slopedB: $size/$ratio solid transparent; @@ -129,6 +133,24 @@ background-size: $d $d; } +@mixin bgVertStripes($c: yellow, $a: 0.1, $d: 40px) { + @include background-image(linear-gradient(-90deg, + rgba($c, $a) 0%, rgba($c, $a) 50%, + transparent 50%, transparent 100% + )); + background-repeat: repeat; + background-size: $d $d; +} + +@mixin bgVertFuzzyStripes($c: yellow, $a: 0.1, $d: 40px) { + @include background-image(linear-gradient(-90deg, + rgba($c, $a) 0%, transparent 50%, + transparent 50%, rgba($c, $a) 100% + )); + background-repeat: repeat; + background-size: $d $d; +} + @mixin bgTicks($c: $colorBodyFg, $repeatDir: 'x') { $deg: 90deg; @if ($repeatDir != 'x') { @@ -156,31 +178,28 @@ } @mixin controlGrippy($b, $direction: horizontal, $w: 1px, $style: dotted) { - &:before { - @include trans-prop-nice("border-color", 0.75s); - content: ''; - display: block; - height: auto; - pointer-events: none; - position: absolute; - z-index: 2; + //&:before { + //@include trans-prop-nice("border-color", 25ms); + content: ''; + display: block; + //height: auto; + pointer-events: none; + position: absolute; + z-index: 2; - @if $direction == "horizontal" { - border-top: $w $style darken($b, 15%); - top: 2px; - left: 5px; - right: 5px; + @if $direction == "horizontal" { + border-top: $w $style darken($b, 15%); + top: 2px; + left: 5px; + right: 5px; + height: 1px; - } @else if $direction == "vertical" { - border-left: $w $style darken($b, 15%); - left: 2px; - bottom: 5px; - top: 5px; - } - } - &:not(.disabled):hover:before { - @include trans-prop-nice("border-color", 25ms); - border-color: $colorGrippyInteriorHover; + } @else if $direction == "vertical" { + border-left: $w $style darken($b, 15%); + left: 2px; + bottom: 5px; + top: 5px; + width: 1px; } } @@ -256,6 +275,12 @@ @return percentage($d); } +@function splitterHandleInset($splitterD: 21px, $splitterHandleD: 1px) { + // Space to either side of the handle + @return ($splitterD - $splitterHandleD) * 0.5; +} + + /*********************************************** CONTROLS, FORM ELEMENTS */ @mixin containerBase($bg: $colorBodyBg, $fg: $colorBodyFg) { @@ -347,10 +372,7 @@ /* This doesn't work on an element inside an element with absolute positioning that has height: auto */ //position: relative; top: 50%; - @include webkitProp(transform, translateY(-50%)); - //-webkit-transform: translateY(-50%); - //-ms-transform: translateY(-50%); - //transform: translateY(-50%); + @include transform(translateY(-50%)); } @mixin verticalCenterBlock($holderH, $itemH) { @@ -375,20 +397,6 @@ overflow-y: $showBar; } -@mixin wait-spinner($b: 5px, $c: $colorAlt1) { - display: block; - position: absolute; - -webkit-animation: rotation .6s infinite linear; - -moz-animation: rotation .6s infinite linear; - -o-animation: rotation .6s infinite linear; - animation: rotation .6s infinite linear; - border-color: rgba($c, 0.25); - border-top-color: rgba($c, 1.0); - border-style: solid; - border-width: $b; - border-radius: 100%; -} - @mixin test($c: #ffcc00, $a: 0.2) { background-color: rgba($c, $a) !important; } diff --git a/platform/commonUI/general/res/sass/controls/_buttons.scss b/platform/commonUI/general/res/sass/controls/_buttons.scss index abf191cece..956e144899 100644 --- a/platform/commonUI/general/res/sass/controls/_buttons.scss +++ b/platform/commonUI/general/res/sass/controls/_buttons.scss @@ -22,13 +22,17 @@ $baseRatio: 1.5; $pad: $interiorMargin * $baseRatio; -.s-btn { - @include box-sizing(border-box); +.s-btn, +.s-icon-btn { @include user-select(none); cursor: pointer; text-decoration: none; height: $btnStdH; line-height: $btnStdH; +} + +.s-btn { + @include box-sizing(border-box); padding: 0 $pad; font-size: 0.7rem; @@ -89,6 +93,155 @@ $pad: $interiorMargin * $baseRatio; } } +.s-icon-btn { + @extend .ui-symbol; + color: $colorBtnIcon; + &:hover { + color: lighten($colorBtnIcon, $ltGamma); + } +} + +.mini-tab { + // Meant to be used as pane hide/show control elements in concert with mct-splitter + //@extend .ui-symbol; + @include desktop { + //@include test(green); + $iconH: $uePaneMiniTabH; + $iconW: $uePaneMiniTabW; + $iconInnerLR: 0; + $arwD: 9px; + $arwOffsetX: 0px; + $arwAnimOffsetX: 2px + $iconInnerLR; + $cBg: pullForward($colorBodyBg, 15%); + $cFg: $cBg; + + + @include border-radius($basicCr); + //@include boxShdw($shdwBtns); + @include box-sizing(border-box); + @include trans-prop-nice((color, background-color), 100ms); + color: $cFg; + cursor: pointer; + font-family: symbolsfont; + font-size: $arwD; + display: block; + position: absolute; + line-height: $iconH; + height: $iconH; width: $iconW; + text-align: center; + + &:hover { + //background-color: $cBg; + color: $colorKey; //pullForward($cFg, $ltGamma); + } + + &.collapsed { + // State when the pane this element controls has been collapsed + @include btnSubtle($colorBtnBg, $colorKey, $colorBtnFg, $colorBtnIcon); + &:before { opacity: 0; } + &:after { opacity: 1; } + &:hover { + &:before { opacity: 1; } + &:after { opacity: 0; } + } + + } + + &:before, + &:after { + //@include test(); + @include trans-prop-nice((left, right, opacity), 250ms); + display: block; + height: 100%; + position: absolute; + } + + &:before { + // Always the arrow icon + //@include test(green); + //font-size: $arwD; + width: $arwD; + } + &:after { + // Always icon; content is set in _layout.scss + width: 100%; + text-align: center; + opacity: 0; + } + + &.anchor-left { + // |< + text-align: right; + &:before { + content:'\3c'; // Collapse left icon e613 + right: $iconInnerLR; + } + //&:hover:before { right: $arwAnimOffsetX; } + &.collapsed { + @include border-left-radius(0); + text-align: left; + &:before { + content:'\3e'; + left: $iconInnerLR; + } + &:hover:before { left: $arwAnimOffsetX; } + } + } + &.anchor-right { + // >| + text-align: left; + &:before { + content:'\3e'; // Collapse right icon e614 + left: $iconInnerLR; + } + //&:hover:before { left: $arwAnimOffsetX; } + &.collapsed { + @include border-right-radius(0); + &:before { + text-align: right; + content:'\3c'; + right: $iconInnerLR; + } + &:hover:before { right: $arwAnimOffsetX; } + } + } + } +} + +.mini-tab-icon { + // Meant to be used as pane hide/show control elements in concert with mct-splitter + //@extend .ui-symbol; + @include desktop { + $d: $uePaneMiniTabW; + //@include trans-prop-nice(transform, 150ms); + color: pullForward($colorBodyBg, 15%); + cursor: pointer; + display: block; + font-family: symbolsfont; + font-size: $d; + position: absolute; + height: $d; width: $d; + line-height: $d; + overflow: hidden; + word-break: break-all; + + &:before, + &:after { + position: absolute; + display: inherit; + } + + &:before { + content: '\78'; // X icon + } + + &:hover { + color: $colorKey; + //@include transform(scale(1.2)); + } + } +} + .l-btn-set { // Buttons that have a very tight conceptual grouping - no internal space between them. // Structure: .btn-set > mct-representation class=first|last > .s-btn diff --git a/platform/commonUI/general/res/sass/controls/_controls.scss b/platform/commonUI/general/res/sass/controls/_controls.scss index eef87c3439..a8c1f68aa3 100644 --- a/platform/commonUI/general/res/sass/controls/_controls.scss +++ b/platform/commonUI/general/res/sass/controls/_controls.scss @@ -19,462 +19,507 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*.control { - // UNUSED? - &.view-control { - .icon { - display: inline-block; - margin: -1px 5px 1px 2px; - vertical-align: middle; - &.triangle-down { - margin: 2px 2px -2px 0px; - } - } - - .label { - display: inline-block; - font-size: 11px; - vertical-align: middle; - } - - .toggle { - @include border-radius(3px); - display: inline-block; - padding: 1px 6px 4px 4px; - &:hover { - background: rgba(white, 0.1); - } - } - } -}*/ .accordion { - $accordionHeadH: 18px; - margin-top: $interiorMargin; - &:first-child { - margin-top: 0; - } - .accordion-head { - $op: 0.2; - @include border-radius($basicCr * 0.75); - @include box-sizing("border-box"); - background: rgba($colorBodyFg, $op); - cursor: pointer; - font-size: 0.75em; - line-height: $accordionHeadH; - margin-bottom: $interiorMargin; - padding: 0 $interiorMargin; - position: absolute; - top: 0; - right: 0; - bottom: auto; - left: 0; - width: auto; - height: $accordionHeadH; - text-transform: uppercase; - &:hover { - background: rgba($colorBodyFg, $op * 2); - } - &:after { - content: "^"; - display: block; - font-family: 'symbolsfont'; - font-size: 0.9em; - position: absolute; - right: $interiorMargin; - text-transform: none; - top: 0; - } - &:not(.expanded):after { - content: "v"; - } - } - .accordion-contents { - position: absolute; - top: $accordionHeadH + $interiorMargin; - right: 0; - bottom: 0; - left: 0; - overflow-y: auto; - overflow-x: hidden; - } + $accordionHeadH: 18px; + margin-top: $interiorMargin; + &:first-child { + margin-top: 0; + } + .accordion-head { + $op: 0.2; + @include border-radius($basicCr * 0.75); + @include box-sizing("border-box"); + background: rgba($colorBodyFg, $op); + cursor: pointer; + font-size: 0.75em; + line-height: $accordionHeadH; + margin-bottom: $interiorMargin; + padding: 0 $interiorMargin; + position: absolute; + top: 0; + right: 0; + bottom: auto; + left: 0; + width: auto; + height: $accordionHeadH; + text-transform: uppercase; + &:hover { + background: rgba($colorBodyFg, $op * 2); + } + &:after { + content: "^"; + display: block; + font-family: 'symbolsfont'; + font-size: 0.9em; + position: absolute; + right: $interiorMargin; + text-transform: none; + top: 0; + } + &:not(.expanded):after { + content: "v"; + } + } + .accordion-contents { + position: absolute; + top: $accordionHeadH + $interiorMargin; + right: 0; + bottom: 0; + left: 0; + overflow-y: auto; + overflow-x: hidden; + } } .l-composite-control { - vertical-align: middle; - &.l-checkbox { - .composite-control-label { - line-height: 18px; - } - } + vertical-align: middle; + &.l-checkbox { + .composite-control-label { + line-height: 18px; + } + } } .l-control-group { - // Buttons that have a conceptual grouping - internal space between, and a divider between groups. - // @include test(); - @include box-sizing(border-box); - border-left: 1px solid $colorInteriorBorder; - display: inline-block; - padding: 0 $interiorMargin; - position: relative; - &:first-child { - border-left: none; - padding-left: 0; - } + // Buttons that have a conceptual grouping - internal space between, and a divider between groups. + // @include test(); + @include box-sizing(border-box); + border-left: 1px solid $colorInteriorBorder; + display: inline-block; + padding: 0 $interiorMargin; + position: relative; + &:first-child { + border-left: none; + padding-left: 0; + } } .l-local-controls { - // Control shown when hovering over an object, like plots and imagery - // Default position is upper right - $p: $interiorMargin; - position: absolute; - top: $p; - right: $p; - z-index: 5; + // Control shown when hovering over an object, like plots and imagery + // Default position is upper right + $p: $interiorMargin; + position: absolute; + top: $p; + right: $p; + z-index: 5; } .s-local-controls { - font-size: 0.7rem; + font-size: 0.7rem; } label.checkbox.custom { - $bg: pullForward($colorBodyBg, 10%); - $d: $formRowCtrlsH; - cursor: pointer; - display: inline-block; - line-height: $d; - margin-right: $interiorMargin * 4; - padding-left: $d + $interiorMargin; - position: relative; - vertical-align: middle; // was top - em { - color: $colorBodyFg; - display: inline-block; - height: $d; - min-width: $d; - &:before { - @include border-radius($basicCr * .75); - background: $bg; - //border-bottom: 1px solid lighten($bg, 10%); - @include box-shadow(inset rgba(black, 0.4) 0 1px 2px); - box-sizing: border-box; - content: " "; - font-family: 'symbolsfont'; - font-size: 0.8em; - display: inline-block; - margin-right: $interiorMargin; - height: $d; - width: $d; - left: 0; - top: 0; - position: absolute; - text-align: center; - } - } - &.no-text { - overflow: hidden; - margin-right: 0; - padding-left: 0; - height: $d; - width: $d; - em { - overflow: hidden; - } - } - input { - display: none; - &:checked ~ em:before { - background: $colorCheck; - color: lighten($colorCheck, 50%); - content: "2"; - } - } + $bg: pullForward($colorBodyBg, 10%); + $d: $formRowCtrlsH; + cursor: pointer; + display: inline-block; + line-height: $d; + margin-right: $interiorMargin * 4; + padding-left: $d + $interiorMargin; + position: relative; + vertical-align: middle; // was top + em { + color: $colorBodyFg; + display: inline-block; + height: $d; + min-width: $d; + &:before { + @include border-radius($basicCr * .75); + background: $bg; + //border-bottom: 1px solid lighten($bg, 10%); + @include box-shadow(inset rgba(black, 0.4) 0 1px 2px); + box-sizing: border-box; + content: " "; + font-family: 'symbolsfont'; + font-size: 0.8em; + display: inline-block; + margin-right: $interiorMargin; + height: $d; + width: $d; + left: 0; + top: 0; + position: absolute; + text-align: center; + } + } + &.no-text { + overflow: hidden; + margin-right: 0; + padding-left: 0; + height: $d; + width: $d; + em { + overflow: hidden; + } + } + input { + display: none; + &:checked ~ em:before { + background: $colorCheck; + color: lighten($colorCheck, 50%); + content: "2"; + } + } } .input-labeled { - margin-left: $interiorMargin; - label { - display: inline-block; - margin-right: $interiorMarginSm; - } - &.inline { - display: inline-block; - } - &:first-child { - margin-left: 0; - } + margin-left: $interiorMargin; + label { + display: inline-block; + margin-right: $interiorMarginSm; + } + &.inline { + display: inline-block; + } + &:first-child { + margin-left: 0; + } } .s-menu-btn label.checkbox.custom { - margin-left: 5px; + margin-left: 5px; } .item .checkbox { - &.checked label { - @include box-shadow(none); - border-bottom: none; - } + &.checked label { + @include box-shadow(none); + border-bottom: none; + } } .context-available { - $c: $colorKey; - color: $c; - &:hover { - color: lighten($c, 10%); - } + $c: $colorKey; + color: $c; + &:hover { + color: lighten($c, 10%); + } } .view-switcher { - @include trans-prop-nice-fade($controlFadeMs); + @include trans-prop-nice-fade($controlFadeMs); } /******************************************************** OBJECT-HEADER */ .object-header { - //@include test(); - font-size: 1em; + //@include test(); + font-size: 1em; - //> .title-label, - //> .type-icon, - //> .context-available { - // //@include tmpBorder(#6666ff); - // //vertical-align: middle; - //} + > .type-icon { + color: $colorObjHdrIc; + font-size: 120%; + float: left; + margin-right: $interiorMargin; + } - > .type-icon { - color: $colorObjHdrIc; - font-size: 120%; - float: left; - margin-right: $interiorMargin; - } + .l-elem-wrapper { + //@include test(#66f, 0.2); + @include justify-content(flex-start); + mct-representation { + // Holds the context-available item + // Must have min-width to make flex work properly + // in Safari + min-width: 0.7em; + } + } - .l-elem-wrapper { - //@include test(#66f, 0.2); - @include webkitProp(justify-content, flex-start); - mct-representation { - // Holds the context-available item - // Must have min-width to make flex work properly - // in Safari - min-width: 0.7em; - } - } + .action { + margin-right: $interiorMargin; + } - .action { - margin-right: $interiorMargin; - } + .title-label { + //@include test(green, 0.9); + color: $colorObjHdrTxt; + @include ellipsize(); + //color: pushBack($colorBodyFg, 40%); + @include webkitProp(flex, '0 1 auto'); + padding-right: 0.35em; // For context arrow. Done with em's so pad is relative to the scale of the text. + //position: relative; + } - .title-label { - //@include test(green, 0.9); - color: $colorObjHdrTxt; - @include ellipsize(); - //color: pushBack($colorBodyFg, 40%); - @include webkitProp(flex, '0 1 auto'); - padding-right: 0.35em; // For context arrow. Done with em's so pad is relative to the scale of the text. - //position: relative; - } + .context-available { + font-size: 0.7em; + @include webkitProp(flex, '0 0 1'); + //margin-right: $interiorMargin; + } - .context-available { - font-size: 0.7em; - @include webkitProp(flex, '0 0 1'); - //margin-right: $interiorMargin; - } + @include desktop { + .context-available { + @include trans-prop-nice(opacity, 0.25s); + opacity: 0; + } + &:hover { + .context-available { + opacity: 1; + } + } + } +} - @include desktop { - .context-available { - @include trans-prop-nice(opacity, 0.25s); - opacity: 0; - } - &:hover { - .context-available { - opacity: 1; - } - } - } +/******************************************************** PROGRESS BAR */ +@include keyframes(progress) { + 100% { background-position: $progressBarStripeW center; } +} + +@mixin bgProgressAnim($c: yellow, $a: 0.1, $d: 20px) { + @include background-image(linear-gradient(-90deg, + rgba($c, $a) 0%, transparent 50%, + transparent 50%, rgba($c, $a) 100% + )); + background-position: 0 center; + background-repeat: repeat-x; + background-size: $d 40%; +} + +.l-progress-bar { + // Assume will be determinate by default + display: inline-block; + overflow: hidden; + position: relative; + + .progress-amt-holder { + @include absPosDefault(1px); + } + .progress-amt, + .progress-amt:before, + .progress-amt:after { + @include absPosDefault(); + display: block; + content: ''; + } + + .progress-amt { + right: auto; // Allow inline width to control } + } + + &.indeterminate { + .progress-amt { + width: 100% !important; + } + } +} + +.s-progress-bar { + @include border-radius($basicCr); + @include boxIncised(0.3, 4px); + background: $colorProgressBarOuter; + //border:1px solid $colorProgressBarOuter; + .progress-amt { + @include border-radius($basicCr); + @include boxShdw(); + @include border-radius($basicCr - 1); + @include trans-prop-nice(width); + &:before { + background-color: $colorProgressBarAmt; + } + &:after { + // Sheen + @include background-image(linear-gradient( + transparent 5%, rgba(#fff,0.25) 30%, transparent 100% + )); + } + } + + &:not(.indeterminate) { + .progress-amt:before { + // More subtle anim for determinate progress + @include animation(progress .4s linear infinite); + @include bgProgressAnim(#fff, 0.1, $progressBarStripeW); + } + } + + &.indeterminate .progress-amt { + &:before { + // More visible std diag stripe anim for indeterminate progress + @include animation(progress .6s linear infinite); + @include bgDiagonalStripes(#fff, 0.2, $progressBarStripeW); + } + &:after { display: none; } + } } /******************************************************** SLIDERS */ .slider { - $knobH: 100%; //14px; - .slot { - // @include border-radius($basicCr * .75); - //@include sliderTrack(); - width: auto; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - } - .knob { - //@include btnSubtle(); - //@include controlGrippy(rgba(black, 0.3), vertical, 1px, solid); - @include trans-prop-nice-fade(.25s); - background-color: $sliderColorKnob; - &:hover { - background-color: $sliderColorKnobHov; - } - position: absolute; - height: $knobH; - width: $sliderKnobW; - top: 0; - auto: 0; - bottom: auto; - left: auto; - } - .knob-l { - @include border-left-radius($sliderKnobW); - cursor: w-resize; - } - .knob-r { - @include border-right-radius($sliderKnobW); - cursor: e-resize; - } - .range { - @include trans-prop-nice-fade(.25s); - background-color: $sliderColorRange; - cursor: ew-resize; - position: absolute; - top: 0; //$tbOffset; - right: auto; - bottom: 0; - left: auto; - height: auto; - width: auto; - &:hover { - background-color: $sliderColorRangeHov; - } - } + $knobH: 100%; //14px; + .slot { + // @include border-radius($basicCr * .75); + //@include sliderTrack(); + width: auto; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + .knob { + @include trans-prop-nice-fade(.25s); + background-color: $sliderColorKnob; + &:hover { + background-color: $sliderColorKnobHov; + } + position: absolute; + height: $knobH; + width: $sliderKnobW; + top: 0; + auto: 0; + bottom: auto; + left: auto; + } + .knob-l { + @include border-left-radius($sliderKnobW); + cursor: w-resize; + } + .knob-r { + @include border-right-radius($sliderKnobW); + cursor: e-resize; + } + .range { + @include trans-prop-nice-fade(.25s); + background-color: $sliderColorRange; + cursor: ew-resize; + position: absolute; + top: 0; //$tbOffset; + right: auto; + bottom: 0; + left: auto; + height: auto; + width: auto; + &:hover { + background-color: $sliderColorRangeHov; + } + } } /******************************************************** DATETIME PICKER */ .l-datetime-picker { - $r1H: 15px; - @include user-select(none); - font-size: 0.8rem; - padding: $interiorMarginLg !important; - width: 230px; - .l-month-year-pager { - $pagerW: 20px; - //@include test(); - //font-size: 0.8rem; - height: $r1H; - margin-bottom: $interiorMargin; - position: relative; - .pager, - .val { - //@include test(red); - @extend .abs; - } - .pager { - width: $pagerW; - @extend .ui-symbol; - &.prev { - right: auto; - &:before { - content: "\3c"; - } - } - &.next { - left: auto; - text-align: right; - &:before { - content: "\3e"; - } - } - } - .val { - text-align: center; - left: $pagerW + $interiorMargin; - right: $pagerW + $interiorMargin; - } - } - .l-calendar, - .l-time-selects { - border-top: 1px solid $colorInteriorBorder - } - .l-time-selects { - line-height: $formInputH; - } + $r1H: 15px; + @include user-select(none); + font-size: 0.8rem; + padding: $interiorMarginLg !important; + width: 230px; + .l-month-year-pager { + $pagerW: 20px; + //@include test(); + //font-size: 0.8rem; + height: $r1H; + margin-bottom: $interiorMargin; + position: relative; + .pager, + .val { + //@include test(red); + @extend .abs; + } + .pager { + width: $pagerW; + @extend .ui-symbol; + &.prev { + right: auto; + &:before { + content: "\3c"; + } + } + &.next { + left: auto; + text-align: right; + &:before { + content: "\3e"; + } + } + } + .val { + text-align: center; + left: $pagerW + $interiorMargin; + right: $pagerW + $interiorMargin; + } + } + .l-calendar, + .l-time-selects { + border-top: 1px solid $colorInteriorBorder + } + .l-time-selects { + line-height: $formInputH; + } } /******************************************************** CALENDAR */ .l-calendar { - $colorMuted: pushBack($colorMenuFg, 30%); - ul.l-cal-row { - @include display-flex; - @include flex-flow(row nowrap); - margin-top: 1px; - &:first-child { - margin-top: 0; - } - li { - @include flex(1 0); - //@include test(); - margin-left: 1px; - padding: $interiorMargin; - text-align: center; - &:first-child { - margin-left: 0; - } - } - &.l-header li { - color: $colorMuted; - } - &.l-body li { - @include trans-prop-nice(background-color, .25s); - cursor: pointer; - &.in-month { - background-color: $colorCalCellInMonthBg; - } - .sub { - color: $colorMuted; - font-size: 0.8em; - } - &.selected { - background: $colorCalCellSelectedBg; - color: $colorCalCellSelectedFg; - .sub { - color: inherit; - } - } - &:hover { - background-color: $colorCalCellHovBg; - color: $colorCalCellHovFg; - .sub { - color: inherit; - } - } - } - } + $colorMuted: pushBack($colorMenuFg, 30%); + ul.l-cal-row { + @include display-flex; + @include flex-flow(row nowrap); + margin-top: 1px; + &:first-child { + margin-top: 0; + } + li { + @include flex(1 0); + //@include test(); + margin-left: 1px; + padding: $interiorMargin; + text-align: center; + &:first-child { + margin-left: 0; + } + } + &.l-header li { + color: $colorMuted; + } + &.l-body li { + @include trans-prop-nice(background-color, .25s); + cursor: pointer; + &.in-month { + background-color: $colorCalCellInMonthBg; + } + .sub { + color: $colorMuted; + font-size: 0.8em; + } + &.selected { + background: $colorCalCellSelectedBg; + color: $colorCalCellSelectedFg; + .sub { + color: inherit; + } + } + &:hover { + background-color: $colorCalCellHovBg; + color: $colorCalCellHovFg; + .sub { + color: inherit; + } + } + } + } } /******************************************************** BROWSER ELEMENTS */ @include desktop { - ::-webkit-scrollbar { - @include border-radius(2px); - @include box-sizing(border-box); - @include box-shadow(inset $scrollbarTrackShdw); - background-color: $scrollbarTrackColorBg; - height: $scrollbarTrackSize; - width: $scrollbarTrackSize; - } + ::-webkit-scrollbar { + @include border-radius(2px); + @include box-sizing(border-box); + @include box-shadow(inset $scrollbarTrackShdw); + background-color: $scrollbarTrackColorBg; + height: $scrollbarTrackSize; + width: $scrollbarTrackSize; + } - ::-webkit-scrollbar-thumb { - $bg: $scrollbarThumbColor; - $hc: $scrollbarThumbColorHov; - $gr: 5%; - @include background-image(linear-gradient(lighten($bg, $gr), $bg 20px)); - @include border-radius(2px); - @include box-sizing(border-box); - //@include boxShdwSubtle(); - //border-top: 1px solid lighten($bg, 20%); - &:hover { - @include background-image(linear-gradient(lighten($hc, $gr), $hc 20px)); - } - } + ::-webkit-scrollbar-thumb { + $bg: $scrollbarThumbColor; + $hc: $scrollbarThumbColorHov; + $gr: 5%; + @include background-image(linear-gradient(lighten($bg, $gr), $bg 20px)); + @include border-radius(2px); + @include box-sizing(border-box); + //@include boxShdwSubtle(); + //border-top: 1px solid lighten($bg, 20%); + &:hover { + @include background-image(linear-gradient(lighten($hc, $gr), $hc 20px)); + } + } - ::-webkit-scrollbar-corner { - background: $scrollbarTrackColorBg; - } -} \ No newline at end of file + ::-webkit-scrollbar-corner { + background: $scrollbarTrackColorBg; + } +} diff --git a/platform/commonUI/general/res/sass/controls/_lists.scss b/platform/commonUI/general/res/sass/controls/_lists.scss index d19f91ef47..accae0583f 100644 --- a/platform/commonUI/general/res/sass/controls/_lists.scss +++ b/platform/commonUI/general/res/sass/controls/_lists.scss @@ -32,7 +32,7 @@ .l-tree-item-flat-list { // For lists of tree-items that are flat. Remove margin, etc. normally needed for the expansion arrow. .tree-item { - .label { + .t-object-label { left: $interiorMargin !important; } } diff --git a/platform/commonUI/general/res/sass/controls/_menus.scss b/platform/commonUI/general/res/sass/controls/_menus.scss index 49693b3c59..473e9f1d45 100644 --- a/platform/commonUI/general/res/sass/controls/_menus.scss +++ b/platform/commonUI/general/res/sass/controls/_menus.scss @@ -43,6 +43,11 @@ } &.create-btn { + &:before { + content:'\2b'; + display: inline; + font-family: symbolsfont; + } .title-label { font-size: 1rem; } @@ -83,7 +88,7 @@ @include menuUlReset(); li { @include box-sizing(border-box); - border-top: 1px solid lighten($colorMenuBg, 20%); + border-top: 1px solid pullForward($colorMenuBg, 10%); color: pullForward($colorMenuBg, 60%); line-height: $menuLineH; padding: $interiorMarginSm $interiorMargin * 2 $interiorMarginSm ($interiorMargin * 2) + $treeTypeIconW; diff --git a/platform/commonUI/general/res/sass/controls/_messages.scss b/platform/commonUI/general/res/sass/controls/_messages.scss new file mode 100644 index 0000000000..740df6ba8d --- /dev/null +++ b/platform/commonUI/general/res/sass/controls/_messages.scss @@ -0,0 +1,306 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +@mixin statusBannerColors($bg, $fg: $colorStatusFg) { + $bgPb: 30%; + $bgPbD: 10%; + background-color: darken($bg, $bgPb); + color: $fg; + &:hover { + background-color: darken($bg, $bgPb - $bgPbD); + } + .s-action { + background-color: darken($bg, $bgPb + $bgPbD); + &:hover { + background-color: darken($bg, $bgPb); + } + } +} + +.status.block { + color: $colorStatusDefault; + cursor: default; + display: inline-block; + margin-right: $interiorMargin; + .status-indicator, + .label, + .count { + //@include test(#00ff00); + display: inline-block; + vertical-align: top; + } + .status-indicator { + margin-right: $interiorMarginSm; + } + &.ok .status-indicator, + &.info .status-indicator { + color: $colorStatusInfo; + } + &.alert .status-indicator, + &.warning .status-indicator, + &.caution .status-indicator { + color: $colorStatusAlert; + } + &.error .status-indicator { + color: $colorStatusError; + } + .label { + // Max-width silliness is necessary for width transition + @include trans-prop-nice(max-width, .25s); + overflow: hidden; + max-width: 0px; + } + .count { + @include trans-prop-nice(opacity, .25s); + font-weight: bold; + opacity: 1; + } + &:hover { + .label { + max-width: 450px; + width: auto; + } + .count { + opacity: 0; + } + } +} + +/* Styles for messages and message banners */ +.message { + &.block { + @include border-radius($basicCr); + padding: $interiorMarginLg; + } + &.error { + background-color: rgba($colorAlert,0.3); + color: lighten($colorAlert, 20%); + } +} + +.l-message-banner { + $m: $interiorMarginSm; + $lh: $ueFooterH - ($m*2) - 1; + @include box-sizing(border-box); + @include ellipsize(); + @include display-flex; + @include flex-direction(row); + @include align-items(center); + position: absolute; + top: $m; right: auto; bottom: $m; left: 50%; + height: auto; width: auto; + line-height: $lh; + max-width: 300px; + padding: 0 $interiorMargin 0 $interiorMargin; + @include transform(translateX(-50%)); + + &.minimized { + @include transition-property(left, opacity); + @include transition-duration(0.3s); + @include transition-timing-function(ease-in-out); + left: 0; + opacity: 0; + } + + &.new { + left: 50%; + opacity: 1; + &:not(.info) { + @include pulse(100ms, 10); + } + } + + .banner-elem { + @include flex(0 1 auto); + margin-left: $interiorMargin; + } + a { + display: inline-block; + } + .l-action { + line-height: $lh - 3; + padding: 0 $interiorMargin; + } + .close { + //@include test(red, 0.7); + cursor: pointer; + font-size: 7px; + width: 8px; + } + .l-progress-bar { + $h: $lh - 10; + height: $h; + line-height: $h; + width: 100px; + } + .progress-info { display: none; } + z-index: 10; +} + +.s-message-banner { + //@include transition-property(left, opacity); + //@include transition-duration(0.35s); + //@include transition-timing-function(ease-in-out); +} + +.s-message-banner { + @include border-radius($controlCr); + @include statusBannerColors($colorStatusDefault, $colorStatusFg); + cursor: pointer; + a { color: inherit; } + .s-action { + @include border-radius($basicCr); + @include trans-prop-nice(background-color); + } + .close { + opacity: 0.5; + &:hover { + opacity: 1; + } + } + &.ok, + &.info { + @include statusBannerColors($colorStatusInfo); + } + &.caution, + &.warning, + &.alert { + @include statusBannerColors($colorStatusAlert); + } + &.error { + @include statusBannerColors($colorStatusError); + } +} + +@mixin messageBlock($iconW: 32px) { + .type-icon.message-type { + @include txtShdw($shdwStatusIc); + &:before { content:"\e608"; } + color: $colorStatusDefault; + font-size: $iconW; + padding: 1px; + width: $iconW + 2; + } + + .message-severity-info .type-icon.message-type { + &:before { content:"\e608"; } + color: $colorStatusInfo; + } + .message-severity-alert .type-icon.message-type { + &:before { content:"\e610"; } + color: $colorStatusAlert; + } + .message-severity-error .type-icon.message-type { + &:before { content:"\21"; } + color: $colorStatusError; + } +} +/* Paths: + t-dialog | t-dialog-sm > t-message-single | t-message-list > overlay > holder > contents > l-message > + message-type > (icon) + message-contents > + top-bar > + title + hint + editor > + (if displaying list of messages) + ul > li > l-message > + ... same as above + bottom-bar +*/ + +.l-message { + @include display-flex; + @include flex-direction(row); + @include align-items(stretch); + .type-icon.message-type { + //@include test(red); + @include flex(0 1 auto); + position: relative; + } + .message-contents { + //@include test(blue); + @include flex(1 1 auto); + margin-left: $overlayMargin; + position: relative; + + .top-bar, + .message-body { + margin-bottom: $interiorMarginLg * 2; + } + } +} + + +// Message as singleton +.t-message-single { + @include messageBlock(80px); + + @include desktop { + .l-message, + .bottom-bar { + @include absPosDefault(); + } + + .bottom-bar { + top: auto; + height: $ovrFooterH; + } + } +} + +// Messages in list +.t-message-list { + @include messageBlock(32px); + + .message-contents { + .l-message { + //border-bottom: 1px solid pullForward($colorOvrBg, 20%); + @include border-radius($controlCr); + background: rgba($colorOvrFg, 0.1); + margin-bottom: $interiorMargin; + padding: $interiorMarginLg; + + .message-contents, + .bottom-bar { + //@include test(green); + position: relative; + } + + .message-contents { + font-size: 0.9em; + margin-left: $interiorMarginLg; + .message-action { color: pushBack($colorOvrFg, 20%); } + .bottom-bar { text-align: left; } + } + + .top-bar, + .message-body { + margin-bottom: $interiorMarginLg; + } + } + } + + @include desktop { + .message-contents .l-message { margin-right: $interiorMarginLg; } + } +} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/helpers/_splitter.scss b/platform/commonUI/general/res/sass/helpers/_splitter.scss index f91f5936d4..a98fc28af5 100644 --- a/platform/commonUI/general/res/sass/helpers/_splitter.scss +++ b/platform/commonUI/general/res/sass/helpers/_splitter.scss @@ -19,68 +19,114 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -.split-layout { - $b: pullForward($colorBodyBg, $contrastRatioPercent); - .splitter { - background-color: $b; - @include border-radius($splitterEndCr); - @include boxShdw($splitterShdw); - overflow: hidden; - position: absolute; - z-index: 1; - //@if $colorSplitterHover != 'none' { - &:hover { - background-color: $colorSplitterHover; - } - //} - } - &.horizontal { - // Slides vertically up and down, splitting the element horizontally - overflow: hidden; // Suppress overall scroll; each internal pane handles its own overflow - .pane { - left: 0; - right: 0; - &.top { - bottom: auto; - } - &.bottom { - top: auto; - } - } - >.splitter { - @include controlGrippy($colorSplitterInterior, horizontal); - cursor: row-resize; - left: 0; right: 0; - width: auto; - height: $splitterW; - } - } - &.vertical { - // Slides horizontally left to right, splitting the element vertically - .pane { - top: 0; - bottom: 0; - &.left { - right: auto; - } - &.right { - left: auto; - } - } - >.splitter { - @include controlGrippy($colorBodyBg, vertical); - bottom: 0; - cursor: col-resize; - width: $splitterW; - } - } +.splitter { + // Redo the splitter. + // New look is a simple line. + // Main width is used to provide a good click area, and is always transparent + // :after will be a positioned and colored element that is the handle + + //@include test(red); + display: block; + position: absolute; + z-index: 1; + &:after { + // The handle + content:""; + pointer-events: none; + @include absPosDefault(0); + background: $colorSplitterBg; + display: block; + + @if $splitterEndCr != 'none' { + @include border-radius($splitterEndCr); + } + } + &:active { + //@include test(); + &:after { + background-color: $colorSplitterActive !important; + } + } + + @if $colorSplitterHover != 'none' { + &:not(:active) { + &:hover { + &:after { + background-color: $colorSplitterHover !important; + @include trans-prop-nice(background-color, 150ms); + } + } + } + } } -.browse-area .splitter { - top: $ueTopBarH + $interiorMarginLg; +.split-layout { + $inset: splitterHandleInset($splitterD,$splitterHandleD); + &.horizontal { + // Slides vertically up and down, splitting the element horizontally + overflow: hidden; // Suppress overall scroll; each internal pane handles its own overflow + .pane { + left: 0; + right: 0; + &.top { + bottom: auto; + } + &.bottom { + top: auto; + } + } + >.splitter { + cursor: row-resize; + left: 0; + right: 0; + height: $splitterD; + &:after { + top: $inset; bottom: $inset; + } + } + } + + &.vertical { + // Slides horizontally left to right, splitting the element vertically + .pane { + top: 0; + bottom: 0; + &.left { + right: auto; + } + &.right { + left: auto; + } + } + >.splitter { + cursor: col-resize; + top: 0; + bottom: 0; + &:not(.flush-right) { + width: $splitterD; + &:after { + left: $inset; right: $inset; + } + } + &.flush-right { + width: ceil($splitterD / 2); + &:after { + background-color: transparent; + left: auto; right: 0; width: $splitterHandleD; + } + &.edge-shdw { + @include background-image(linear-gradient(90deg, rgba(black, 0) 40%, rgba(black, 0.05) 70%, rgba(black, 0.2) 100%)); + } + } + } + } +} + +/*.browse-area .splitter { + top: 0; //$ueTopBarH + $interiorMarginLg; } .edit-area .splitter { top: 0; -} +}*/ diff --git a/platform/commonUI/general/res/sass/helpers/_wait-spinner.scss b/platform/commonUI/general/res/sass/helpers/_wait-spinner.scss index f80c1f1971..86c23a266a 100644 --- a/platform/commonUI/general/res/sass/helpers/_wait-spinner.scss +++ b/platform/commonUI/general/res/sass/helpers/_wait-spinner.scss @@ -19,24 +19,45 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -@-webkit-keyframes rotation { - from {-webkit-transform: rotate(0deg);} - to {-webkit-transform: rotate(359deg);} +@include keyframes(rotation) { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(359deg); } } -@-moz-keyframes rotation { - from {-moz-transform: rotate(0deg);} - to {-moz-transform: rotate(359deg);} +@mixin wait-spinner2($b: 5px, $c: $colorAlt1) { + @include keyframes(rotateCentered) { + 0% { transform: translateX(-50%) translateY(-50%) rotate(0deg); } + 100% { transform: translateX(-50%) translateY(-50%) rotate(359deg); } + } + @include animation-name(rotateCentered); + @include animation-duration(0.5s); + @include animation-iteration-count(infinite); + @include animation-timing-function(linear); + border-color: rgba($c, 0.25); + border-top-color: rgba($c, 1.0); + border-style: solid; + border-width: 5px; + @include border-radius(100%); + @include box-sizing(border-box); + display: block; + position: absolute; + height: 0; width: 0; + padding: 7%; + left: 50%; top: 50%; } -@-o-keyframes rotation { - from {-o-transform: rotate(0deg);} - to {-o-transform: rotate(359deg);} -} - -@keyframes rotation { - from {transform: rotate(0deg);} - to {transform: rotate(359deg);} +@mixin wait-spinner($b: 5px, $c: $colorAlt1) { + display: block; + position: absolute; + -webkit-animation: rotation .6s infinite linear; + -moz-animation: rotation .6s infinite linear; + -o-animation: rotation .6s infinite linear; + animation: rotation .6s infinite linear; + border-color: rgba($c, 0.25); + border-top-color: rgba($c, 1.0); + border-style: solid; + border-width: $b; + @include border-radius(100%); } .t-wait-spinner, @@ -96,4 +117,28 @@ margin-top: 0 !important; padding: 0 !important; top: 0; left: 0; +} + +.loading { + // Can be applied to any block element with height and width + pointer-events: none; + &:before, + &:after { + content: ''; + } + &:before { + @include wait-spinner2(5px, $colorLoadingFg); + z-index: 10; + } + &:after { + @include absPosDefault(); + background: $colorLoadingBg; + display: block; + z-index: 9; + } + &.tree-item:before { + padding: $menuLineH / 4; + border-width: 2px; + } + } \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/items/_item.scss b/platform/commonUI/general/res/sass/items/_item.scss index 9e5fe1cf47..d9d068cf90 100644 --- a/platform/commonUI/general/res/sass/items/_item.scss +++ b/platform/commonUI/general/res/sass/items/_item.scss @@ -86,27 +86,16 @@ //top: $ueBrowseGridItemTopBarH; bottom: $ueBrowseGridItemBottomBarH; // line-height: $lh; z-index: 1; - .item-type { - //@include trans-prop-nice("color", $transTime); - @include absPosDefault($iconMargin, false); - //@include test(red); - //color: $colorItemIc; - text-align: center; + .item-type, + .t-item-icon { + //@include test(); + @include transform(translateX(-50%) translateY(-55%)); + position: absolute; + top: 50%; left: 50%; + //height: $iconD; width: $iconD; font-size: $iconD * 0.95; //6em; - line-height: $iconD; - bottom: auto; - height: $iconD; - top: $iconMargin - 10; - .l-icon-link { - color: $colorIconLink; - height: auto; - line-height: 100%; - position: absolute; - font-size: 0.3em; - left: 0px; - bottom: 10px; - z-index: 2; - } + //line-height: normal; + //text-align: center; } .item-open { @include trans-prop-nice("opacity", $transTime); diff --git a/platform/commonUI/general/res/sass/mobile/_constants.scss b/platform/commonUI/general/res/sass/mobile/_constants.scss index bd9443cc50..067bbef6ca 100644 --- a/platform/commonUI/general/res/sass/mobile/_constants.scss +++ b/platform/commonUI/general/res/sass/mobile/_constants.scss @@ -23,7 +23,7 @@ /************************** MOBILE REPRESENTATION ITEMS DIMENSIONS */ $mobileListIconSize: 30px; $mobileTitleDescH: 35px; -$mobileOverlayMargin: 10px; +$mobileOverlayMargin: 20px; $phoneItemH: floor($ueBrowseGridItemLg/4); $tabletItemH: floor($ueBrowseGridItemLg/3); diff --git a/platform/commonUI/general/res/sass/mobile/_item.scss b/platform/commonUI/general/res/sass/mobile/_item.scss index 717e589cb5..e60e96c5a7 100644 --- a/platform/commonUI/general/res/sass/mobile/_item.scss +++ b/platform/commonUI/general/res/sass/mobile/_item.scss @@ -49,18 +49,11 @@ } .item-main { - .item-type { - //@include test(blue); + .item-type, + .t-item-icon { font-size: $mobileListIconSize; - right: auto; - bottom: auto; - left: 0; - line-height: 100%; - text-align: left; - width: $mobileListIconSize; - .l-icon-link { - bottom: 0; - } + left: $interiorMarginLg + $interiorMargin; + line-height: normal; } .item-open { display: block; diff --git a/platform/commonUI/general/res/sass/mobile/_layout.scss b/platform/commonUI/general/res/sass/mobile/_layout.scss index 17ff8c4213..589d7e7c90 100644 --- a/platform/commonUI/general/res/sass/mobile/_layout.scss +++ b/platform/commonUI/general/res/sass/mobile/_layout.scss @@ -32,7 +32,7 @@ background-color: $colorMobilePaneLeft; } - .pane.right-repr { + .pane.right.items { //@include test(); @include slMenuTransitions; margin-left: 0 !important; @@ -42,78 +42,66 @@ } } - .user-environ .browse-area, - .user-environ .edit-area, - .user-environ .editor { - top: 0; left: 0; right: 0; bottom: $ueFooterH; - } - - .holder.l-mobile { - top: $bodyMargin !important; + .holder.holder-create-and-search { right: $bodyMargin !important; - bottom: $bodyMargin !important; - left: $bodyMargin !important; } - // When the tree is hidden, these are the + +// When the tree is hidden, these are the // classes used for the left menu and the // right representation. - .browse-hidetree { - @include user-select(none); + .pane-tree-hidden { // Sets the left tree menu when the tree // is hidden. .pane.left.treeview { - opacity: 0; - right: 100% !important; - width: auto !important; - overflow-y: hidden; - overflow-x: hidden; + @include trans-prop-nice(opacity, 150ms); + //right: 100% !important; + //width: auto !important; + //overflow-y: hidden; + //overflow-x: hidden; + opacity: 0 !important; } - // Sets the right represenation when - // the tree is hidden. - .pane.right-repr { + .pane.right.items { left: 0 !important; } } - .browse-showtree { + .pane-tree-showing { // NOTE: DISABLED SELECTION // Selection disabled in both panes // causing cut/copy/paste menu to // not appear. Should me moved in // future to properly work - @include user-select(none); + //@include user-select(none); // Sets the left tree menu when the tree is shown. .pane.left.treeview { - @include trans-prop-nice(opacity, .4s); + @include trans-prop-nice(opacity, 250ms, $delay: 250ms); @include background-image(linear-gradient(90deg, rgba(black, 0) 98%, rgba(black, 0.3) 100%)); - opacity: 1; - display: block !important; - //width: auto !important; // CH CO right: auto !important; width: $proporMenuWithView !important; } // Sets the right representation when the tree is shown. - .pane.right-repr { + .pane.right.items { left: $proporMenuWithView !important; - //width: auto !important; - - //left: 0 !important; - //transform: translateX($proporMenuWithView); } } - .mobile-menu-icon { + .toggle-tree { + color: $colorKey !important; font-size: 110%; position: absolute; top: $bodyMargin + 2; left: $bodyMargin; + &:after { + content:'m' !important; + font-family: symbolsfont; + } } .object-browse-bar { //@include test(); - left: 30px !important; + left: 45px !important; .context-available { opacity: 1 !important; } @@ -153,13 +141,13 @@ } @include phonePortrait { - .browse-showtree { + .pane-tree-showing { .pane.left.treeview { width: $proporMenuOnly !important; } - .pane.right-repr { + .pane.right.items { left: 0 !important; - @include webkitProp(transform, translateX($proporMenuOnly)); + @include transform(translateX($proporMenuOnly)); #content-area { opacity: 0; } diff --git a/platform/commonUI/general/res/sass/mobile/_tree.scss b/platform/commonUI/general/res/sass/mobile/_tree.scss index e6efb4be55..f3862be742 100644 --- a/platform/commonUI/general/res/sass/mobile/_tree.scss +++ b/platform/commonUI/general/res/sass/mobile/_tree.scss @@ -37,21 +37,18 @@ //@include test(red); position: absolute; font-size: 1.1em; + height: $mobileTreeItemH; + line-height: inherit; right: 0px; width: $mobileTreeRightArrowW; text-align: center; } - .label { + .label, + .t-object-label { left: 0; right: $mobileTreeRightArrowW + $interiorMargin; // Allows tree item name to stop prior to the arrow - line-height: $mobileTreeItemH; - //font-size: 1.1em; // CH CO - .type-icon { - @include verticalCenterBlock($mobileTreeItemH, $treeTypeIconH); - } - .title-label { - } + line-height: inherit; } } } diff --git a/platform/commonUI/general/res/sass/mobile/overlay/_overlay.scss b/platform/commonUI/general/res/sass/mobile/overlay/_overlay.scss index caa6df0967..9fd6721130 100644 --- a/platform/commonUI/general/res/sass/mobile/overlay/_overlay.scss +++ b/platform/commonUI/general/res/sass/mobile/overlay/_overlay.scss @@ -1,16 +1,12 @@ @include phoneandtablet { .overlay { - $m: 0; .clk-icon.close { top: $mobileOverlayMargin; right: $mobileOverlayMargin; } > .holder { - @include border-radius($m); - top: $m; - right: $m; - bottom: $m; - left: $m; + height: 90%; width: 90%; + > .contents { top: $mobileOverlayMargin; right: $mobileOverlayMargin; @@ -22,35 +18,64 @@ margin-right: 1.2em; } } - - .form.editor { - border: none; - - .contents { - top: 0; - right: 0; - bottom: 0; - left: 0; - } - } } } } } @include phone { - .overlay > .holder > .contents .form.editor .contents .form-row { - > .label, - > .controls { - //@include test(blue); - display: block; - float: none; - width: 100%; + .overlay > .holder { + //@include test(orange); // This works! + $m: 0; + @include border-radius($m); + top: $m; + right: $m; + bottom: $m; + left: $m; + height: auto; width: auto; + min-width: 200px; min-height: 200px; + max-height: 100%; max-width: 100%; + overflow: auto; + @include transform(none); + + .editor .form .form-row { + > .label, + > .controls { + //@include test(blue); + display: block; + float: none; + width: 100%; + } + > .label { + &:after { + float: none; + } + } + } + + .contents { + .abs.top-bar, + .abs.editor, + .abs.message-body, + .abs.bottom-bar { + //@include test(orange); + top: auto; right: auto; bottom: auto; left: auto; + height: auto; width: auto; + margin-bottom: $interiorMarginLg * 2; + position: relative; + } + } + } + .t-dialog-sm .overlay > .holder { + //@include test(blue); + height: auto; max-height: 100%; + } +} + +@include phonePortrait { + .overlay > .holder { + .contents .bottom-bar { + text-align: center; } - > .label { - &:after { - float: none; - } - } } } \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/overlay/_overlay.scss b/platform/commonUI/general/res/sass/overlay/_overlay.scss index 2dce492563..602af62887 100644 --- a/platform/commonUI/general/res/sass/overlay/_overlay.scss +++ b/platform/commonUI/general/res/sass/overlay/_overlay.scss @@ -20,79 +20,124 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ .overlay { - .blocker { - background: $colorOvrBlocker; - z-index: 100; - } + font-size: 90%; + .blocker { + background: $colorOvrBlocker; + z-index: 100; + } .clk-icon.close { font-size: 0.8rem; position: absolute; - top: $interiorMarginLg; right: $interiorMarginLg; bottom: auto; left: auto; - z-index: 100; + top: $interiorMarginLg; + right: $interiorMarginLg; + bottom: auto; + left: auto; + z-index: 100; } - >.holder { - $i: 15%; - @include containerSubtle($colorOvrBg, $colorOvrFg); - @include border-radius($basicCr * 3); - color: $colorOvrFg; - top: $i; right: $i; bottom: $i; left: $i; - z-index: 101; - >.contents { + > .holder { + //$i: 15%; + @include containerSubtle($colorOvrBg, $colorOvrFg); + @include border-radius($basicCr * 3); + color: $colorOvrFg; + top: 50%; + right: auto; + bottom: auto; + left: 50%; + @include transform(translateX(-50%) translateY(-50%)); + height: 70%; + width: 50%; + min-height: 300px; + max-height: 800px; + min-width: 600px; + max-width: 1000px; + z-index: 101; + > .contents { $m: $overlayMargin; - top: $m; right: $m; bottom: $m; left: $m; + top: $m; + right: $m; + bottom: $m; + left: $m; + + //.top-bar, + //.editor, + //.bottom-bar { + // @include absPosDefault(); + //} } - } - .title { - @include ellipsize(); - font-size: 1.2em; - margin-bottom: $interiorMargin; } - - .top-bar { + + .title { + @include ellipsize(); + font-size: 1.2em; + line-height: 120%; + margin-bottom: $interiorMargin; + } + + .hint { + color: pushBack($colorOvrFg, 20%); + } + + .abs.top-bar { height: $ovrTopBarH; } - - .editor { - top: $ovrTopBarH + ($interiorMargin * 2); - bottom: $ovrFooterH + $interiorMargin * 2; - left: 0; right: 0; - } - - .bottom-bar { - top: auto; right: 0; bottom: 0; left: 0; - overflow: visible; - //font-size: 1em; - height: $ovrFooterH; - text-align: right; - .s-btn { - $bg: $colorOvrBtnBg; - &:not(.major) { - @include btnSubtle($bg, pullForward($bg, 10%), $colorOvrBtnFg, $colorOvrBtnFg); - } - font-size: 95%; - height: $ovrFooterH; - line-height: $ovrFooterH; - margin-left: $interiorMargin; - padding: 0 $interiorMargin * 3; - //&.major { - // @extend .s-btn.major; - // &:hover { - // @extend .s-btn.major:hover; - // } - //} + + .abs.editor, + .abs.message-body { + top: $ovrTopBarH + $interiorMarginLg; + bottom: $ovrFooterH + $interiorMarginLg; + left: 0; + right: 0; + overflow: auto; + .field.l-med { + input[type='text'] { + width: 100%; + } } } - .contents.l-dialog { - $myM: $interiorMargin; - top: $myM; - right: $myM; - bottom: $myM; - left: $myM; - overflow: auto; - .field.l-med { - input[type='text'] { - width: 100%; - } - } + + .bottom-bar { + text-align: right; + .s-btn { + $bg: $colorOvrBtnBg; + &:not(.major) { + @include btnSubtle($bg, pullForward($bg, 10%), $colorOvrBtnFg, $colorOvrBtnFg); + } + font-size: 95%; + height: $ovrFooterH; + line-height: $ovrFooterH; + margin-left: $interiorMargin; + padding: 0 $interiorMargin * 3; + &:first-child { + margin-left: 0; + } + } + } + + .abs.bottom-bar { + top: auto; + right: 0; + bottom: 0; + left: 0; + overflow: visible; + //font-size: 1em; + height: $ovrFooterH; + } + + .l-progress-bar { + $h: $progressBarHOverlay; + display: block; + height: $h; + line-height: $h; + margin: .5em 0; + width: 100%; + } +} + +.t-dialog-sm .overlay > .holder { + // Used for blocker and in-progress dialogs, modal alerts, etc. + //@include test(red); + $h: 225px; + min-height: $h; + height: $h; } \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/search/_search.scss b/platform/commonUI/general/res/sass/search/_search.scss index 3aa349d6f8..85fadc5015 100644 --- a/platform/commonUI/general/res/sass/search/_search.scss +++ b/platform/commonUI/general/res/sass/search/_search.scss @@ -214,8 +214,6 @@ .search-scroll { order: 3; - - //padding-right: $rightPadding; margin-top: 4px; // Adjustable scrolling size @@ -227,28 +225,6 @@ .load-icon { position: relative; - &.loading { - pointer-events: none; - margin-left: $leftMargin; - - .title-label { - // Text styling - font-style: italic; - font-size: .9em; - opacity: 0.5; - - // Text positioning - margin-left: $iconWidth + $leftMargin; - line-height: 24px; - } - .wait-spinner { - margin-left: $leftMargin; - } - } - - &:not(.loading) { - cursor: pointer; - } } .load-more-button { diff --git a/platform/commonUI/general/res/sass/tree/_tree.scss b/platform/commonUI/general/res/sass/tree/_tree.scss index 2c0343a6c4..a3b9a1a000 100644 --- a/platform/commonUI/general/res/sass/tree/_tree.scss +++ b/platform/commonUI/general/res/sass/tree/_tree.scss @@ -52,7 +52,6 @@ ul.tree { font-size: 0.75em; width: $treeVCW; $runningItemW: $interiorMargin + $treeVCW; - // NOTE: [Mobile] Removed Hover on Mobile @include desktop { &:hover { color: $colorItemTreeVCHover !important; @@ -60,30 +59,38 @@ ul.tree { } } - .label { + .label, + .t-object-label { display: block; - // @include test(orange); @include absPosDefault(); - //left: $runningItemW + $interiorMargin; // Adding pad to left to make room for link icon line-height: $menuLineH; - //left: $runningItemW; + + .t-item-icon { + @include txtShdwSubtle($shdwItemTreeIcon); + font-size: $treeTypeIconH; + color: $colorItemTreeIcon; + position: absolute; + left: $interiorMargin; + top: 50%; + width: $treeTypeIconH; + @include transform(translateY(-50%)); + } .type-icon { //@include absPosDefault(0, false); - $d: $treeTypeIconH; // 16px is crisp size + $d: $treeTypeIconH; @include txtShdwSubtle($shdwItemTreeIcon); - font-size: $d; + font-size: $treeTypeIconH; color: $colorItemTreeIcon; left: $interiorMargin; position: absolute; - @include verticalCenterBlock($menuLineHPx, $d); + @include verticalCenterBlock($menuLineHPx, $treeTypeIconHPx); line-height: 100%; - right: auto; width: $d; + right: auto; width: $treeTypeIconH; .icon { &.l-icon-link, &.l-icon-alert { - //@include txtShdw($shdwItemTreeIcon); position: absolute; z-index: 2; } @@ -101,37 +108,24 @@ ul.tree { } } } - .title-label { + .title-label, + .t-title-label { @include absPosDefault(); display: block; left: $runningItemW + ($interiorMargin * 3); - //right: $treeContextTriggerW + $interiorMargin; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } - &.loading { - pointer-events: none; - .label { - opacity: 0.5; - .title-label { - font-style: italic; - } - } - .wait-spinner { - margin-left: 14px; - } - } - &.selected { background: $colorItemTreeSelectedBg; color: $colorItemTreeSelectedFg; .view-control { color: $colorItemTreeSelectedVC; } - .label .type-icon { + .t-object-label .t-item-icon { color: $colorItemTreeSelectedFg; //$colorItemTreeIconHover; } } @@ -140,12 +134,9 @@ ul.tree { // NOTE: [Mobile] Removed Hover on Mobile @include desktop { &:hover { - background: rgba($colorBodyFg, 0.1); //lighten($colorBodyBg, 5%); - color: pullForward($colorBodyFg, 20%); - //.context-trigger { - // display: block; - //} - .icon { + background: $colorItemTreeHoverBg; + color: $colorItemTreeHoverFg; + .t-item-icon { color: $colorItemTreeIconHover; } } @@ -158,7 +149,6 @@ ul.tree { .context-trigger { $h: 0.9rem; - //display: none; top: -1px; position: absolute; right: $interiorMarginSm; @@ -171,7 +161,7 @@ ul.tree { } .tree-item { - .label { + .t-object-label { left: $interiorMargin + $treeVCW; } } \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/user-environ/_bottom-bar.scss b/platform/commonUI/general/res/sass/user-environ/_bottom-bar.scss deleted file mode 100644 index dd705b0000..0000000000 --- a/platform/commonUI/general/res/sass/user-environ/_bottom-bar.scss +++ /dev/null @@ -1,72 +0,0 @@ -/***************************************************************************** - * 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. - *****************************************************************************/ -.ue-bottom-bar { - background: $colorFooterBg; - color: lighten($colorBodyBg, 30%); - font-size: .7rem; - //line-height: $ueFooterH - 4px; - //line-height: $ueFooterH; // New status bar design - .status-holder { - //@include border-radius($basicCr * 1.75); // New status bar design - @include box-sizing(border-box); - //background: $colorFooterBg; - //border-bottom: 1px solid lighten($colorBodyBg, 10%); // New status bar design - @include absPosDefault($interiorMargin); - @include ellipsize(); - line-height: $ueFooterH - ($interiorMargin * 2); - right: 120px; - text-transform: uppercase; - } - .app-logo { - @include box-sizing(border-box); - @include absPosDefault($interiorMargin); - left: auto; - cursor: pointer; - //font-size: 0.8em; - //line-height: $ueFooterH - 10px; - //padding-top: 1px; - //text-transform: uppercase; - &.logo-openmctweb { - background: url($dirImgs + 'logo-openmctweb.svg') no-repeat center center; - } - } -} - -.status.block { - //display: inline-block; - display: inline; // New status bar design. Inline to support ellipsis overflow - margin-right: $interiorMarginLg; - .status-indicator { - //@include border-radius($controlCr * 0.9); - //@include box-shadow(inset rgba(black, 0.5) 0 0 3px); - //@include text-shadow(rgba(black, 0.3) 0 0 2px); - display: inline-block; - margin-right: $interiorMarginSm; - color: $colorKey; - &.ok { - color: #009900; - } - &.caution { - color: #ffaa00; - } - } -} \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/user-environ/_layout.scss b/platform/commonUI/general/res/sass/user-environ/_layout.scss index 0d8983c182..4800cdeb6d 100644 --- a/platform/commonUI/general/res/sass/user-environ/_layout.scss +++ b/platform/commonUI/general/res/sass/user-environ/_layout.scss @@ -29,13 +29,13 @@ } } -.holder-all { +/*.holder-all { $myM: 0; // $interiorMarginSm; top: $myM; right: $myM; bottom: $myM; left: $myM; -} +}*/ .browse-area, .edit-area, @@ -96,12 +96,8 @@ .user-environ { .browse-area, - .edit-area, .editor { - top: $bodyMargin + $ueTopBarH + ($interiorMargin); - right: $bodyMargin; - bottom: $ueFooterH + $bodyMargin; - left: $bodyMargin; + top: 0; left: 0; right: 0; bottom: $ueFooterH; } .browse-area, @@ -115,31 +111,51 @@ .edit-area { $tbH: $btnToolbarH + $interiorMargin; top: $bodyMargin + $ueTopBarEditH + ($interiorMargin); + left: $bodyMargin; + right: $bodyMargin; + bottom: $bodyMargin + $ueFooterH; .tool-bar { bottom: auto; height: $tbH; line-height: $btnToolbarH; } - .work-area { + .object-holder.work-area { top: $tbH + $interiorMargin * 2; + overflow: auto; } } - .ue-bottom-bar { - //@include absPosDefault($bodyMargin); - @include absPosDefault(0); // New status bar design - top: auto; - height: $ueFooterH; - .status-holder { - //right: $ueAppLogoW + $bodyMargin; New status bar design - z-index: 1; - } - .app-logo { - left: auto; - width: $ueAppLogoW; - z-index: 2; - } - } + // from _bottom-bar.scss + .ue-bottom-bar { + @include absPosDefault(0);// New status bar design + top: auto; + height: $ueFooterH; + line-height: $ueFooterH - ($interiorMargin * 2); + background: $colorFooterBg; + color: lighten($colorBodyBg, 30%); + font-size: .7rem; + + .status-holder { + @include box-sizing(border-box); + @include absPosDefault($interiorMargin); + @include ellipsize(); + //line-height: $ueFooterH - ($interiorMargin * 2); + right: 120px; + text-transform: uppercase; + z-index: 1; + } + .app-logo { + @include box-sizing(border-box); + @include absPosDefault($interiorMargin); + cursor: pointer; + left: auto; + width: $ueAppLogoW; + z-index: 2; + &.logo-openmctweb { + background: url($dirImgs + 'logo-openmctweb.svg') no-repeat center center; + } + } + } } .cols { @@ -205,10 +221,19 @@ .browse-mode { .split-layout { - .split-pane-component.pane.left { - min-width: 150px; - max-width: 800px; - width: $ueBrowseLeftPaneW; + .split-pane-component.pane { + //@include test(green); + &.treeview.left { + min-width: 150px; + max-width: 800px; + width: $ueBrowseLeftPaneTreeW; + } + &.t-inspect.right { + min-width: 200px; + max-width: 600px; + //padding-left: $ueCollapsedPaneEdgeM; // Allow room for mini-tab element + width: $ueBrowseRightPaneInspectW; + } } } } @@ -226,16 +251,33 @@ } .pane { + @include box-sizing(border-box); position: absolute; + + .pane-header { + text-transform: uppercase; + height: $ueTopBarH; + line-height: $ueTopBarH; + margin-bottom: $interiorMargin; + } + + .primary-pane { + // Need to lift up this pane to ensure that 'collapsed' panes don't block user interactions + z-index: 2; + } + &.treeview.left { - .create-btn-holder { - bottom: auto; - top: 0; - height: $ueTopBarH; - .wrapper.menu-element { - position: absolute; - bottom: $interiorMargin; - } + //.create-btn-holder { + // //bottom: auto; + // //top: 0; + // height: $ueTopBarH; + // .wrapper.menu-element { + // position: absolute; + // bottom: $interiorMargin; + // } + //} + .holder-create-and-search{ + } .search-holder { top: $ueTopBarH + $interiorMarginLg; @@ -245,6 +287,54 @@ top: $ueTopBarH + $interiorMarginLg + $treeSearchInputBarH + $interiorMargin; } } + + .mini-tab-icon.toggle-pane { + //@include test(blue, 0.3); + z-index: 5; + @include desktop { + $d: $uePaneMiniTabH; + $paneExpandedOffset: $splitterD + $uePaneMiniTabW; + top: $bodyMargin; + height: $d; + line-height: $d; + &:after { + // Always the icon that shows when the pane is collapsed + opacity: 0; + } + &.collapsed { + &:before { + opacity: 0; + } + &:after { + opacity: 1; + } + } + &.toggle-tree.anchor-left { + left: 0; + @include transform(translateX(-1 * $paneExpandedOffset)); + &:after { + content: '\6d'; // Menu 'hamburger' icon + } + &.collapsed { + left: 0; + @include transform(translateX((-1 * $ueCollapsedPaneEdgeM) + $interiorMargin)); + } + &:not(.collapsed):before { + @include trans-prop-nice(opacity, 200ms, 200ms); + } + } + &.toggle-inspect.anchor-right { + right: $bodyMargin; + &:after { + content: '\e615'; // e615: Crosshair icon; was e608: Info "i" icon + } + &.collapsed { + right: $interiorMargin; + } + } + } + } + &.items { .object-browse-bar { .left.abs, @@ -266,32 +356,50 @@ } } } - &.vertical { + /* &.vertical { // Slides left and right - > .pane { - // @include test(); - margin-left: $interiorMargin; + > .pane.left { > .holder { - left: 0; - right: 0; - } - &:first-child { - margin-left: 0; - .holder { - right: $interiorMarginSm; - } + left: $bodyMargin; } } + > .pane.right { + > .holder { + right: $bodyMargin; + } + } + }*/ + // Specific elements margins + .holder.holder-create-and-search { + top: $bodyMargin; + right: 0; + bottom: $bodyMargin; + left: $bodyMargin; + } + + .holder.holder-object-and-inspector { + top: 0; + right: 0; + bottom: 0; + left: 0; + .holder-object { + top: $bodyMargin; + bottom: $bodyMargin; + } + .holder-inspector-elements { + top: $bodyMargin; + bottom: $bodyMargin; + left: $bodyMargin; + right: $bodyMargin; + + } } } .object-holder { - overflow: hidden; // Contained objects need to handle their own overflow now + @include absPosDefault(0, auto); top: $ueTopBarH + $interiorMarginLg; - > ng-include { - @include absPosDefault(0, auto); - } &.l-controls-visible { &.l-time-controller-visible { bottom: nth($ueTimeControlH,1) + nth($ueTimeControlH,2) +nth($ueTimeControlH,3) + ($interiorMargin * 3); @@ -343,4 +451,84 @@ @include webkitProp(flex, '1 1 0'); padding-right: $interiorMarginLg; } -} \ No newline at end of file +} + +// When the tree is hidden, these are the +// classes used for the left menu and the +// right representation. +.pane-tree-hidden { + // Sets the left tree menu when the tree is hidden. + //.pane.left.treeview, + .tree-holder, + .splitter-treeview, + .holder-create-and-search { + opacity: 0; + } + /*.holder-create-and-search { + @include trans-prop-nice((top, left), 250ms); + top: $ueTopBarH + $interiorMargin; + left: -1 * $bodyMargin !important; + .create-btn { + @include border-left-radius(0); + @include trans-prop-nice((width), 250ms); + width: $uePaneMiniTabW !important; + text-align: center !important; + padding: 0; + .title-label, + &:after { + display: none; + } + &:before { + font-size: 9px; + } + } + }*/ +} + +.pane-tree-showing { + // Sets the left tree menu when the tree is shown. + //.pane.left.treeview, + .tree-holder, + .splitter-treeview { + @include trans-prop-nice(opacity, $dur: 250ms, $delay: 250ms); + opacity: 1; + } + + .holder-create-and-search { + @include trans-prop-nice(opacity, $dur: 250ms, $delay: 200ms); + } +} + +.pane-inspect-showing { + .l-object-and-inspector { + .l-inspect, + .splitter-inspect { + @include trans-prop-nice(opacity, $dur: 250ms, $delay: 250ms); + opacity: 1; + } + } +} +.pane-inspect-hidden { + .l-object-and-inspector { + .l-inspect, + .splitter-inspect { + opacity: 0; + } + } +} + +@include desktop { + .pane.treeview.left .tree-holder { + padding-right: $interiorMargin; + } + .pane-tree-hidden { + .pane.right.primary-pane { left: $ueCollapsedPaneEdgeM !important; } + } + .pane-inspect-hidden .l-object-and-inspector { + .pane.left { right: $ueCollapsedPaneEdgeM !important; } + } + + .pane:not(.resizing) { + @include trans-prop-nice-resize-w(250ms); + } +} diff --git a/platform/commonUI/general/res/templates/bottombar.html b/platform/commonUI/general/res/templates/bottombar.html index 4da2686fa1..f0d3799542 100644 --- a/platform/commonUI/general/res/templates/bottombar.html +++ b/platform/commonUI/general/res/templates/bottombar.html @@ -26,5 +26,6 @@ key="indicator.template">
+
\ No newline at end of file diff --git a/platform/commonUI/general/res/templates/controls/datetime-field.html b/platform/commonUI/general/res/templates/controls/datetime-field.html new file mode 100644 index 0000000000..6ba8cbf901 --- /dev/null +++ b/platform/commonUI/general/res/templates/controls/datetime-field.html @@ -0,0 +1,20 @@ + + + + + + +
+ + +
+
+
diff --git a/platform/commonUI/general/res/templates/controls/time-controller.html b/platform/commonUI/general/res/templates/controls/time-controller.html index ff142ea189..e44a9ff77c 100644 --- a/platform/commonUI/general/res/templates/controls/time-controller.html +++ b/platform/commonUI/general/res/templates/controls/time-controller.html @@ -19,84 +19,67 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> -
-
- C - - - - {{startOuterText}} - - -
- - -
-
-
-
+
+ C + + + + - to + to - - - - {{endOuterText}} - - -
- - -
-
-
  -
-
+ + +   + +
-
-
-
-
-
{{startInnerText}}
-
-
-
{{endInnerText}}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
{{startInnerText}}
+
+
+
{{endInnerText}}
+
+
+
+
+
+
+
+
+
-
-
-
- {{tick}} -
-
-
-
\ No newline at end of file +
+
+
+ {{tick}} +
+
+
+
diff --git a/platform/commonUI/general/res/templates/indicator.html b/platform/commonUI/general/res/templates/indicator.html index 34ea2fe9c8..e9be598b18 100644 --- a/platform/commonUI/general/res/templates/indicator.html +++ b/platform/commonUI/general/res/templates/indicator.html @@ -19,20 +19,21 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> + +
- - {{ngModel.getGlyph()}} - - - {{ngModel.getText()}} - - + + {{ngModel.getGlyph()}} + + {{ngModel.getText()}} + + + G diff --git a/platform/commonUI/general/res/templates/label.html b/platform/commonUI/general/res/templates/label.html index 7ca73bb026..beb57626a5 100644 --- a/platform/commonUI/general/res/templates/label.html +++ b/platform/commonUI/general/res/templates/label.html @@ -19,16 +19,7 @@ this source code distribution or the Licensing information page available at runtime from the About dialog for additional information. --> - - - {{type.getGlyph()}} - - - - - {{model.name}} - + +{{type.getGlyph()}} +{{model.name}} diff --git a/platform/commonUI/general/res/templates/message-banner.html b/platform/commonUI/general/res/templates/message-banner.html new file mode 100644 index 0000000000..44c7f915b6 --- /dev/null +++ b/platform/commonUI/general/res/templates/message-banner.html @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/platform/commonUI/general/res/templates/object-inspector.html b/platform/commonUI/general/res/templates/object-inspector.html new file mode 100644 index 0000000000..83ed591ff7 --- /dev/null +++ b/platform/commonUI/general/res/templates/object-inspector.html @@ -0,0 +1,63 @@ + + +
+
Inspection
+
    +
  • + Properties +
    +
    {{ data.name }}
    +
    {{ data.value }}
    +
    +
  • +
  • + Location + + + + +
  • +
  • + Original Location + + + + +
  • +
+
+
diff --git a/platform/commonUI/general/res/templates/progress-bar.html b/platform/commonUI/general/res/templates/progress-bar.html new file mode 100644 index 0000000000..a24a4fab29 --- /dev/null +++ b/platform/commonUI/general/res/templates/progress-bar.html @@ -0,0 +1,10 @@ + + + + + +
+ {{ngModel.progress}}% complete. + {{ngModel.progressText}} +
\ No newline at end of file diff --git a/platform/commonUI/general/src/controllers/BannerController.js b/platform/commonUI/general/src/controllers/BannerController.js new file mode 100644 index 0000000000..4be9304cc6 --- /dev/null +++ b/platform/commonUI/general/src/controllers/BannerController.js @@ -0,0 +1,68 @@ +/***************************************************************************** + * 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 controller for banner notifications. Banner notifications are a + * non-blocking way of drawing the user's attention to an event such + * as system errors, or the progress or successful completion of an + * ongoing task. This controller provides scoped functions for + * dismissing and 'maximizing' notifications. See {@link NotificationService} + * for more details on Notifications. + * + * @param $scope + * @param notificationService + * @param dialogService + * @constructor + */ + function BannerController($scope, notificationService, dialogService) { + $scope.active = notificationService.active; + + $scope.action = function (action, $event){ + /* + Prevents default 'maximize' behaviour when clicking on + notification button + */ + $event.stopPropagation(); + return action(); + }; + $scope.dismiss = function(notification, $event) { + $event.stopPropagation(); + notification.dismissOrMinimize(); + }; + $scope.maximize = function(notification) { + if (notification.model.severity !== "info"){ + + notification.model.cancel = function(){ + dialogService.dismiss(); + }; + dialogService.showBlockingMessage(notification.model); + } + }; + } + return BannerController; + }); \ No newline at end of file diff --git a/platform/commonUI/general/src/controllers/DateTimeFieldController.js b/platform/commonUI/general/src/controllers/DateTimeFieldController.js new file mode 100644 index 0000000000..b87268dc5a --- /dev/null +++ b/platform/commonUI/general/src/controllers/DateTimeFieldController.js @@ -0,0 +1,79 @@ +/***************************************************************************** + * 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*/ + +define( + [], + function () { + 'use strict'; + + /** + * Controller to support the date-time entry field. + * + * Accepts a `format` property in the `structure` attribute + * which allows a date/time to be specified via its symbolic + * key (as will be used to look up said format from the + * `formatService`.) + * + * {@see FormatService} + * @constructor + * @memberof platform/commonUI/general + * @param $scope the Angular scope for this controller + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormat the format to request when no + * format has been otherwise specified + */ + function DateTimeFieldController($scope, formatService, defaultFormat) { + var formatter = formatService.getFormat(defaultFormat); + + function updateFromModel(value) { + // Only reformat if the value is different from user + // input (to avoid reformatting valid input while typing.) + if (!formatter.validate($scope.textValue) || + formatter.parse($scope.textValue) !== value) { + $scope.textValue = formatter.format(value); + $scope.textInvalid = false; + } + } + + function updateFromView(textValue) { + $scope.textInvalid = !formatter.validate(textValue); + if (!$scope.textInvalid) { + $scope.ngModel[$scope.field] = + formatter.parse(textValue); + } + } + + function setFormat(format) { + formatter = formatService.getFormat(format || defaultFormat); + updateFromModel($scope.ngModel[$scope.field]); + } + + $scope.$watch('structure.format', setFormat); + $scope.$watch('ngModel[field]', updateFromModel); + $scope.$watch('textValue', updateFromView); + } + + return DateTimeFieldController; + } +); diff --git a/platform/commonUI/general/src/controllers/ObjectInspectorController.js b/platform/commonUI/general/src/controllers/ObjectInspectorController.js new file mode 100644 index 0000000000..5b04304af0 --- /dev/null +++ b/platform/commonUI/general/src/controllers/ObjectInspectorController.js @@ -0,0 +1,117 @@ +/***************************************************************************** + * 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*/ + +/** + * Module defining ObjectInspectorController. Created by shale on 08/21/2015. + */ +define( + [], + function () { + "use strict"; + + /** + * The ObjectInspectorController gets and formats the data for + * the inspector display + * + * @constructor + */ + function ObjectInspectorController($scope, objectService) { + $scope.primaryParents = []; + $scope.contextutalParents = []; + //$scope.isLink = false; + + // Gets an array of the contextual parents/anscestors of the selected object + function getContextualPath() { + var currentObj = $scope.ngModel.selectedObject, + currentParent, + parents = []; + + currentParent = currentObj && + currentObj.hasCapability('context') && + currentObj.getCapability('context').getParent(); + + while (currentParent && currentParent.getModel().type !== 'root' && + currentParent.hasCapability('context')) { + // Record this object + parents.unshift(currentParent); + + // Get the next one up the tree + currentObj = currentParent; + currentParent = currentObj.getCapability('context').getParent(); + } + + $scope.contextutalParents = parents; + } + + // Gets an array of the parents/anscestors of the selected object's + // primary location (locational of original non-link) + function getPrimaryPath(current) { + var location; + + // If this the the initial call of this recursive function + if (!current) { + current = $scope.ngModel.selectedObject; + $scope.primaryParents = []; + } + + location = current.getModel().location; + + if (location && location !== 'root') { + objectService.getObjects([location]).then(function (obj) { + var next = obj[location]; + + $scope.primaryParents.unshift(next); + getPrimaryPath(next); + }); + } + + } + + // Gets the metadata for the selected object + function getMetadata() { + $scope.metadata = $scope.ngModel.selectedObject && + $scope.ngModel.selectedObject.hasCapability('metadata') && + $scope.ngModel.selectedObject.useCapability('metadata'); + } + + // Set scope variables when the selected object changes + $scope.$watch('ngModel.selectedObject', function () { + $scope.isLink = $scope.ngModel.selectedObject && + $scope.ngModel.selectedObject.hasCapability('location') && + $scope.ngModel.selectedObject.getCapability('location').isLink(); + + if ($scope.isLink) { + getPrimaryPath(); + getContextualPath(); + } else { + $scope.primaryParents = []; + getContextualPath(); + } + + getMetadata(); + }); + } + + return ObjectInspectorController; + } +); \ No newline at end of file diff --git a/platform/commonUI/general/src/controllers/TimeRangeController.js b/platform/commonUI/general/src/controllers/TimeRangeController.js index 55bddd712f..cdcdb7f8d0 100644 --- a/platform/commonUI/general/src/controllers/TimeRangeController.js +++ b/platform/commonUI/general/src/controllers/TimeRangeController.js @@ -26,25 +26,32 @@ define( function (moment) { "use strict"; - var - DATE_FORMAT = "YYYY-MM-DD HH:mm:ss", - TICK_SPACING_PX = 150; + var TICK_SPACING_PX = 150; + /** + * Controller used by the `time-controller` template. * @memberof platform/commonUI/general * @constructor + * @param $scope the Angular scope for this controller + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormat the format to request when no + * format has been otherwise specified + * @param {Function} now a function to return current system time */ - function TimeConductorController($scope, now) { + function TimeRangeController($scope, formatService, defaultFormat, now) { var tickCount = 2, innerMinimumSpan = 1000, // 1 second outerMinimumSpan = 1000 * 60 * 60, // 1 hour - initialDragValue; + initialDragValue, + formatter = formatService.getFormat(defaultFormat); function formatTimestamp(ts) { - return moment.utc(ts).format(DATE_FORMAT); + return formatter.format(ts); } - // From 0.0-1.0 to "0%"-"1%" + // From 0.0-1.0 to "0%"-"100%" function toPercent(p) { return (100 * p) + "%"; } @@ -94,22 +101,14 @@ define( } function updateViewFromModel(ngModel) { - var t = now(); - ngModel = ngModel || {}; ngModel.outer = ngModel.outer || defaultBounds(); ngModel.inner = ngModel.inner || copyBounds(ngModel.outer); - // First, dates for the date pickers for outer bounds - $scope.startOuterDate = new Date(ngModel.outer.start); - $scope.endOuterDate = new Date(ngModel.outer.end); - - // Then various updates for the inner span - updateViewForInnerSpanFromModel(ngModel); - // Stick it back is scope (in case we just set defaults) $scope.ngModel = ngModel; + updateViewForInnerSpanFromModel(ngModel); updateTicks(); } @@ -129,7 +128,8 @@ define( } function toMillis(pixels) { - var span = $scope.ngModel.outer.end - $scope.ngModel.outer.start; + var span = + $scope.ngModel.outer.end - $scope.ngModel.outer.start; return (pixels / $scope.spanWidth) * span; } @@ -178,6 +178,8 @@ define( function updateOuterStart(t) { var ngModel = $scope.ngModel; + ngModel.outer.start = t; + ngModel.outer.end = Math.max( ngModel.outer.start + outerMinimumSpan, ngModel.outer.end @@ -190,14 +192,15 @@ define( ngModel.inner.end ); - $scope.startOuterText = formatTimestamp(t); - updateViewForInnerSpanFromModel(ngModel); + updateTicks(); } function updateOuterEnd(t) { var ngModel = $scope.ngModel; + ngModel.outer.end = t; + ngModel.outer.start = Math.min( ngModel.outer.end - outerMinimumSpan, ngModel.outer.start @@ -210,9 +213,14 @@ define( ngModel.inner.start ); - $scope.endOuterText = formatTimestamp(t); - updateViewForInnerSpanFromModel(ngModel); + updateTicks(); + } + + function updateFormat(key) { + formatter = formatService.getFormat(key || defaultFormat); + updateViewForInnerSpanFromModel($scope.ngModel); + updateTicks(); } $scope.startLeftDrag = startLeftDrag; @@ -222,7 +230,6 @@ define( $scope.rightDrag = rightDrag; $scope.middleDrag = middleDrag; - $scope.state = false; $scope.ticks = []; // Initialize scope to defaults @@ -232,8 +239,9 @@ define( $scope.$watch("spanWidth", updateSpanWidth); $scope.$watch("ngModel.outer.start", updateOuterStart); $scope.$watch("ngModel.outer.end", updateOuterEnd); + $scope.$watch("parameters.format", updateFormat); } - return TimeConductorController; + return TimeRangeController; } ); diff --git a/platform/commonUI/general/src/directives/MCTSplitPane.js b/platform/commonUI/general/src/directives/MCTSplitPane.js index abc54f772e..9abc641ebd 100644 --- a/platform/commonUI/general/src/directives/MCTSplitPane.js +++ b/platform/commonUI/general/src/directives/MCTSplitPane.js @@ -132,10 +132,10 @@ define( // Get actual size (to obey min-width etc.) firstSize = getSize(first[0]); first.css(anchor.dimension, firstSize + 'px'); - splitter.css(anchor.edge, (firstSize + splitterSize) + 'px'); + splitter.css(anchor.edge, firstSize + 'px'); splitter.css(anchor.opposite, "auto"); - last.css(anchor.edge, (firstSize + splitterSize * 3) + 'px'); + last.css(anchor.edge, (firstSize + splitterSize) + 'px'); last.css(anchor.opposite, "0px"); position = firstSize + splitterSize; @@ -178,6 +178,12 @@ define( return position; } + // Dynamically apply a CSS class to elements when the user + // is actively resizing + function toggleClass(classToToggle) { + $element.children().toggleClass(classToToggle); + } + // Make sure anchor parameter is something we know if (!ANCHORS[anchorKey]) { $log.warn(ANCHOR_WARNING_MESSAGE); @@ -208,6 +214,7 @@ define( // Interface exposed by controller, for mct-splitter to user return { position: getSetPosition, + toggleClass: toggleClass, anchor: function () { return anchor; } diff --git a/platform/commonUI/general/src/directives/MCTSplitter.js b/platform/commonUI/general/src/directives/MCTSplitter.js index c163c107e0..ad8f809c65 100644 --- a/platform/commonUI/general/src/directives/MCTSplitter.js +++ b/platform/commonUI/general/src/directives/MCTSplitter.js @@ -29,7 +29,8 @@ define( // Pixel width to allocate for the splitter itself var SPLITTER_TEMPLATE = "
", + "mct-drag=\"splitter.move(delta)\" " + + "mct-drag-up=\"splitter.endMove()\">
", OFFSETS_BY_EDGE = { left: "offsetLeft", right: "offsetRight", @@ -53,6 +54,7 @@ define( startMove: function () { var splitter = element[0]; initialPosition = mctSplitPane.position(); + mctSplitPane.toggleClass('resizing'); }, // Handle user changes to splitter position move: function (delta) { @@ -63,6 +65,11 @@ define( // Update the position of this splitter mctSplitPane.position(initialPosition + pixelDelta); + }, + // Grab the event when the user is done moving + // the splitter and pass it on + endMove: function() { + mctSplitPane.toggleClass('resizing'); } }; } diff --git a/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js new file mode 100644 index 0000000000..8f516ece5d --- /dev/null +++ b/platform/commonUI/general/test/controllers/DateTimeFieldControllerSpec.js @@ -0,0 +1,183 @@ +/***************************************************************************** + * 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/controllers/DateTimeFieldController", "moment"], + function (DateTimeFieldController, moment) { + 'use strict'; + + var TEST_FORMAT = "YYYY-MM-DD HH:mm:ss"; + + describe("The DateTimeFieldController", function () { + var mockScope, + mockFormatService, + mockFormat, + controller; + + function fireWatch(expr, value) { + mockScope.$watch.calls.forEach(function (call) { + if (call.args[0] === expr) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockScope = jasmine.createSpyObj('$scope', ['$watch']); + mockFormatService = + jasmine.createSpyObj('formatService', ['getFormat']); + mockFormat = jasmine.createSpyObj('format', [ + 'parse', + 'validate', + 'format' + ]); + + mockFormatService.getFormat.andReturn(mockFormat); + + mockFormat.validate.andCallFake(function (text) { + return moment.utc(text, TEST_FORMAT).isValid(); + }); + mockFormat.parse.andCallFake(function (text) { + return moment.utc(text, TEST_FORMAT).valueOf(); + }); + mockFormat.format.andCallFake(function (value) { + return moment.utc(value).format(TEST_FORMAT); + }); + + mockScope.ngModel = { testField: 12321 }; + mockScope.field = "testField"; + mockScope.structure = { format: "someFormat" }; + + 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(); + }); + + it("updates text from model values", function () { + var testTime = mockFormat.parse("1977-05-25 17:30:00"); + mockScope.ngModel.testField = testTime; + fireWatch("ngModel[field]", testTime); + expect(mockScope.textValue).toEqual("1977-05-25 17:30:00"); + }); + + describe("when user input is invalid", function () { + var newText, oldValue; + + beforeEach(function () { + newText = "Not a date"; + oldValue = mockScope.ngModel.testField; + mockScope.textValue = newText; + fireWatch("textValue", newText); + }); + + it("displays error state", function () { + expect(mockScope.textInvalid).toBeTruthy(); + }); + + it("does not modify model state", function () { + expect(mockScope.ngModel.testField).toEqual(oldValue); + }); + + it("does not modify user input", function () { + expect(mockScope.textValue).toEqual(newText); + }); + }); + + it("does not modify valid but irregular user input", function () { + // Don't want the controller "fixing" bad or + // irregularly-formatted input out from under + // the user's fingertips. + var newText = "2015-3-3 01:02:04", + oldValue = mockScope.ngModel.testField; + + mockFormat.validate.andReturn(true); + mockFormat.parse.andReturn(42); + mockScope.textValue = newText; + fireWatch("textValue", newText); + + expect(mockScope.textValue).toEqual(newText); + expect(mockScope.ngModel.testField).toEqual(42); + expect(mockScope.ngModel.testField).not.toEqual(oldValue); + }); + + it("obtains a format from the format service", function () { + fireWatch('structure.format', mockScope.structure.format); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith(mockScope.structure.format); + }); + + it("throws an error for unknown formats", function () { + mockFormatService.getFormat.andReturn(undefined); + expect(function () { + fireWatch("structure.format", "some-format"); + }).toThrow(); + }); + + describe("using the obtained format", function () { + var testValue = 1234321, + testText = "some text"; + + beforeEach(function () { + mockFormat.validate.andReturn(true); + mockFormat.parse.andReturn(testValue); + mockFormat.format.andReturn(testText); + }); + + it("parses user input", function () { + var newText = "some other new text"; + mockScope.textValue = newText; + fireWatch("textValue", newText); + expect(mockFormat.parse).toHaveBeenCalledWith(newText); + expect(mockScope.ngModel.testField).toEqual(testValue); + }); + + it("validates user input", function () { + var newText = "some other new text"; + mockScope.textValue = newText; + fireWatch("textValue", newText); + expect(mockFormat.validate).toHaveBeenCalledWith(newText); + }); + + it("formats model data for display", function () { + var newValue = 42; + mockScope.ngModel.testField = newValue; + fireWatch("ngModel[field]", newValue); + expect(mockFormat.format).toHaveBeenCalledWith(newValue); + expect(mockScope.textValue).toEqual(testText); + }); + }); + + }); + } +); diff --git a/platform/commonUI/general/test/controllers/ObjectInspectorControllerSpec.js b/platform/commonUI/general/test/controllers/ObjectInspectorControllerSpec.js new file mode 100644 index 0000000000..496467ea2d --- /dev/null +++ b/platform/commonUI/general/test/controllers/ObjectInspectorControllerSpec.js @@ -0,0 +1,112 @@ +/***************************************************************************** + * 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*/ + +/** + * Created by shale on 08/24/2015. + */ +define( + ["../../src/controllers/ObjectInspectorController"], + function (ObjectInspectorController) { + "use strict"; + + describe("The object inspector controller ", function () { + var mockScope, + mockObjectService, + mockPromise, + mockDomainObject, + mockContextCapability, + mockLocationCapability, + controller; + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + "$scope", + [ "$watch" ] + ); + mockScope.ngModel = {}; + mockScope.ngModel.selectedObject = 'mock selected object'; + + mockObjectService = jasmine.createSpyObj( + "objectService", + [ "getObjects" ] + ); + mockPromise = jasmine.createSpyObj( + "promise", + [ "then" ] + ); + mockObjectService.getObjects.andReturn(mockPromise); + + mockDomainObject = jasmine.createSpyObj( + "selectedObject", + [ "hasCapability", "getCapability", "useCapability", "getModel" ] + ); + mockDomainObject.getModel.andReturn({location: 'somewhere'}); + mockDomainObject.hasCapability.andReturn(true); + + mockContextCapability = jasmine.createSpyObj( + "context capability", + [ "getParent" ] + ); + mockLocationCapability = jasmine.createSpyObj( + "location capability", + [ "isLink" ] + ); + mockDomainObject.getCapability.andCallFake(function (param) { + if (param === 'location') { + return mockLocationCapability; + } else if (param === 'context') { + return mockContextCapability; + } + }); + + controller = new ObjectInspectorController(mockScope, mockObjectService); + + // Change the selected object to trigger the watch call + mockScope.ngModel.selectedObject = mockDomainObject; + }); + + it("watches for changes to the selected object", function () { + expect(mockScope.$watch).toHaveBeenCalledWith('ngModel.selectedObject', jasmine.any(Function)); + }); + + it("looks for contextual parent objects", function () { + mockScope.$watch.mostRecentCall.args[1](); + expect(mockContextCapability.getParent).toHaveBeenCalled(); + }); + + it("if link, looks for primary parent objects", function () { + mockLocationCapability.isLink.andReturn(true); + + mockScope.$watch.mostRecentCall.args[1](); + expect(mockDomainObject.getModel).toHaveBeenCalled(); + expect(mockObjectService.getObjects).toHaveBeenCalled(); + mockPromise.then.mostRecentCall.args[0]({'somewhere': mockDomainObject}); + }); + + it("gets metadata", function () { + mockScope.$watch.mostRecentCall.args[1](); + expect(mockDomainObject.useCapability).toHaveBeenCalledWith('metadata'); + }); + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js index 9d7a6a9f52..85e77e4889 100644 --- a/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js +++ b/platform/commonUI/general/test/controllers/TimeRangeControllerSpec.js @@ -22,8 +22,8 @@ /*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ define( - ["../../src/controllers/TimeRangeController"], - function (TimeRangeController) { + ["../../src/controllers/TimeRangeController", "moment"], + function (TimeRangeController, moment) { "use strict"; var SEC = 1000, @@ -33,7 +33,10 @@ define( describe("The TimeRangeController", function () { var mockScope, + mockFormatService, + testDefaultFormat, mockNow, + mockFormat, controller; function fireWatch(expr, value) { @@ -57,8 +60,30 @@ define( "$scope", [ "$apply", "$watch", "$watchCollection" ] ); + mockFormatService = jasmine.createSpyObj( + "formatService", + [ "getFormat" ] + ); + testDefaultFormat = 'utc'; + mockFormat = jasmine.createSpyObj( + "format", + [ "validate", "format", "parse" ] + ); + + mockFormatService.getFormat.andReturn(mockFormat); + + mockFormat.format.andCallFake(function (value) { + return moment.utc(value).format("YYYY-MM-DD HH:mm:ss"); + }); + mockNow = jasmine.createSpy('now'); - controller = new TimeRangeController(mockScope, mockNow); + + controller = new TimeRangeController( + mockScope, + mockFormatService, + testDefaultFormat, + mockNow + ); }); it("watches the model that was passed in", function () { @@ -166,6 +191,22 @@ define( expect(mockScope.ngModel.inner.end) .toBeGreaterThan(mockScope.ngModel.inner.start); }); + + }); + + it("watches for changes in format selection", function () { + expect(mockFormatService.getFormat) + .not.toHaveBeenCalledWith('test-format'); + fireWatch("parameters.format", 'test-format'); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith('test-format'); + }); + + it("throws an error for unknown formats", function () { + mockFormatService.getFormat.andReturn(undefined); + expect(function () { + fireWatch("parameters.format", "some-format"); + }).toThrow(); }); }); diff --git a/platform/commonUI/general/test/suite.json b/platform/commonUI/general/test/suite.json index 0d19fbb9e4..6b89f83d61 100644 --- a/platform/commonUI/general/test/suite.json +++ b/platform/commonUI/general/test/suite.json @@ -3,8 +3,10 @@ "controllers/BottomBarController", "controllers/ClickAwayController", "controllers/ContextMenuController", + "controllers/DateTimeFieldController", "controllers/DateTimePickerController", "controllers/GetterSetterController", + "controllers/ObjectInspectorController", "controllers/SelectorController", "controllers/SplitPaneController", "controllers/TimeRangeController", diff --git a/platform/commonUI/inspect/src/gestures/InfoGesture.js b/platform/commonUI/inspect/src/gestures/InfoGesture.js index 09740841e4..688a27cb6c 100644 --- a/platform/commonUI/inspect/src/gestures/InfoGesture.js +++ b/platform/commonUI/inspect/src/gestures/InfoGesture.js @@ -55,11 +55,6 @@ define( self.trackPosition(event); }; - // Also make sure we dismiss bubble if representation is destroyed - // before the mouse actually leaves it - this.scopeOff = - element.scope().$on('$destroy', this.hideBubbleCallback); - this.element = element; this.$timeout = $timeout; this.infoService = infoService; @@ -143,7 +138,6 @@ define( this.hideBubble(); // ...and detach listeners this.element.off('mouseenter', this.showBubbleCallback); - this.scopeOff(); }; return InfoGesture; diff --git a/platform/commonUI/notification/bundle.json b/platform/commonUI/notification/bundle.json new file mode 100644 index 0000000000..4851dd28b6 --- /dev/null +++ b/platform/commonUI/notification/bundle.json @@ -0,0 +1,45 @@ +{ + "extensions": { + "constants": [ + { + "key": "DEFAULT_AUTO_DISMISS", + "value": 3000 + }, + { + "key": "FORCE_AUTO_DISMISS", + "value": 1000 + }, + { + "key": "MINIMIZE_TIMEOUT", + "value": 300 + } + ], + "templates": [ + { + "key":"notificationIndicatorTemplate", + "templateUrl": "notification-indicator.html" + } + ], + "controllers": [ + { + "key": "NotificationIndicatorController", + "implementation": "NotificationIndicatorController.js", + "depends": ["$scope", "notificationService", "dialogService"] + } + ], + "indicators": [ + { + "implementation": "NotificationIndicator.js", + "priority": "fallback" + } + ], + "services": [ + { + "key": "notificationService", + "implementation": "NotificationService.js", + "depends": [ "$timeout", "DEFAULT_AUTO_DISMISS", + "MINIMIZE_TIMEOUT" ] + } + ] + } +} diff --git a/platform/commonUI/notification/res/notification-indicator.html b/platform/commonUI/notification/res/notification-indicator.html new file mode 100644 index 0000000000..9c7e80a639 --- /dev/null +++ b/platform/commonUI/notification/res/notification-indicator.html @@ -0,0 +1,10 @@ + + + + {{notifications.length}} + Notifications + + {{notifications.length}} + \ No newline at end of file diff --git a/platform/commonUI/general/res/sass/_properties.scss b/platform/commonUI/notification/src/NotificationIndicator.js similarity index 81% rename from platform/commonUI/general/res/sass/_properties.scss rename to platform/commonUI/notification/src/NotificationIndicator.js index b48fe66406..29a831d251 100644 --- a/platform/commonUI/general/res/sass/_properties.scss +++ b/platform/commonUI/notification/src/NotificationIndicator.js @@ -19,17 +19,17 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/* Classes to be used for lists of properties and values */ +/*global define,window*/ -.properties { - .s-row { - border-top: 1px solid $colorInteriorBorder; - font-size: 0.8em; - &:first-child { - border: none; - } - .s-value { - color: #fff; - } +define( + [], + function () { + "use strict"; + + function NotificationIndicator() {} + + NotificationIndicator.template = 'notificationIndicatorTemplate'; + + return NotificationIndicator; } -} \ No newline at end of file +); diff --git a/platform/commonUI/notification/src/NotificationIndicatorController.js b/platform/commonUI/notification/src/NotificationIndicatorController.js new file mode 100644 index 0000000000..e58b0a606b --- /dev/null +++ b/platform/commonUI/notification/src/NotificationIndicatorController.js @@ -0,0 +1,67 @@ +/***************************************************************************** + * 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"; + + /** + * Provides an indicator that is visible when there are + * banner notifications that have been minimized. Will also indicate + * the number of notifications. Notifications can be viewed by + * clicking on the indicator to launch a dialog showing a list of + * notifications. + * @param $scope + * @param notificationService + * @param dialogService + * @constructor + */ + function NotificationIndicatorController($scope, notificationService, dialogService) { + $scope.notifications = notificationService.notifications; + $scope.highest = notificationService.highest; + + /** + * Launch a dialog showing a list of current notifications. + */ + $scope.showNotificationsList = function(){ + dialogService.getDialogResponse('overlay-message-list', { + dialog: { + title: "Messages", + //Launch the message list dialog with the models + // from the notifications + messages: notificationService.notifications && notificationService.notifications.map(function(notification){ + return notification.model; + }) + }, + cancel: function(){ + dialogService.dismiss(); + } + }); + + }; + } + return NotificationIndicatorController; + } +); + diff --git a/platform/commonUI/notification/src/NotificationService.js b/platform/commonUI/notification/src/NotificationService.js new file mode 100644 index 0000000000..568d31beb5 --- /dev/null +++ b/platform/commonUI/notification/src/NotificationService.js @@ -0,0 +1,387 @@ +/***************************************************************************** + * 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 implements the notification service, which can be used to + * show banner notifications to the user. Banner notifications + * are used to inform users of events in a non-intrusive way. As + * much as possible, notifications share a model with blocking + * dialogs so that the same information can be provided in a dialog + * and then minimized to a banner notification if needed. + * + * @namespace platform/commonUI/notification + */ +define( + [], + function () { + "use strict"; + + /** + * A representation of a user action. Options are provided to + * dialogs and notifications and are shown as buttons. + * + * @typedef {object} NotificationOption + * @property {string} label the label to appear on the button for + * this action + * @property {function} callback a callback function to be invoked + * when the button is clicked + */ + + /** + * A representation of a banner notification. Banner notifications + * are used to inform users of events in a non-intrusive way. As + * much as possible, notifications share a model with blocking + * dialogs so that the same information can be provided in a dialog + * and then minimized to a banner notification if needed, or vice-versa. + * + * @typedef {object} NotificationModel + * @property {string} title The title of the message + * @property {string} severity The importance of the message (one of + * 'info', 'alert', or 'error' where info < alert
\ No newline at end of file + +
diff --git a/platform/search/src/controllers/SearchController.js b/platform/search/src/controllers/SearchController.js index 10cf056b4f..629e495331 100644 --- a/platform/search/src/controllers/SearchController.js +++ b/platform/search/src/controllers/SearchController.js @@ -26,146 +26,155 @@ */ define(function () { "use strict"; - - var INITIAL_LOAD_NUMBER = 20, - LOAD_INCREMENT = 20; - + + /** + * Controller for search in Tree View. + * + * Filtering is currently buggy; it filters after receiving results from + * search providers, the downside of this is that it requires search + * providers to provide objects for all possible results, which is + * potentially a hit to persistence, thus can be very very slow. + * + * Ideally, filtering should be handled before loading objects from the persistence + * store, the downside to this is that filters must be applied to object + * models, not object instances. + * + * @constructor + * @param $scope + * @param searchService + */ function SearchController($scope, searchService) { - // numResults is the amount of results to display. Will get increased. - // fullResults holds the most recent complete searchService response object - var numResults = INITIAL_LOAD_NUMBER, - fullResults = {hits: []}; - - // Scope variables are: - // Variables used only in SearchController: - // results, an array of searchResult objects - // loading, whether search() is loading - // ngModel.input, the text of the search query - // ngModel.search, a boolean of whether to display search or the tree - // Variables used also in SearchMenuController: - // ngModel.filter, the function filter defined below - // ngModel.types, an array of type objects - // ngModel.checked, a dictionary of which type filter options are checked - // ngModel.checkAll, a boolean of whether to search all types - // ngModel.filtersString, a string list of what filters on the results are active - $scope.results = []; - $scope.loading = false; - - - // Filters searchResult objects by type. Allows types that are - // checked. (ngModel.checked['typekey'] === true) - // If hits is not provided, will use fullResults.hits - function filter(hits) { - var newResults = [], - i = 0; - - if (!hits) { - hits = fullResults.hits; - } - - // If checkAll is checked, search everything no matter what the other - // checkboxes' statuses are. Otherwise filter the search by types. - if ($scope.ngModel.checkAll) { - newResults = fullResults.hits.slice(0, numResults); - } else { - while (newResults.length < numResults && i < hits.length) { - // If this is of an acceptable type, add it to the list - if ($scope.ngModel.checked[hits[i].object.getModel().type]) { - newResults.push(fullResults.hits[i]); - } - i += 1; - } - } - - $scope.results = newResults; - return newResults; - } - - // Make function accessible from SearchMenuController - $scope.ngModel.filter = filter; - - // For documentation, see search below - function search(maxResults) { - var inputText = $scope.ngModel.input; - - if (inputText !== '' && inputText !== undefined) { - // We are starting to load. - $scope.loading = true; - - // Update whether the file tree should be displayed - // Hide tree only when starting search - $scope.ngModel.search = true; - } - - if (!maxResults) { - // Reset 'load more' - numResults = INITIAL_LOAD_NUMBER; - } - - // Send the query - searchService.query(inputText, maxResults).then(function (result) { - // Store all the results before splicing off the front, so that - // we can load more to display later. - fullResults = result; - $scope.results = filter(result.hits); - - // Update whether the file tree should be displayed - // Reveal tree only when finishing search - if (inputText === '' || inputText === undefined) { - $scope.ngModel.search = false; - } - - // Now we are done loading. - $scope.loading = false; - }); - } - - return { - /** - * Search the filetree. Assumes that any search text will - * be in ngModel.input - * - * @param maxResults (optional) The maximum number of results - * that this function should return. If not provided, search - * service default will be used. - */ - search: search, - - /** - * Checks to see if there are more search results to display. If the answer is - * unclear, this function will err toward saying that there are more results. - */ - areMore: function () { - var i; - - // Check to see if any of the not displayed results are of an allowed type - for (i = numResults; i < fullResults.hits.length; i += 1) { - if ($scope.ngModel.checkAll || $scope.ngModel.checked[fullResults.hits[i].object.getModel().type]) { - return true; - } - } - - // If none of the ones at hand are correct, there still may be more if we - // re-search with a larger maxResults - return fullResults.hits.length < fullResults.total; - }, - - /** - * Increases the number of search results to display, and then - * loads them, adding to the displayed results. - */ - loadMore: function () { - numResults += LOAD_INCREMENT; - - if (numResults > fullResults.hits.length && fullResults.hits.length < fullResults.total) { - // Resend the query if we are out of items to display, but there are more to get - search(numResults); - } else { - // Otherwise just take from what we already have - $scope.results = filter(fullResults.hits); - } - } + var controller = this; + this.$scope = $scope; + this.searchService = searchService; + this.numberToDisplay = this.RESULTS_PER_PAGE; + this.availabileResults = 0; + this.$scope.results = []; + this.$scope.loading = false; + this.pendingQuery = undefined; + this.$scope.ngModel.filter = function () { + return controller.onFilterChange.apply(controller, arguments); }; } + + SearchController.prototype.RESULTS_PER_PAGE = 20; + + /** + * Returns true if there are more results than currently displayed for the + * for the current query and filters. + */ + SearchController.prototype.areMore = function () { + return this.$scope.results.length < this.availableResults; + }; + + /** + * Display more results for the currently displayed query and filters. + */ + SearchController.prototype.loadMore = function () { + this.numberToDisplay += this.RESULTS_PER_PAGE; + this.dispatchSearch(); + }; + + /** + * Reset search results, then search for the query string specified in + * scope. + */ + SearchController.prototype.search = function () { + var inputText = this.$scope.ngModel.input; + + this.clearResults(); + + if (inputText) { + this.$scope.loading = true; + this.$scope.ngModel.search = true; + } else { + this.pendingQuery = undefined; + this.$scope.ngModel.search = false; + this.$scope.loading = false; + return; + } + + this.dispatchSearch(); + }; + + /** + * Dispatch a search to the search service if it hasn't already been + * dispatched. + * + * @private + */ + SearchController.prototype.dispatchSearch = function () { + var inputText = this.$scope.ngModel.input, + controller = this, + queryId = inputText + this.numberToDisplay; + + if (this.pendingQuery === queryId) { + return; // don't issue multiple queries for the same term. + } + + this.pendingQuery = queryId; + + this + .searchService + .query(inputText, this.numberToDisplay, this.filterPredicate()) + .then(function (results) { + if (controller.pendingQuery !== queryId) { + return; // another query in progress, so skip this one. + } + controller.onSearchComplete(results); + }); + }; + + SearchController.prototype.filter = SearchController.prototype.onFilterChange; + + /** + * Refilter results and update visible results when filters have changed. + */ + SearchController.prototype.onFilterChange = function () { + this.pendingQuery = undefined; + this.search(); + }; + + /** + * Returns a predicate function that can be used to filter object models. + * + * @private + */ + SearchController.prototype.filterPredicate = function () { + if (this.$scope.ngModel.checkAll) { + return function () { + return true; + }; + } + var includeTypes = this.$scope.ngModel.checked; + return function (model) { + return !!includeTypes[model.type]; + }; + }; + + /** + * Clear the search results. + * + * @private + */ + SearchController.prototype.clearResults = function () { + this.$scope.results = []; + this.availableResults = 0; + this.numberToDisplay = this.RESULTS_PER_PAGE; + }; + + + /** + * Update search results from given `results`. + * + * @private + */ + SearchController.prototype.onSearchComplete = function (results) { + this.availableResults = results.total; + this.$scope.results = results.hits; + this.$scope.loading = false; + this.pendingQuery = undefined; + }; + return SearchController; }); diff --git a/platform/search/src/services/GenericSearchProvider.js b/platform/search/src/services/GenericSearchProvider.js index 9574d156fb..71dfe8c0ed 100644 --- a/platform/search/src/services/GenericSearchProvider.js +++ b/platform/search/src/services/GenericSearchProvider.js @@ -19,234 +19,262 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define*/ +/*global define,setTimeout*/ /** * Module defining GenericSearchProvider. Created by shale on 07/16/2015. */ -define( - [], - function () { - "use strict"; +define([ - var DEFAULT_MAX_RESULTS = 100, - DEFAULT_TIMEOUT = 1000, - MAX_CONCURRENT_REQUESTS = 100, - FLUSH_INTERVAL = 0, - stopTime; +], function ( - /** - * A search service which searches through domain objects in - * the filetree without using external search implementations. - * - * @constructor - * @param $q Angular's $q, for promise consolidation. - * @param $log Anglar's $log, for logging. - * @param {Function} throttle a function to throttle function invocations - * @param {ObjectService} objectService The service from which - * domain objects can be gotten. - * @param {WorkerService} workerService The service which allows - * more easy creation of web workers. - * @param {GENERIC_SEARCH_ROOTS} ROOTS An array of the root - * domain objects' IDs. - */ - function GenericSearchProvider($q, $log, throttle, objectService, workerService, topic, ROOTS) { - var indexed = {}, - pendingIndex = {}, - pendingQueries = {}, - toRequest = [], - worker = workerService.run('genericSearchWorker'), - mutationTopic = topic("mutation"), - indexingStarted = Date.now(), - pendingRequests = 0, - scheduleFlush; +) { + "use strict"; - this.worker = worker; - this.pendingQueries = pendingQueries; - this.$q = $q; - // pendingQueries is a dictionary with the key value pairs st - // the key is the timestamp and the value is the promise + /** + * A search service which searches through domain objects in + * the filetree without using external search implementations. + * + * @constructor + * @param $q Angular's $q, for promise consolidation. + * @param $log Anglar's $log, for logging. + * @param {ModelService} modelService the model service. + * @param {WorkerService} workerService the workerService. + * @param {TopicService} topic the topic service. + * @param {Array} ROOTS An array of object Ids to begin indexing. + */ + function GenericSearchProvider($q, $log, modelService, workerService, topic, ROOTS) { + var provider = this; + this.$q = $q; + this.$log = $log; + this.modelService = modelService; - function scheduleIdsForIndexing(ids) { - ids.forEach(function (id) { - if (!indexed[id] && !pendingIndex[id]) { - indexed[id] = true; - pendingIndex[id] = true; - toRequest.push(id); - } - }); - scheduleFlush(); - } + this.indexedIds = {}; + this.idsToIndex = []; + this.pendingIndex = {}; + this.pendingRequests = 0; - // Tell the web worker to add a domain object's model to its list of items. - function indexItem(domainObject) { - var model = domainObject.getModel(); + this.pendingQueries = {}; - worker.postMessage({ - request: 'index', - model: model, - id: domainObject.getId() - }); + this.worker = this.startWorker(workerService); + this.indexOnMutation(topic); - if (Array.isArray(model.composition)) { - scheduleIdsForIndexing(model.composition); - } - } + ROOTS.forEach(function indexRoot(rootId) { + provider.scheduleForIndexing(rootId); + }); - // Handles responses from the web worker. Namely, the results of - // a search request. - function handleResponse(event) { - var ids = [], - id; - // If we have the results from a search - if (event.data.request === 'search') { - // Convert the ids given from the web worker into domain objects - for (id in event.data.results) { - ids.push(id); - } - objectService.getObjects(ids).then(function (objects) { - var searchResults = [], - id; + } - // Create searchResult objects - for (id in objects) { - searchResults.push({ - object: objects[id], - id: id, - score: event.data.results[id] - }); - } + /** + * Maximum number of concurrent index requests to allow. + */ + GenericSearchProvider.prototype.MAX_CONCURRENT_REQUESTS = 100; - // Resove the promise corresponding to this - pendingQueries[event.data.timestamp].resolve({ - hits: searchResults, - total: event.data.total, - timedOut: event.data.timedOut - }); - }); - } - } + /** + * Query the search provider for results. + * + * @param {String} input the string to search by. + * @param {Number} maxResults max number of results to return. + * @returns {Promise} a promise for a modelResults object. + */ + GenericSearchProvider.prototype.query = function ( + input, + maxResults + ) { - function requestAndIndex(id) { - pendingRequests += 1; - objectService.getObjects([id]).then(function (objects) { - delete pendingIndex[id]; - if (objects[id]) { - indexItem(objects[id]); - } - }, function () { - $log.warn("Failed to index domain object " + id); - }).then(function () { - pendingRequests -= 1; - scheduleFlush(); - }); - } + var queryId = this.dispatchSearch(input, maxResults), + pendingQuery = this.$q.defer(); - scheduleFlush = throttle(function flush() { - var batchSize = - Math.max(MAX_CONCURRENT_REQUESTS - pendingRequests, 0); + this.pendingQueries[queryId] = pendingQuery; - if (toRequest.length + pendingRequests < 1) { - $log.info([ - 'GenericSearch finished indexing after ', - ((Date.now() - indexingStarted) / 1000).toFixed(2), - ' seconds.' - ].join('')); - } else { - toRequest.splice(-batchSize, batchSize) - .forEach(requestAndIndex); - } - }, FLUSH_INTERVAL); + return pendingQuery.promise; + }; - worker.onmessage = handleResponse; + /** + * Creates a search worker and attaches handlers. + * + * @private + * @param workerService + * @returns worker the created search worker. + */ + GenericSearchProvider.prototype.startWorker = function (workerService) { + var worker = workerService.run('genericSearchWorker'), + provider = this; - // Index the tree's contents once at the beginning - scheduleIdsForIndexing(ROOTS); + worker.addEventListener('message', function (messageEvent) { + provider.onWorkerMessage(messageEvent); + }); - // Re-index items when they are mutated - mutationTopic.listen(function (domainObject) { - var id = domainObject.getId(); - indexed[id] = false; - scheduleIdsForIndexing([id]); + return worker; + }; + + /** + * Listen to the mutation topic and re-index objects when they are + * mutated. + * + * @private + * @param topic the topicService. + */ + GenericSearchProvider.prototype.indexOnMutation = function (topic) { + var mutationTopic = topic('mutation'), + provider = this; + + mutationTopic.listen(function (mutatedObject) { + var id = mutatedObject.getId(); + provider.indexedIds[id] = false; + provider.scheduleForIndexing(id); + }); + }; + + /** + * Schedule an id to be indexed at a later date. If there are less + * pending requests then allowed, will kick off an indexing request. + * + * @private + * @param {String} id to be indexed. + */ + GenericSearchProvider.prototype.scheduleForIndexing = function (id) { + if (!this.indexedIds[id] && !this.pendingIndex[id]) { + this.indexedIds[id] = true; + this.pendingIndex[id] = true; + this.idsToIndex.push(id); + } + this.keepIndexing(); + }; + + /** + * If there are less pending requests than concurrent requests, keep + * firing requests. + * + * @private + */ + GenericSearchProvider.prototype.keepIndexing = function () { + while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS && + this.idsToIndex.length + ) { + this.beginIndexRequest(); + } + }; + + /** + * Pass an id and model to the worker to be indexed. If the model has + * composition, schedule those ids for later indexing. + * + * @private + * @param id a model id + * @param model a model + */ + GenericSearchProvider.prototype.index = function (id, model) { + var provider = this; + + this.worker.postMessage({ + request: 'index', + model: model, + id: id + }); + + if (Array.isArray(model.composition)) { + model.composition.forEach(function (id) { + provider.scheduleForIndexing(id); }); } + }; - /** - * Searches through the filetree for domain objects which match - * the search term. This function is to be used as a fallback - * in the case where other search services are not avaliable. - * Returns a promise for a result object that has the format - * {hits: searchResult[], total: number, timedOut: boolean} - * where a searchResult has the format - * {id: string, object: domainObject, score: number} - * - * Notes: - * * The order of the results is not guarenteed. - * * A domain object qualifies as a match for a search input if - * the object's name property contains any of the search terms - * (which are generated by splitting the input at spaces). - * * Scores are higher for matches that have more of the terms - * as substrings. - * - * @param input The text input that is the query. - * @param timestamp The time at which this function was called. - * This timestamp is used as a unique identifier for this - * query and the corresponding results. - * @param maxResults (optional) The maximum number of results - * that this function should return. - * @param timeout (optional) The time after which the search should - * stop calculations and return partial results. - */ - GenericSearchProvider.prototype.query = function query(input, timestamp, maxResults, timeout) { - var terms = [], - searchResults = [], - pendingQueries = this.pendingQueries, - worker = this.worker, - defer = this.$q.defer(); + /** + * Pulls an id from the indexing queue, loads it from the model service, + * and indexes it. Upon completion, tells the provider to keep + * indexing. + * + * @private + */ + GenericSearchProvider.prototype.beginIndexRequest = function () { + var idToIndex = this.idsToIndex.shift(), + provider = this; - // Tell the worker to search for items it has that match this searchInput. - // Takes the searchInput, as well as a max number of results (will return - // less than that if there are fewer matches). - function workerSearch(searchInput, maxResults, timestamp, timeout) { - var message = { - request: 'search', - input: searchInput, - maxNumber: maxResults, - timestamp: timestamp, - timeout: timeout - }; - worker.postMessage(message); - } - - // If the input is nonempty, do a search - if (input !== '' && input !== undefined) { - - // Allow us to access this promise later to resolve it later - pendingQueries[timestamp] = defer; - - // Check to see if the user provided a maximum - // number of results to display - if (!maxResults) { - // Else, we provide a default value - maxResults = DEFAULT_MAX_RESULTS; - } - // Similarly, check if timeout was provided - if (!timeout) { - timeout = DEFAULT_TIMEOUT; + this.pendingRequests += 1; + this.modelService + .getModels([idToIndex]) + .then(function (models) { + delete provider.pendingIndex[idToIndex]; + if (models[idToIndex]) { + provider.index(idToIndex, models[idToIndex]); } + }, function () { + provider + .$log + .warn('Failed to index domain object ' + idToIndex); + }) + .then(function () { + setTimeout(function () { + provider.pendingRequests -= 1; + provider.keepIndexing(); + }, 0); + }); + }; - // Send the query to the worker - workerSearch(input, maxResults, timestamp, timeout); + /** + * Handle messages from the worker. Only really knows how to handle search + * results, which are parsed, transformed into a modelResult object, which + * is used to resolve the corresponding promise. + * @private + */ + GenericSearchProvider.prototype.onWorkerMessage = function (event) { + if (event.data.request !== 'search') { + return; + } - return defer.promise; - } else { - // Otherwise return an empty result - return { hits: [], total: 0 }; - } - }; + var pendingQuery = this.pendingQueries[event.data.queryId], + modelResults = { + total: event.data.total + }; + + modelResults.hits = event.data.results.map(function (hit) { + return { + id: hit.item.id, + model: hit.item.model, + score: hit.matchCount + }; + }); + + pendingQuery.resolve(modelResults); + delete this.pendingQueries[event.data.queryId]; + }; + + /** + * @private + * @returns {Number} a unique, unusued query Id. + */ + GenericSearchProvider.prototype.makeQueryId = function () { + var queryId = Math.ceil(Math.random() * 100000); + while (this.pendingQueries[queryId]) { + queryId = Math.ceil(Math.random() * 100000); + } + return queryId; + }; + + /** + * Dispatch a search query to the worker and return a queryId. + * + * @private + * @returns {Number} a unique query Id for the query. + */ + GenericSearchProvider.prototype.dispatchSearch = function ( + searchInput, + maxResults + ) { + var queryId = this.makeQueryId(); + + this.worker.postMessage({ + request: 'search', + input: searchInput, + maxResults: maxResults, + queryId: queryId + }); + + return queryId; + }; - return GenericSearchProvider; - } -); + return GenericSearchProvider; +}); diff --git a/platform/search/src/services/GenericSearchWorker.js b/platform/search/src/services/GenericSearchWorker.js index 57be98b423..928f66cab8 100644 --- a/platform/search/src/services/GenericSearchWorker.js +++ b/platform/search/src/services/GenericSearchWorker.js @@ -26,133 +26,132 @@ */ (function () { "use strict"; - + // An array of objects composed of domain object IDs and models // {id: domainObject's ID, model: domainObject's model} - var indexedItems = []; - - // Helper function for serach() - function convertToTerms(input) { - var terms = input; - // Shave any spaces off of the ends of the input - while (terms.substr(0, 1) === ' ') { - terms = terms.substring(1, terms.length); - } - while (terms.substr(terms.length - 1, 1) === ' ') { - terms = terms.substring(0, terms.length - 1); - } - - // Then split it at spaces and asterisks - terms = terms.split(/ |\*/); - - // Remove any empty strings from the terms - while (terms.indexOf('') !== -1) { - terms.splice(terms.indexOf(''), 1); - } - - return terms; + var indexedItems = [], + TERM_SPLITTER = /[ _\*]/; + + function indexItem(id, model) { + var vector = { + name: model.name + }; + vector.cleanName = model.name.trim(); + vector.lowerCaseName = vector.cleanName.toLocaleLowerCase(); + vector.terms = vector.lowerCaseName.split(TERM_SPLITTER); + + indexedItems.push({ + id: id, + vector: vector, + model: model + }); } - + // Helper function for search() - function scoreItem(item, input, terms) { - var name = item.model.name.toLocaleLowerCase(), - weight = 0.65, - score = 0.0, - i; - - // Make the score really big if the item name and - // the original search input are the same - if (name === input) { - score = 42; - } - - for (i = 0; i < terms.length; i += 1) { - // Increase the score if the term is in the item name - if (name.indexOf(terms[i]) !== -1) { - score += 1; - - // Add extra to the score if the search term exists - // as its own term within the items - if (name.split(' ').indexOf(terms[i]) !== -1) { - score += 0.5; - } - } - } - - return score * weight; + function convertToTerms(input) { + var query = { + exactInput: input + }; + query.inputClean = input.trim(); + query.inputLowerCase = query.inputClean.toLocaleLowerCase(); + query.terms = query.inputLowerCase.split(TERM_SPLITTER); + query.exactTerms = query.inputClean.split(TERM_SPLITTER); + return query; } - - /** + + /** * Gets search results from the indexedItems based on provided search - * input. Returns matching results from indexedItems, as well as the - * timestamp that was passed to it. - * + * input. Returns matching results from indexedItems + * * @param data An object which contains: * * input: The original string which we are searching with - * * maxNumber: The maximum number of search results desired - * * timestamp: The time identifier from when the query was made + * * maxResults: The maximum number of search results desired + * * queryId: an id identifying this query, will be returned. */ function search(data) { - // This results dictionary will have domain object ID keys which - // point to the value the domain object's score. - var results = {}, - input = data.input.toLocaleLowerCase(), - terms = convertToTerms(input), + // This results dictionary will have domain object ID keys which + // point to the value the domain object's score. + var results, + input = data.input, + query = convertToTerms(input), message = { request: 'search', results: {}, total: 0, - timestamp: data.timestamp, - timedOut: false + queryId: data.queryId }, - score, - i, - id; - - // If the user input is empty, we want to have no search results. - if (input !== '') { - for (i = 0; i < indexedItems.length; i += 1) { - // If this is taking too long, then stop - if (Date.now() > data.timestamp + data.timeout) { - message.timedOut = true; - break; - } - - // Score and add items - score = scoreItem(indexedItems[i], input, terms); - if (score > 0) { - results[indexedItems[i].id] = score; - message.total += 1; - } - } + matches = {}; + + if (!query.inputClean) { + // No search terms, no results; + return message; } - - // Truncate results if there are more than maxResults - if (message.total > data.maxResults) { - i = 0; - for (id in results) { - message.results[id] = results[id]; - i += 1; - if (i >= data.maxResults) { - break; + + // Two phases: find matches, then score matches. + // Idea being that match finding should be fast, so that future scoring + // operations process fewer objects. + + query.terms.forEach(function findMatchingItems(term) { + indexedItems + .filter(function matchesItem(item) { + return item.vector.lowerCaseName.indexOf(term) !== -1; + }) + .forEach(function trackMatch(matchedItem) { + if (!matches[matchedItem.id]) { + matches[matchedItem.id] = { + matchCount: 0, + item: matchedItem + }; + } + matches[matchedItem.id].matchCount += 1; + }); + }); + + // Then, score matching items. + results = Object + .keys(matches) + .map(function asMatches(matchId) { + return matches[matchId]; + }) + .map(function prioritizeExactMatches(match) { + if (match.item.vector.name === query.exactInput) { + match.matchCount += 100; + } else if (match.item.vector.lowerCaseName === + query.inputLowerCase) { + match.matchCount += 50; } - } - // TODO: This seems inefficient. - } else { - message.results = results; - } - + return match; + }) + .map(function prioritizeCompleteTermMatches(match) { + match.item.vector.terms.forEach(function (term) { + if (query.terms.indexOf(term) !== -1) { + match.matchCount += 0.5; + } + }); + return match; + }) + .sort(function compare(a, b) { + if (a.matchCount > b.matchCount) { + return -1; + } + if (a.matchCount < b.matchCount) { + return 1; + } + return 0; + }); + + message.total = results.length; + message.results = results + .slice(0, data.maxResults); + return message; } - + self.onmessage = function (event) { if (event.data.request === 'index') { - indexedItems.push({ - id: event.data.id, - model: event.data.model - }); + indexItem(event.data.id, event.data.model); } else if (event.data.request === 'search') { self.postMessage(search(event.data)); } }; -}()); \ No newline at end of file +}()); diff --git a/platform/search/src/services/SearchAggregator.js b/platform/search/src/services/SearchAggregator.js index 2324090595..00988f81a8 100644 --- a/platform/search/src/services/SearchAggregator.js +++ b/platform/search/src/services/SearchAggregator.js @@ -24,122 +24,201 @@ /** * Module defining SearchAggregator. Created by shale on 07/16/2015. */ -define( - [], - function () { - "use strict"; +define([ - var DEFUALT_TIMEOUT = 1000, - DEFAULT_MAX_RESULTS = 100; - - /** - * Allows multiple services which provide search functionality - * to be treated as one. - * - * @constructor - * @param $q Angular's $q, for promise consolidation. - * @param {SearchProvider[]} providers The search providers to be - * aggregated. - */ - function SearchAggregator($q, providers) { - this.$q = $q; - this.providers = providers; +], function ( + +) { + "use strict"; + + /** + * Aggregates multiple search providers as a singular search provider. + * Search providers are expected to implement a `query` method which returns + * a promise for a `modelResults` object. + * + * The search aggregator combines the results from multiple providers, + * removes aggregates, and converts the results to domain objects. + * + * @constructor + * @param $q Angular's $q, for promise consolidation. + * @param objectService + * @param {SearchProvider[]} providers The search providers to be + * aggregated. + */ + function SearchAggregator($q, objectService, providers) { + this.$q = $q; + this.objectService = objectService; + this.providers = providers; + } + + /** + * If max results is not specified in query, use this as default. + */ + SearchAggregator.prototype.DEFAULT_MAX_RESULTS = 100; + + /** + * Because filtering isn't implemented inside each provider, the fudge + * factor is a multiplier on the number of results returned-- more results + * than requested will be fetched, and then will be filtered. This helps + * provide more predictable pagination when large numbers of results are + * returned but very few results match filters. + * + * If a provider level filter implementation is implemented in the future, + * remove this. + */ + SearchAggregator.prototype.FUDGE_FACTOR = 5; + + /** + * Sends a query to each of the providers. Returns a promise for + * a result object that has the format + * {hits: searchResult[], total: number} + * where a searchResult has the format + * {id: string, object: domainObject, score: number} + * + * @param {String} inputText The text input that is the query. + * @param {Number} maxResults (optional) The maximum number of results + * that this function should return. If not provided, a + * default of 100 will be used. + * @param {Function} [filter] if provided, will be called for every + * potential modelResult. If it returns false, the model result will be + * excluded from the search results. + * @returns {Promise} A Promise for a search result object. + */ + SearchAggregator.prototype.query = function ( + inputText, + maxResults, + filter + ) { + + var aggregator = this, + resultPromises; + + if (!maxResults) { + maxResults = this.DEFAULT_MAX_RESULTS; } - /** - * Sends a query to each of the providers. Returns a promise for - * a result object that has the format - * {hits: searchResult[], total: number, timedOut: boolean} - * where a searchResult has the format - * {id: string, object: domainObject, score: number} - * - * @param inputText The text input that is the query. - * @param maxResults (optional) The maximum number of results - * that this function should return. If not provided, a - * default of 100 will be used. - */ - SearchAggregator.prototype.query = function queryAll(inputText, maxResults) { - var $q = this.$q, - providers = this.providers, - i, - timestamp = Date.now(), - resultPromises = []; + resultPromises = this.providers.map(function (provider) { + return provider.query( + inputText, + maxResults * aggregator.FUDGE_FACTOR + ); + }); - // Remove duplicate objects that have the same ID. Modifies the passed - // array, and returns the number that were removed. - function filterDuplicates(results, total) { - var ids = {}, - numRemoved = 0, - i; + return this.$q + .all(resultPromises) + .then(function (providerResults) { + var modelResults = { + hits: [], + total: 0 + }; - for (i = 0; i < results.length; i += 1) { - if (ids[results[i].id]) { - // If this result's ID is already there, remove the object - results.splice(i, 1); - numRemoved += 1; - - // Reduce loop index because we shortened the array - i -= 1; - } else { - // Otherwise add the ID to the list of the ones we have seen - ids[results[i].id] = true; - } - } - - return numRemoved; - } - - // Order the objects from highest to lowest score in the array. - // Modifies the passed array, as well as returns the modified array. - function orderByScore(results) { - results.sort(function (a, b) { - if (a.score > b.score) { - return -1; - } else if (b.score > a.score) { - return 1; - } else { - return 0; - } + providerResults.forEach(function (providerResult) { + modelResults.hits = + modelResults.hits.concat(providerResult.hits); + modelResults.total += providerResult.total; }); - return results; - } - if (!maxResults) { - maxResults = DEFAULT_MAX_RESULTS; - } + modelResults = aggregator.orderByScore(modelResults); + modelResults = aggregator.applyFilter(modelResults, filter); + modelResults = aggregator.removeDuplicates(modelResults); - // Send the query to all the providers - for (i = 0; i < providers.length; i += 1) { - resultPromises.push( - providers[i].query(inputText, timestamp, maxResults, DEFUALT_TIMEOUT) - ); - } - - // Get promises for results arrays - return $q.all(resultPromises).then(function (resultObjects) { - var results = [], - totalSum = 0, - i; - - // Merge results - for (i = 0; i < resultObjects.length; i += 1) { - results = results.concat(resultObjects[i].hits); - totalSum += resultObjects[i].total; - } - // Order by score first, so that when removing repeats we keep the higher scored ones - orderByScore(results); - totalSum -= filterDuplicates(results, totalSum); - - return { - hits: results, - total: totalSum, - timedOut: resultObjects.some(function (obj) { - return obj.timedOut; - }) - }; + return aggregator.asObjectResults(modelResults); }); - }; + }; - return SearchAggregator; - } -); \ No newline at end of file + /** + * Order model results by score descending and return them. + */ + SearchAggregator.prototype.orderByScore = function (modelResults) { + modelResults.hits.sort(function (a, b) { + if (a.score > b.score) { + return -1; + } else if (b.score > a.score) { + return 1; + } else { + return 0; + } + }); + return modelResults; + }; + + /** + * Apply a filter to each model result, removing it from search results + * if it does not match. + */ + SearchAggregator.prototype.applyFilter = function (modelResults, filter) { + if (!filter) { + return modelResults; + } + var initialLength = modelResults.hits.length, + finalLength, + removedByFilter; + + modelResults.hits = modelResults.hits.filter(function (hit) { + return filter(hit.model); + }); + + finalLength = modelResults.hits.length; + removedByFilter = initialLength - finalLength; + modelResults.total -= removedByFilter; + + return modelResults; + }; + + /** + * Remove duplicate hits in a modelResults object, and decrement `total` + * each time a duplicate is removed. + */ + SearchAggregator.prototype.removeDuplicates = function (modelResults) { + var includedIds = {}; + + modelResults.hits = modelResults + .hits + .filter(function alreadyInResults(hit) { + if (includedIds[hit.id]) { + modelResults.total -= 1; + return false; + } + includedIds[hit.id] = true; + return true; + }); + + return modelResults; + }; + + /** + * Convert modelResults to objectResults by fetching them from the object + * service. + * + * @returns {Promise} for an objectResults object. + */ + SearchAggregator.prototype.asObjectResults = function (modelResults) { + var objectIds = modelResults.hits.map(function (modelResult) { + return modelResult.id; + }); + + return this + .objectService + .getObjects(objectIds) + .then(function (objects) { + + var objectResults = { + total: modelResults.total + }; + + objectResults.hits = modelResults + .hits + .map(function asObjectResult(hit) { + return { + id: hit.id, + object: objects[hit.id], + score: hit.score + }; + }); + + return objectResults; + }); + }; + + return SearchAggregator; +}); diff --git a/platform/search/test/controllers/SearchControllerSpec.js b/platform/search/test/controllers/SearchControllerSpec.js index 720d9bd64a..a755594d58 100644 --- a/platform/search/test/controllers/SearchControllerSpec.js +++ b/platform/search/test/controllers/SearchControllerSpec.js @@ -4,12 +4,12 @@ * 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. + * '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 + * 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. @@ -24,185 +24,162 @@ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/controllers/SearchController"], - function (SearchController) { - "use strict"; +define([ + '../../src/controllers/SearchController' +], function ( + SearchController +) { + 'use strict'; - // These should be the same as the ones on the top of the search controller - var INITIAL_LOAD_NUMBER = 20, - LOAD_INCREMENT = 20; - - describe("The search controller", function () { - var mockScope, - mockSearchService, - mockPromise, - mockSearchResult, - mockDomainObject, - mockTypes, - controller; + describe('The search controller', function () { + var mockScope, + mockSearchService, + mockPromise, + mockSearchResult, + mockDomainObject, + mockTypes, + controller; - function bigArray(size) { - var array = [], - i; - for (i = 0; i < size; i += 1) { - array.push(mockSearchResult); - } - return array; + function bigArray(size) { + var array = [], + i; + for (i = 0; i < size; i += 1) { + array.push(mockSearchResult); } - - - beforeEach(function () { - mockScope = jasmine.createSpyObj( - "$scope", - [ "$watch" ] - ); - mockScope.ngModel = {}; - mockScope.ngModel.input = "test input"; - mockScope.ngModel.checked = {}; - mockScope.ngModel.checked['mock.type'] = true; + return array; + } + + + beforeEach(function () { + mockScope = jasmine.createSpyObj( + '$scope', + [ '$watch' ] + ); + mockScope.ngModel = {}; + mockScope.ngModel.input = 'test input'; + mockScope.ngModel.checked = {}; + mockScope.ngModel.checked['mock.type'] = true; + mockScope.ngModel.checkAll = true; + + mockSearchService = jasmine.createSpyObj( + 'searchService', + [ 'query' ] + ); + mockPromise = jasmine.createSpyObj( + 'promise', + [ 'then' ] + ); + mockSearchService.query.andReturn(mockPromise); + + mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}]; + + mockSearchResult = jasmine.createSpyObj( + 'searchResult', + [ '' ] + ); + mockDomainObject = jasmine.createSpyObj( + 'domainObject', + [ 'getModel' ] + ); + mockSearchResult.object = mockDomainObject; + mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'}); + + controller = new SearchController(mockScope, mockSearchService, mockTypes); + controller.search(); + }); + + it('has a default number of results per page', function () { + expect(controller.RESULTS_PER_PAGE).toBe(20); + }); + + it('sends queries to the search service', function () { + expect(mockSearchService.query).toHaveBeenCalledWith( + 'test input', + controller.RESULTS_PER_PAGE, + jasmine.any(Function) + ); + }); + + describe('filter query function', function () { + it('returns true when all types allowed', function () { mockScope.ngModel.checkAll = true; - - mockSearchService = jasmine.createSpyObj( - "searchService", - [ "query" ] - ); - mockPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - mockSearchService.query.andReturn(mockPromise); - - mockTypes = [{key: 'mock.type', name: 'Mock Type', glyph: '?'}]; - - mockSearchResult = jasmine.createSpyObj( - "searchResult", - [ "" ] - ); - mockDomainObject = jasmine.createSpyObj( - "domainObject", - [ "getModel" ] - ); - mockSearchResult.object = mockDomainObject; - mockDomainObject.getModel.andReturn({name: 'Mock Object', type: 'mock.type'}); - - controller = new SearchController(mockScope, mockSearchService, mockTypes); - controller.search(); - }); - - it("sends queries to the search service", function () { - expect(mockSearchService.query).toHaveBeenCalled(); - }); - - it("populates the results with results from the search service", function () { - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({hits: []}); - - expect(mockScope.results).toBeDefined(); - }); - - it("is loading until the service's promise fufills", function () { - // Send query - controller.search(); - expect(mockScope.loading).toBeTruthy(); - - // Then resolve the promises - mockPromise.then.mostRecentCall.args[0]({hits: []}); - expect(mockScope.loading).toBeFalsy(); + controller.onFilterChange(); + var filterFn = mockSearchService.query.mostRecentCall.args[2]; + expect(filterFn('askbfa')).toBe(true); }); - - it("displays only some results when there are many", function () { - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({hits: bigArray(100)}); - - expect(mockScope.results).toBeDefined(); - expect(mockScope.results.length).toBeLessThan(100); - }); - - it("detects when there are more results", function () { + it('returns true only for matching checked types', function () { mockScope.ngModel.checkAll = false; - - expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + 5), - total: INITIAL_LOAD_NUMBER + 5 - }); - // bigArray gives searchResults of type 'mock.type' - mockScope.ngModel.checked['mock.type'] = false; - mockScope.ngModel.checked['mock.type.2'] = true; - - expect(controller.areMore()).toBeFalsy(); - - mockScope.ngModel.checked['mock.type'] = true; - - expect(controller.areMore()).toBeTruthy(); - }); - - it("can load more results", function () { - var oldSize; - - expect(mockPromise.then).toHaveBeenCalled(); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - // These hits and total lengths are the case where the controller - // DOES NOT have to re-search to load more results - oldSize = mockScope.results.length; - - expect(controller.areMore()).toBeTruthy(); - - controller.loadMore(); - expect(mockScope.results.length).toBeGreaterThan(oldSize); - }); - - it("can re-search to load more results", function () { - var oldSize, - oldCallCount; - - expect(mockPromise.then).toHaveBeenCalled(); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT - 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - // These hits and total lengths are the case where the controller - // DOES have to re-search to load more results - oldSize = mockScope.results.length; - oldCallCount = mockPromise.then.callCount; - expect(controller.areMore()).toBeTruthy(); - - controller.loadMore(); - expect(mockPromise.then).toHaveBeenCalled(); - // Make sure that a NEW call to search has been made - expect(oldCallCount).toBeLessThan(mockPromise.then.callCount); - mockPromise.then.mostRecentCall.args[0]({ - hits: bigArray(INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1), - total: INITIAL_LOAD_NUMBER + LOAD_INCREMENT + 1 - }); - expect(mockScope.results.length).toBeGreaterThan(oldSize); - }); - - it("sets the ngModel.search flag", function () { - // Flag should be true with nonempty input - expect(mockScope.ngModel.search).toEqual(true); - - // Flag should be flase with empty input - mockScope.ngModel.input = ""; - controller.search(); - mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); - expect(mockScope.ngModel.search).toEqual(false); - - // Both the empty string and undefined should be 'empty input' - mockScope.ngModel.input = undefined; - controller.search(); - mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); - expect(mockScope.ngModel.search).toEqual(false); - }); - - it("has a default results list to filter from", function () { - expect(mockScope.ngModel.filter()).toBeDefined(); + controller.onFilterChange(); + var filterFn = mockSearchService.query.mostRecentCall.args[2]; + expect(filterFn({type: 'mock.type'})).toBe(true); + expect(filterFn({type: 'other.type'})).toBe(false); }); }); - } -); \ No newline at end of file + + it('populates the results with results from the search service', function () { + expect(mockPromise.then).toHaveBeenCalledWith(jasmine.any(Function)); + mockPromise.then.mostRecentCall.args[0]({hits: ['a']}); + + expect(mockScope.results.length).toBe(1); + expect(mockScope.results).toContain('a'); + }); + + it('is loading until the service\'s promise fufills', function () { + expect(mockScope.loading).toBeTruthy(); + + // Then resolve the promises + mockPromise.then.mostRecentCall.args[0]({hits: []}); + expect(mockScope.loading).toBeFalsy(); + }); + + it('detects when there are more results', function () { + mockPromise.then.mostRecentCall.args[0]({ + hits: bigArray(controller.RESULTS_PER_PAGE), + total: controller.RESULTS_PER_PAGE + 5 + }); + + expect(mockScope.results.length).toBe(controller.RESULTS_PER_PAGE); + expect(controller.areMore()).toBeTruthy(); + + controller.loadMore(); + + expect(mockSearchService.query).toHaveBeenCalledWith( + 'test input', + controller.RESULTS_PER_PAGE * 2, + jasmine.any(Function) + ); + + mockPromise.then.mostRecentCall.args[0]({ + hits: bigArray(controller.RESULTS_PER_PAGE + 5), + total: controller.RESULTS_PER_PAGE + 5 + }); + + expect(mockScope.results.length) + .toBe(controller.RESULTS_PER_PAGE + 5); + + expect(controller.areMore()).toBe(false); + }); + + it('sets the ngModel.search flag', function () { + // Flag should be true with nonempty input + expect(mockScope.ngModel.search).toEqual(true); + + // Flag should be flase with empty input + mockScope.ngModel.input = ''; + controller.search(); + mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); + expect(mockScope.ngModel.search).toEqual(false); + + // Both the empty string and undefined should be 'empty input' + mockScope.ngModel.input = undefined; + controller.search(); + mockPromise.then.mostRecentCall.args[0]({hits: [], total: 0}); + expect(mockScope.ngModel.search).toEqual(false); + }); + + it('attaches a filter function to scope', function () { + expect(mockScope.ngModel.filter).toEqual(jasmine.any(Function)); + }); + }); +}); diff --git a/platform/search/test/services/GenericSearchProviderSpec.js b/platform/search/test/services/GenericSearchProviderSpec.js index e3ee0a97ba..cc80e4210d 100644 --- a/platform/search/test/services/GenericSearchProviderSpec.js +++ b/platform/search/test/services/GenericSearchProviderSpec.js @@ -19,275 +19,321 @@ * 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*/ +/*global define,describe,it,expect,beforeEach,jasmine,Promise,spyOn,waitsFor, + runs*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/services/GenericSearchProvider"], - function (GenericSearchProvider) { - "use strict"; +define([ + "../../src/services/GenericSearchProvider" +], function ( + GenericSearchProvider +) { + "use strict"; - describe("The generic search provider ", function () { - var mockQ, - mockLog, - mockThrottle, - mockDeferred, - mockObjectService, - mockObjectPromise, - mockChainedPromise, - mockDomainObjects, - mockCapability, - mockCapabilityPromise, - mockWorkerService, - mockWorker, - mockTopic, - mockMutationTopic, - mockRoots = ['root1', 'root2'], - mockThrottledFn, - throttledCallCount, - provider, - mockProviderResults; + describe('GenericSearchProvider', function () { + var $q, + $log, + modelService, + models, + workerService, + worker, + topic, + mutationTopic, + ROOTS, + provider; - function resolveObjectPromises() { - var i; - for (i = 0; i < mockObjectPromise.then.calls.length; i += 1) { - mockChainedPromise.then.calls[i].args[0]( - mockObjectPromise.then.calls[i] - .args[0](mockDomainObjects) - ); - } - } + beforeEach(function () { + $q = jasmine.createSpyObj( + '$q', + ['defer'] + ); + $log = jasmine.createSpyObj( + '$log', + ['warn'] + ); + models = {}; + modelService = jasmine.createSpyObj( + 'modelService', + ['getModels'] + ); + modelService.getModels.andReturn(Promise.resolve(models)); + workerService = jasmine.createSpyObj( + 'workerService', + ['run'] + ); + worker = jasmine.createSpyObj( + 'worker', + [ + 'postMessage', + 'addEventListener' + ] + ); + workerService.run.andReturn(worker); + topic = jasmine.createSpy('topic'); + mutationTopic = jasmine.createSpyObj( + 'mutationTopic', + ['listen'] + ); + topic.andReturn(mutationTopic); + ROOTS = [ + 'mine' + ]; - function resolveThrottledFn() { - if (mockThrottledFn.calls.length > throttledCallCount) { - mockThrottle.mostRecentCall.args[0](); - throttledCallCount = mockThrottledFn.calls.length; - } - } + spyOn(GenericSearchProvider.prototype, 'scheduleForIndexing'); - function resolveAsyncTasks() { - resolveThrottledFn(); - resolveObjectPromises(); - } + provider = new GenericSearchProvider( + $q, + $log, + modelService, + workerService, + topic, + ROOTS + ); + }); + + it('listens for general mutation', function () { + expect(topic).toHaveBeenCalledWith('mutation'); + expect(mutationTopic.listen) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it('reschedules indexing when mutation occurs', function () { + var mockDomainObject = + jasmine.createSpyObj('domainObj', ['getId']); + mockDomainObject.getId.andReturn("some-id"); + mutationTopic.listen.mostRecentCall.args[0](mockDomainObject); + expect(provider.scheduleForIndexing).toHaveBeenCalledWith('some-id'); + }); + + it('starts indexing roots', function () { + expect(provider.scheduleForIndexing).toHaveBeenCalledWith('mine'); + }); + + it('runs a worker', function () { + expect(workerService.run) + .toHaveBeenCalledWith('genericSearchWorker'); + }); + + it('listens for messages from worker', function () { + expect(worker.addEventListener) + .toHaveBeenCalledWith('message', jasmine.any(Function)); + spyOn(provider, 'onWorkerMessage'); + worker.addEventListener.mostRecentCall.args[1]('mymessage'); + expect(provider.onWorkerMessage).toHaveBeenCalledWith('mymessage'); + }); + + it('has a maximum number of concurrent requests', function () { + expect(provider.MAX_CONCURRENT_REQUESTS).toBe(100); + }); + + describe('scheduleForIndexing', function () { + beforeEach(function () { + provider.scheduleForIndexing.andCallThrough(); + spyOn(provider, 'keepIndexing'); + }); + + it('tracks ids to index', function () { + expect(provider.indexedIds.a).not.toBeDefined(); + expect(provider.pendingIndex.a).not.toBeDefined(); + expect(provider.idsToIndex).not.toContain('a'); + provider.scheduleForIndexing('a'); + expect(provider.indexedIds.a).toBeDefined(); + expect(provider.pendingIndex.a).toBeDefined(); + expect(provider.idsToIndex).toContain('a'); + }); + + it('calls keep indexing', function () { + provider.scheduleForIndexing('a'); + expect(provider.keepIndexing).toHaveBeenCalled(); + }); + }); + + describe('keepIndexing', function () { + it('calls beginIndexRequest until at maximum', function () { + spyOn(provider, 'beginIndexRequest').andCallThrough(); + provider.pendingRequests = 9; + provider.idsToIndex = ['a', 'b', 'c']; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).toHaveBeenCalled(); + expect(provider.beginIndexRequest.calls.length).toBe(1); + }); + + it('calls beginIndexRequest for all ids to index', function () { + spyOn(provider, 'beginIndexRequest').andCallThrough(); + provider.pendingRequests = 0; + provider.idsToIndex = ['a', 'b', 'c']; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).toHaveBeenCalled(); + expect(provider.beginIndexRequest.calls.length).toBe(3); + }); + + it('does not index when at capacity', function () { + spyOn(provider, 'beginIndexRequest'); + provider.pendingRequests = 10; + provider.idsToIndex.push('a'); + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).not.toHaveBeenCalled(); + }); + + it('does not index when no ids to index', function () { + spyOn(provider, 'beginIndexRequest'); + provider.pendingRequests = 0; + provider.MAX_CONCURRENT_REQUESTS = 10; + provider.keepIndexing(); + expect(provider.beginIndexRequest).not.toHaveBeenCalled(); + }); + }); + + describe('index', function () { + it('sends index message to worker', function () { + var id = 'anId', + model = {}; + + provider.index(id, model); + expect(worker.postMessage).toHaveBeenCalledWith({ + request: 'index', + id: id, + model: model + }); + }); + + it('schedules composed ids for indexing', function () { + var id = 'anId', + model = {composition: ['abc', 'def']}; + + provider.index(id, model); + expect(provider.scheduleForIndexing) + .toHaveBeenCalledWith('abc'); + expect(provider.scheduleForIndexing) + .toHaveBeenCalledWith('def'); + }); + }); + + describe('beginIndexRequest', function () { beforeEach(function () { - mockQ = jasmine.createSpyObj( - "$q", - [ "defer" ] - ); - mockLog = jasmine.createSpyObj( - "$log", - [ "error", "warn", "info", "debug" ] - ); - mockDeferred = jasmine.createSpyObj( - "deferred", - [ "resolve", "reject"] - ); - mockDeferred.promise = "mock promise"; - mockQ.defer.andReturn(mockDeferred); - - mockThrottle = jasmine.createSpy("throttle"); - mockThrottledFn = jasmine.createSpy("throttledFn"); - throttledCallCount = 0; - - mockObjectService = jasmine.createSpyObj( - "objectService", - [ "getObjects" ] - ); - mockObjectPromise = jasmine.createSpyObj( - "promise", - [ "then", "catch" ] - ); - mockChainedPromise = jasmine.createSpyObj( - "chainedPromise", - [ "then" ] - ); - mockObjectService.getObjects.andReturn(mockObjectPromise); - - mockTopic = jasmine.createSpy('topic'); - - mockWorkerService = jasmine.createSpyObj( - "workerService", - [ "run" ] - ); - mockWorker = jasmine.createSpyObj( - "worker", - [ "postMessage" ] - ); - mockWorkerService.run.andReturn(mockWorker); - - mockCapabilityPromise = jasmine.createSpyObj( - "promise", - [ "then", "catch" ] - ); - - mockDomainObjects = {}; - ['a', 'root1', 'root2'].forEach(function (id) { - mockDomainObjects[id] = ( - jasmine.createSpyObj( - "domainObject", - [ - "getId", - "getModel", - "hasCapability", - "getCapability", - "useCapability" - ] - ) - ); - mockDomainObjects[id].getId.andReturn(id); - mockDomainObjects[id].getCapability.andReturn(mockCapability); - mockDomainObjects[id].useCapability.andReturn(mockCapabilityPromise); - mockDomainObjects[id].getModel.andReturn({}); - }); - - mockCapability = jasmine.createSpyObj( - "capability", - [ "invoke", "listen" ] - ); - mockCapability.invoke.andReturn(mockCapabilityPromise); - mockDomainObjects.a.getCapability.andReturn(mockCapability); - mockMutationTopic = jasmine.createSpyObj( - 'mutationTopic', - [ 'listen' ] - ); - mockTopic.andCallFake(function (key) { - return key === 'mutation' && mockMutationTopic; - }); - mockThrottle.andReturn(mockThrottledFn); - mockObjectPromise.then.andReturn(mockChainedPromise); - - provider = new GenericSearchProvider( - mockQ, - mockLog, - mockThrottle, - mockObjectService, - mockWorkerService, - mockTopic, - mockRoots - ); + provider.pendingRequests = 0; + provider.pendingIds = {'abc': true}; + provider.idsToIndex = ['abc']; + models.abc = {}; + spyOn(provider, 'index'); }); - it("indexes tree on initialization", function () { - var i; + it('removes items from queue', function () { + provider.beginIndexRequest(); + expect(provider.idsToIndex.length).toBe(0); + }); - resolveThrottledFn(); - - expect(mockObjectService.getObjects).toHaveBeenCalled(); - expect(mockObjectPromise.then).toHaveBeenCalled(); - - // Call through the root-getting part - resolveObjectPromises(); - - mockRoots.forEach(function (id) { - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects[id].getModel(), - id: id - }); + it('tracks number of pending requests', function () { + provider.beginIndexRequest(); + expect(provider.pendingRequests).toBe(1); + waitsFor(function () { + return provider.pendingRequests === 0; + }); + runs(function () { + expect(provider.pendingRequests).toBe(0); }); }); - it("indexes members of composition", function () { - mockDomainObjects.root1.getModel.andReturn({ - composition: ['a'] + it('indexes objects', function () { + provider.beginIndexRequest(); + waitsFor(function () { + return provider.pendingRequests === 0; }); - - resolveAsyncTasks(); - resolveAsyncTasks(); - - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects.a.getModel(), - id: 'a' + runs(function () { + expect(provider.index) + .toHaveBeenCalledWith('abc', models.abc); }); }); - it("listens for changes to mutation", function () { - expect(mockMutationTopic.listen) - .toHaveBeenCalledWith(jasmine.any(Function)); - mockMutationTopic.listen.mostRecentCall - .args[0](mockDomainObjects.a); - - resolveAsyncTasks(); - - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: 'index', - model: mockDomainObjects.a.getModel(), - id: mockDomainObjects.a.getId() - }); - }); - - it("sends search queries to the worker", function () { - var timestamp = Date.now(); - provider.query(' test "query" ', timestamp, 1, 2); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ - request: "search", - input: ' test "query" ', - timestamp: timestamp, - maxNumber: 1, - timeout: 2 - }); - }); - - it("gives an empty result for an empty query", function () { - var timestamp = Date.now(), - queryOutput; - - queryOutput = provider.query('', timestamp, 1, 2); - expect(queryOutput.hits).toEqual([]); - expect(queryOutput.total).toEqual(0); - - queryOutput = provider.query(); - expect(queryOutput.hits).toEqual([]); - expect(queryOutput.total).toEqual(0); - }); - - it("handles responses from the worker", function () { - var timestamp = Date.now(), - event = { - data: { - request: "search", - results: { - 1: 1, - 2: 2 - }, - total: 2, - timedOut: false, - timestamp: timestamp - } - }; - - provider.query(' test "query" ', timestamp); - mockWorker.onmessage(event); - mockObjectPromise.then.mostRecentCall.args[0](mockDomainObjects); - expect(mockDeferred.resolve).toHaveBeenCalled(); - }); - - it("warns when objects are unavailable", function () { - resolveAsyncTasks(); - expect(mockLog.warn).not.toHaveBeenCalled(); - mockChainedPromise.then.mostRecentCall.args[0]( - mockObjectPromise.then.mostRecentCall.args[1]() - ); - expect(mockLog.warn).toHaveBeenCalled(); - }); - - it("throttles the loading of objects to index", function () { - expect(mockObjectService.getObjects).not.toHaveBeenCalled(); - resolveThrottledFn(); - expect(mockObjectService.getObjects).toHaveBeenCalled(); - }); - - it("logs when all objects have been processed", function () { - expect(mockLog.info).not.toHaveBeenCalled(); - resolveAsyncTasks(); - resolveThrottledFn(); - expect(mockLog.info).toHaveBeenCalled(); - }); - }); - } -); + + + it('can dispatch searches to worker', function () { + spyOn(provider, 'makeQueryId').andReturn(428); + expect(provider.dispatchSearch('searchTerm', 100)) + .toBe(428); + + expect(worker.postMessage).toHaveBeenCalledWith({ + request: 'search', + input: 'searchTerm', + maxResults: 100, + queryId: 428 + }); + }); + + it('can generate queryIds', function () { + expect(provider.makeQueryId()).toEqual(jasmine.any(Number)); + }); + + it('can query for terms', function () { + var deferred = {promise: {}}; + spyOn(provider, 'dispatchSearch').andReturn(303); + $q.defer.andReturn(deferred); + + expect(provider.query('someTerm', 100)).toBe(deferred.promise); + expect(provider.pendingQueries[303]).toBe(deferred); + }); + + describe('onWorkerMessage', function () { + var pendingQuery; + beforeEach(function () { + pendingQuery = jasmine.createSpyObj( + 'pendingQuery', + ['resolve'] + ); + provider.pendingQueries[143] = pendingQuery; + }); + + it('resolves pending searches', function () { + provider.onWorkerMessage({ + data: { + request: 'search', + total: 2, + results: [ + { + item: { + id: 'abc', + model: {id: 'abc'} + }, + matchCount: 4 + }, + { + item: { + id: 'def', + model: {id: 'def'} + }, + matchCount: 2 + } + ], + queryId: 143 + } + }); + + expect(pendingQuery.resolve) + .toHaveBeenCalledWith({ + total: 2, + hits: [{ + id: 'abc', + model: {id: 'abc'}, + score: 4 + }, { + id: 'def', + model: {id: 'def'}, + score: 2 + }] + }); + + expect(provider.pendingQueries[143]).not.toBeDefined(); + + }); + + }); + + }); +}); diff --git a/platform/search/test/services/GenericSearchWorkerSpec.js b/platform/search/test/services/GenericSearchWorkerSpec.js index b95ec5a1bb..20afb4c781 100644 --- a/platform/search/test/services/GenericSearchWorkerSpec.js +++ b/platform/search/test/services/GenericSearchWorkerSpec.js @@ -4,12 +4,12 @@ * 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. + * '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 + * 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. @@ -19,114 +19,205 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker,require*/ +/*global define,describe,it,expect,runs,waitsFor,beforeEach,jasmine,Worker, + require,afterEach*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - [], - function () { - "use strict"; +define([ - describe("The generic search worker ", function () { - // If this test fails, make sure this path is correct - var worker = new Worker(require.toUrl('platform/search/src/services/GenericSearchWorker.js')), - numObjects = 5; - - beforeEach(function () { - var i; - for (i = 0; i < numObjects; i += 1) { - worker.postMessage( - { - request: "index", - id: i, - model: { - name: "object " + i, - id: i, - type: "something" - } - } - ); - } - }); - - it("searches can reach all objects", function () { - var flag = false, - workerOutput, - resultsLength = 0; - - // Search something that should return all objects - runs(function () { - worker.postMessage( - { - request: "search", - input: "object", - maxNumber: 100, - timestamp: Date.now(), - timeout: 1000 - } - ); - }); - - worker.onmessage = function (event) { - var id; - - workerOutput = event.data; - for (id in workerOutput.results) { - resultsLength += 1; - } - flag = true; - }; - - waitsFor(function () { - return flag; - }, "The worker should be searching", 1000); - - runs(function () { - expect(workerOutput).toBeDefined(); - expect(resultsLength).toEqual(numObjects); +], function ( + +) { + 'use strict'; + + describe('GenericSearchWorker', function () { + // If this test fails, make sure this path is correct + var worker, + objectX, + objectY, + objectZ, + itemsToIndex, + onMessage, + data, + waitForResult; + + beforeEach(function () { + worker = new Worker( + require.toUrl('platform/search/src/services/GenericSearchWorker.js') + ); + + objectX = { + id: 'x', + model: {name: 'object xx'} + }; + objectY = { + id: 'y', + model: {name: 'object yy'} + }; + objectZ = { + id: 'z', + model: {name: 'object zz'} + }; + itemsToIndex = [ + objectX, + objectY, + objectZ + ]; + + itemsToIndex.forEach(function (item) { + worker.postMessage({ + request: 'index', + id: item.id, + model: item.model }); }); - - it("searches return only matches", function () { - var flag = false, - workerOutput, - resultsLength = 0; - - // Search something that should return 1 object - runs(function () { - worker.postMessage( - { - request: "search", - input: "2", - maxNumber: 100, - timestamp: Date.now(), - timeout: 1000 - } - ); - }); - - worker.onmessage = function (event) { - var id; - - workerOutput = event.data; - for (id in workerOutput.results) { - resultsLength += 1; - } - flag = true; - }; - + + onMessage = jasmine.createSpy('onMessage'); + worker.addEventListener('message', onMessage); + + waitForResult = function () { waitsFor(function () { - return flag; - }, "The worker should be searching", 1000); - - runs(function () { - expect(workerOutput).toBeDefined(); - expect(resultsLength).toEqual(1); - expect(workerOutput.results[2]).toBeDefined(); + if (onMessage.calls.length > 0) { + data = onMessage.calls[0].args[0].data; + return true; + } + return false; }); + }; + }); + + afterEach(function () { + worker.terminate(); + }); + + it('returns search results for partial term matches', function () { + + worker.postMessage({ + request: 'search', + input: 'obj', + maxResults: 100, + queryId: 123 + }); + + waitForResult(); + + runs(function () { + expect(onMessage).toHaveBeenCalled(); + + expect(data.request).toBe('search'); + expect(data.total).toBe(3); + expect(data.queryId).toBe(123); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].item.model).toEqual(objectX.model); + expect(data.results[0].matchCount).toBe(1); + expect(data.results[1].item.id).toBe('y'); + expect(data.results[1].item.model).toEqual(objectY.model); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].item.id).toBe('z'); + expect(data.results[2].item.model).toEqual(objectZ.model); + expect(data.results[2].matchCount).toBe(1); }); }); - } -); \ No newline at end of file + + it('scores exact term matches higher', function () { + worker.postMessage({ + request: 'search', + input: 'object', + maxResults: 100, + queryId: 234 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(234); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1.5); + }); + }); + + it('can find partial term matches', function () { + worker.postMessage({ + request: 'search', + input: 'x', + maxResults: 100, + queryId: 345 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(345); + expect(data.results.length).toBe(1); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1); + }); + }); + + it('matches individual terms', function () { + worker.postMessage({ + request: 'search', + input: 'x y z', + maxResults: 100, + queryId: 456 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(456); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(1); + expect(data.results[1].item.id).toBe('y'); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].item.id).toBe('z'); + expect(data.results[1].matchCount).toBe(1); + }); + }); + + it('scores exact matches highest', function () { + worker.postMessage({ + request: 'search', + input: 'object xx', + maxResults: 100, + queryId: 567 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(567); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(103); + expect(data.results[1].matchCount).toBe(1.5); + expect(data.results[2].matchCount).toBe(1.5); + }); + }); + + it('scores multiple term match above single match', function () { + worker.postMessage({ + request: 'search', + input: 'obj x', + maxResults: 100, + queryId: 678 + }); + + waitForResult(); + + runs(function () { + expect(data.queryId).toBe(678); + expect(data.results.length).toBe(3); + expect(data.results[0].item.id).toBe('x'); + expect(data.results[0].matchCount).toBe(2); + expect(data.results[1].matchCount).toBe(1); + expect(data.results[2].matchCount).toBe(1); + }); + }); + }); +}); diff --git a/platform/search/test/services/SearchAggregatorSpec.js b/platform/search/test/services/SearchAggregatorSpec.js index 3205f0f9ec..f8bee0dcc0 100644 --- a/platform/search/test/services/SearchAggregatorSpec.js +++ b/platform/search/test/services/SearchAggregatorSpec.js @@ -19,83 +19,244 @@ * 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*/ +/*global define,describe,it,expect,beforeEach,jasmine,Promise,waitsFor,spyOn*/ /** * SearchSpec. Created by shale on 07/31/2015. */ -define( - ["../../src/services/SearchAggregator"], - function (SearchAggregator) { - "use strict"; +define([ + "../../src/services/SearchAggregator" +], function (SearchAggregator) { + "use strict"; - describe("The search aggregator ", function () { - var mockQ, - mockPromise, - mockProviders = [], - aggregator, - mockProviderResults = [], - mockAggregatorResults, - i; + describe("SearchAggregator", function () { + var $q, + objectService, + providers, + aggregator; - beforeEach(function () { - mockQ = jasmine.createSpyObj( - "$q", - [ "all" ] - ); - mockPromise = jasmine.createSpyObj( - "promise", - [ "then" ] - ); - for (i = 0; i < 3; i += 1) { - mockProviders.push( - jasmine.createSpyObj( - "mockProvider" + i, - [ "query" ] - ) - ); - mockProviders[i].query.andReturn(mockPromise); - } - mockQ.all.andReturn(mockPromise); - - aggregator = new SearchAggregator(mockQ, mockProviders); - aggregator.query(); - - for (i = 0; i < mockProviders.length; i += 1) { - mockProviderResults.push({ - hits: [ - { - id: i, - score: 42 - i - }, - { - id: i + 1, - score: 42 - (2 * i) - } - ] - }); - } - mockAggregatorResults = mockPromise.then.mostRecentCall.args[0](mockProviderResults); - }); - - it("sends queries to all providers", function () { - for (i = 0; i < mockProviders.length; i += 1) { - expect(mockProviders[i].query).toHaveBeenCalled(); - } - }); - - it("filters out duplicate objects", function () { - expect(mockAggregatorResults.hits.length).toEqual(mockProviders.length + 1); - expect(mockAggregatorResults.total).not.toBeLessThan(mockAggregatorResults.hits.length); - }); - - it("orders results by score", function () { - for (i = 1; i < mockAggregatorResults.hits.length; i += 1) { - expect(mockAggregatorResults.hits[i].score) - .not.toBeGreaterThan(mockAggregatorResults.hits[i - 1].score); - } - }); - + beforeEach(function () { + $q = jasmine.createSpyObj( + '$q', + ['all'] + ); + $q.all.andReturn(Promise.resolve([])); + objectService = jasmine.createSpyObj( + 'objectService', + ['getObjects'] + ); + providers = []; + aggregator = new SearchAggregator($q, objectService, providers); }); - } -); \ No newline at end of file + + it("has a fudge factor", function () { + expect(aggregator.FUDGE_FACTOR).toBe(5); + }); + + it("has default max results", function () { + expect(aggregator.DEFAULT_MAX_RESULTS).toBe(100); + }); + + it("can order model results by score", function () { + var modelResults = { + hits: [ + {score: 1}, + {score: 23}, + {score: 11} + ] + }, + sorted = aggregator.orderByScore(modelResults); + + expect(sorted.hits).toEqual([ + {score: 23}, + {score: 11}, + {score: 1} + ]); + }); + + it('filters results without a function', function () { + var modelResults = { + hits: [ + {thing: 1}, + {thing: 2} + ], + total: 2 + }, + filtered = aggregator.applyFilter(modelResults); + + expect(filtered.hits).toEqual([ + {thing: 1}, + {thing: 2} + ]); + + expect(filtered.total).toBe(2); + }); + + it('filters results with a function', function () { + var modelResults = { + hits: [ + {model: {thing: 1}}, + {model: {thing: 2}}, + {model: {thing: 3}} + ], + total: 3 + }, + filterFunc = function (model) { + return model.thing < 2; + }, + filtered = aggregator.applyFilter(modelResults, filterFunc); + + expect(filtered.hits).toEqual([ + {model: {thing: 1}} + ]); + expect(filtered.total).toBe(1); + }); + + it('can remove duplicates', function () { + var modelResults = { + hits: [ + {id: 15}, + {id: 23}, + {id: 14}, + {id: 23} + ], + total: 4 + }, + deduped = aggregator.removeDuplicates(modelResults); + + expect(deduped.hits).toEqual([ + {id: 15}, + {id: 23}, + {id: 14} + ]); + expect(deduped.total).toBe(3); + }); + + it('can convert model results to object results', function () { + var modelResults = { + hits: [ + {id: 123, score: 5}, + {id: 234, score: 1} + ], + total: 2 + }, + objects = { + 123: '123-object-hey', + 234: '234-object-hello' + }, + promiseChainComplete = false; + + objectService.getObjects.andReturn(Promise.resolve(objects)); + + aggregator + .asObjectResults(modelResults) + .then(function (objectResults) { + expect(objectResults).toEqual({ + hits: [ + {id: 123, score: 5, object: '123-object-hey'}, + {id: 234, score: 1, object: '234-object-hello'} + ], + total: 2 + }); + }) + .then(function () { + promiseChainComplete = true; + }); + + waitsFor(function () { + return promiseChainComplete; + }); + }); + + it('can send queries to providers', function () { + var provider = jasmine.createSpyObj( + 'provider', + ['query'] + ); + provider.query.andReturn('i prooomise!'); + providers.push(provider); + + aggregator.query('find me', 123, 'filter'); + expect(provider.query) + .toHaveBeenCalledWith( + 'find me', + 123 * aggregator.FUDGE_FACTOR + ); + expect($q.all).toHaveBeenCalledWith(['i prooomise!']); + }); + + it('supplies max results when none is provided', function () { + var provider = jasmine.createSpyObj( + 'provider', + ['query'] + ); + providers.push(provider); + aggregator.query('find me'); + expect(provider.query).toHaveBeenCalledWith( + 'find me', + aggregator.DEFAULT_MAX_RESULTS * aggregator.FUDGE_FACTOR + ); + }); + + it('can combine responses from multiple providers', function () { + var providerResponses = [ + { + hits: [ + 'oneHit', + 'twoHit' + ], + total: 2 + }, + { + hits: [ + 'redHit', + 'blueHit', + 'by', + 'Pete' + ], + total: 4 + } + ], + promiseChainResolved = false; + + $q.all.andReturn(Promise.resolve(providerResponses)); + spyOn(aggregator, 'orderByScore').andReturn('orderedByScore!'); + spyOn(aggregator, 'applyFilter').andReturn('filterApplied!'); + spyOn(aggregator, 'removeDuplicates') + .andReturn('duplicatesRemoved!'); + spyOn(aggregator, 'asObjectResults').andReturn('objectResults'); + + aggregator + .query('something', 10, 'filter') + .then(function (objectResults) { + expect(aggregator.orderByScore).toHaveBeenCalledWith({ + hits: [ + 'oneHit', + 'twoHit', + 'redHit', + 'blueHit', + 'by', + 'Pete' + ], + total: 6 + }); + expect(aggregator.applyFilter) + .toHaveBeenCalledWith('orderedByScore!', 'filter'); + expect(aggregator.removeDuplicates) + .toHaveBeenCalledWith('filterApplied!'); + expect(aggregator.asObjectResults) + .toHaveBeenCalledWith('duplicatesRemoved!'); + + expect(objectResults).toBe('objectResults'); + }) + .then(function () { + promiseChainResolved = true; + }); + + waitsFor(function () { + return promiseChainResolved; + }); + }); + + }); +}); diff --git a/platform/telemetry/bundle.json b/platform/telemetry/bundle.json index 69b32b54c7..d264a1d6c0 100644 --- a/platform/telemetry/bundle.json +++ b/platform/telemetry/bundle.json @@ -37,7 +37,8 @@ "services": [ { "key": "telemetryFormatter", - "implementation": "TelemetryFormatter.js" + "implementation": "TelemetryFormatter.js", + "depends": [ "formatService", "DEFAULT_TIME_FORMAT" ] }, { "key": "telemetrySubscriber", @@ -63,4 +64,4 @@ } ] } -} \ No newline at end of file +} diff --git a/platform/telemetry/src/TelemetryCapability.js b/platform/telemetry/src/TelemetryCapability.js index 1fbd12a691..d89b3cd3bf 100644 --- a/platform/telemetry/src/TelemetryCapability.js +++ b/platform/telemetry/src/TelemetryCapability.js @@ -36,6 +36,64 @@ define( getRangeValue: ZERO }; + /** + * Provides metadata about telemetry associated with a + * given domain object. + * + * @typedef TelemetryMetadata + * @property {string} source the machine-readable identifier for + * the source of telemetry data for this object; used by + * {@link TelemetryService} implementations to determine + * whether or not they provide data for this object. + * @property {string} key the machine-readable identifier for + * telemetry data associated with this specific object, + * within that `source`. + * @property {TelemetryDomainMetadata[]} domains supported domain + * options for telemetry data associated with this object, + * to use in interpreting a {@link TelemetrySeries} + * @property {TelemetryRangeMetadata[]} ranges supported range + * options for telemetry data associated with this object, + * to use in interpreting a {@link TelemetrySeries} + */ + + /** + * Provides metadata about range options within a telemetry series. + * Range options describe distinct properties within any given datum + * of a telemetry series; for instance, a telemetry series containing + * both raw and uncalibrated values may provide separate ranges for + * each. + * + * @typedef TelemetryRangeMetadata + * @property {string} key machine-readable identifier for this range + * @property {string} name human-readable name for this range + * @property {string} [units] human-readable units for this range + * @property {string} [format] data format for this range; usually, + * one of `number`, or `string`. If `undefined`, + * should presume to be a `number`. Custom formats + * may be indicated here. + */ + + /** + * Provides metadata about domain options within a telemetry series. + * Domain options describe distinct properties within any given datum + * of a telemtry series; for instance, a telemetry series containing + * both spacecraft event time and earth received times may provide + * separate domains for each. + * + * Domains are typically used to represent timestamps in a telemetry + * series, but more generally may express any property which will + * have unique values for each datum in a series. It is this property + * which makes domains distinct from ranges, as it makes these values + * appropriate and meaningful for use to sort and bound a series. + * + * @typedef TelemetryDomainMetadata + * @property {string} key machine-readable identifier for this range + * @property {string} name human-readable name for this range + * @property {string} [system] machine-readable identifier for the + * time/date system associated with this domain; + * used by {@link DateService} + */ + /** * A telemetry capability provides a means of requesting telemetry * for a specific object, and for unwrapping the response (to get diff --git a/platform/telemetry/src/TelemetryFormatter.js b/platform/telemetry/src/TelemetryFormatter.js index bbd4cf100c..dd434d4ac3 100644 --- a/platform/telemetry/src/TelemetryFormatter.js +++ b/platform/telemetry/src/TelemetryFormatter.js @@ -22,14 +22,13 @@ /*global define,moment*/ define( - ['moment'], - function (moment) { + [], + function () { "use strict"; // Date format to use for domain values; in particular, // use day-of-year instead of month/day - var DATE_FORMAT = "YYYY-DDD HH:mm:ss", - VALUE_FORMAT_DIGITS = 3; + var VALUE_FORMAT_DIGITS = 3; /** * The TelemetryFormatter is responsible for formatting (as text @@ -37,22 +36,31 @@ define( * the range (usually value) of a data series. * @memberof platform/telemetry * @constructor + * @param {FormatService} formatService the service to user to format + * domain values + * @param {string} defaultFormatKey the format to request when no + * format has been otherwise specified */ - function TelemetryFormatter() { + function TelemetryFormatter(formatService, defaultFormatKey) { + this.formatService = formatService; + this.defaultFormat = formatService.getFormat(defaultFormatKey); } /** * Format a domain value. - * @param {number} v the domain value; a timestamp + * @param {number} v the domain value; usually, a timestamp * in milliseconds since start of 1970 - * @param {string} [key] the key which identifies the - * domain; if unspecified or unknown, this will - * be treated as a standard timestamp. + * @param {string} [key] a key which identifies the format + * to use * @returns {string} a textual representation of the * data and time, suitable for display. */ TelemetryFormatter.prototype.formatDomainValue = function (v, key) { - return isNaN(v) ? "" : moment.utc(v).format(DATE_FORMAT); + var formatter = (key === undefined) ? + this.defaultFormat : + this.formatService.getFormat(key); + + return isNaN(v) ? "" : formatter.format(v); }; /** diff --git a/platform/telemetry/test/TelemetryFormatterSpec.js b/platform/telemetry/test/TelemetryFormatterSpec.js index 22f1579059..23c7b95fd4 100644 --- a/platform/telemetry/test/TelemetryFormatterSpec.js +++ b/platform/telemetry/test/TelemetryFormatterSpec.js @@ -27,16 +27,35 @@ define( "use strict"; describe("The telemetry formatter", function () { - var formatter; + var mockFormatService, + mockFormat, + formatter; beforeEach(function () { - formatter = new TelemetryFormatter(); + mockFormatService = + jasmine.createSpyObj("formatService", ["getFormat"]); + mockFormat = jasmine.createSpyObj("format", [ + "validate", + "parse", + "format" + ]); + mockFormatService.getFormat.andReturn(mockFormat); + formatter = new TelemetryFormatter(mockFormatService); }); - it("formats domains using YYYY-DDD style", function () { - expect(formatter.formatDomainValue(402513731000)).toEqual( - "1982-276 17:22:11" - ); + it("formats domains using the formatService", function () { + var testValue = 12321, testResult = "some result"; + mockFormat.format.andReturn(testResult); + + expect(formatter.formatDomainValue(testValue)) + .toEqual(testResult); + expect(mockFormat.format).toHaveBeenCalledWith(testValue); + }); + + it("passes format keys to the formatService", function () { + formatter.formatDomainValue(12321, "someKey"); + expect(mockFormatService.getFormat) + .toHaveBeenCalledWith("someKey"); }); it("formats ranges as values", function () { @@ -44,4 +63,4 @@ define( }); }); } -); \ No newline at end of file +); diff --git a/test-main.js b/test-main.js index 46740a93b2..18b5f2a0d4 100644 --- a/test-main.js +++ b/test-main.js @@ -44,7 +44,8 @@ require.config({ paths: { 'es6-promise': 'platform/framework/lib/es6-promise-2.0.0.min', - 'moment-duration-format': 'warp/clock/lib/moment-duration-format' + 'moment-duration-format': 'warp/clock/lib/moment-duration-format', + 'uuid': 'platform/commonUI/browse/lib/uuid' }, // dynamically load all test files