diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c187137..c1678ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Last Changes +- [#381](https://github.com/LaxarJS/laxar/issues/381): flow: clearly distinguish places from their targets and patterns + + **BREAKING CHANGE:** see ticket for details - [#402](https://github.com/LaxarJS/laxar/issues/402): project: polyfill `Object.assign` - [#395](https://github.com/LaxarJS/laxar/issues/395): cleanup: removed `object.extend` and `object.deepFreeze` + **BREAKING CHANGE:** see ticket for details diff --git a/README.md b/README.md index 5659a934..bae3086e 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ ## Why LaxarJS? -Find out [why](docs/why_laxar.md) you would use LaxarJS and if it's the right tool for you. -Then, explore the [core concepts](docs/concepts.md) and browse the [manuals](docs/manuals/index.md) in the [documentation](docs). +Find out [why](//laxarjs.org/docs/laxar-latest/why_laxar/) you would use LaxarJS and if it's the right tool for you. +Then, explore the [core concepts](http://laxarjs.org/docs/laxar-latest/concepts/) and browse the [manuals](//laxarjs.org/docs/laxar-latest/manuals/) in the [documentation](//laxarjs.org/docs/laxar-latest/). Have a look at the [LaxarJS homepage](http://laxarjs.org) for demos and more information. diff --git a/docs/manuals/flow_and_places.md b/docs/manuals/flow_and_places.md index 5ee1f180..81ad4df1 100644 --- a/docs/manuals/flow_and_places.md +++ b/docs/manuals/flow_and_places.md @@ -2,8 +2,8 @@ [« return to the manuals](index.md) -Every application consisting of more than one page needs a concept for navigating between these pages. -In LaxarJS this is achieved by a *flow* defining a set of *places* in a declarative fashion. +Every application that has more than one page needs a concept for navigating between these pages. +In LaxarJS this is achieved by a *flow* that determines which page is rendered based on a given URL, and how other pages are *related* to the current location. Preliminary readings: @@ -11,97 +11,112 @@ Preliminary readings: * [Configuration](configuration.md) * [Writing Pages](writing_pages.md) -Each place corresponds to a single page that should be rendered, or some other content displayed to the user. -Currently the definition of one single flow file is possible, which can by default be found within the application at the path `application/flow/flow.json`. -This can be adjusted as `laxar-path-flow` in the *require configuration* of your application. +## The Flow +The flow ties together the parts of a LaxarJS application: it defines what pages are reachable, which also determines the set of widgets and controls to load as part of an application. +Each LaxarJS application has only a single flow, but when bootstrapping your application you can decide which flow to use, so that you can easily break it up into several segments, and only load one of these at a time. -Let us start with an example for a simple `flow.json` file: +A flow is specified using a definition in JSON format, and it primarily consists of a set of named *places*. + + +## Places + +Each place is either associated with a specific *[page](./writing_pages.md)* to be rendered when entering that place, or it is a redirect to another place. +A flow is also a natural entry point to your application + +To determine which place is active when navigating to an application, the browser URL is matched against each place's *patterns* until a match is found. +These patterns are also used to *generate URLs* to link between pages, and to update the browser URL when performing event-based navigation. + +For the actual pattern matching and routing, LaxarJS uses with the micro-library [page.js](http://visionmedia.github.io/page.js/) and its routing pattern syntax, which should be familiar to users of AngularJS or React and their routing solutions. + +Let us start with an example for a simple flow definition file that we call `main.json`: ```JSON { "places": { "entry": { - "redirectTo": "pageOne" + "patterns": [ "/" ], + "redirectTo": "details" }, - "pageOne/:userId": { + "details": { + "patterns": [ "/details/:item", "/details" ], "page": "first_page" } } } ``` -A flow definition is always a JSON object having the root property `places`, which in turn is a map. -Each entry of that map consists of the place's URL template as key and a definition of what should happen when reaching that place as value. -For LaxarJS an URL template always starts with a constant prefix, possibly consisting of multiple segments separated by slashes, containing optional *parameters*. -The syntax is taken from AngularJS, where variable parts of a URL are always prefixed by a colon. -Within the flow, the constant prefix of a place is interpreted as its *identifier*. -Thus the second place in the example has the identifier *pageOne* and one parameter, called *userId*. - -The identifier *entry* of the first place is always interpreted as the default place to navigate to if either no place was provided or if the requested place was not found within the flow. -Most commonly it will just redirect to another existing place, that for example handles user login or application startup. -Just as in plain AngularJS, routing a redirect is configured using the `redirectTo` keyword and naming the place identifier to navigate to. -In this example we simply navigate without providing a value for the *userId* parameter to the place *pageOne*. -Any place that simply redirects to another place cannot do any meaningful in addition to that. -Control is directly passed on to the redirection target. - -In contrast to that, the place *pageOne* specifies a page that should be loaded by using the key `page` in its definition. -By default all pages are searched in the `application/pages/` directory with the `.json` suffix automatically appended when omitted. -Just like the path to the flow file, this can also be reconfigured in the *require configuration* of your application as `laxar-path-pages`. -So whenever this place is visited, the according page with all of its configured widgets is loaded and displayed. +A flow definition is always a JSON object with the top-level property `places`, which in turn is a map. +Each entry of that map consists of the place's *ID* as key and a *place definition* as value. + +The ID is a non-empty alphanumeric string that identifies a place within an application. +It is used to reference places when creating links or to perform event-based navigation. + + +### Place Patterns + +Each place definition has a non-empty list of URL-patterns, specified under the key `patterns`. +In the example, the place *entry* has a single pattern (`/`), while the place *details* has two patterns: `/details/:item` with the named parameter *item* filled in by the router, and `/details` which will not set the *item* parameter when navigated to. +If no patterns are specified, a place with ID `$some-id` will automatically be assigned the patterns list `[ "/$some-id" ]`, which will only match a slash followed by the exact place ID. + +The syntax for URL patterns ultimately depends on what the router (page.js) deems valid. +Note that regular-expression patterns, while in principle supported by page.js, are currently not available for use in a LaxarJS flow definition. +It is *strongly recommended* to always start patterns with a leading slash, as relative paths will break down quickly in most setups. +Also note that each list of pattern should begin with a *reversible* pattern, which contains only constant parts and named parameters. +The pattern `*` that matches any path is not reversible, for example. + +Apart from its patterns, a place has either a `page` entry, or a `redirectTo` entry. +The former determines that the corresponding page will be looked up relative to the pages directory of your application and instantiated when entering the place, while the latter makes it a redirect to another place specified by ID. +In the example, the place *entry* specifies a redirect to the place *details*. +You can use redirects to support legacy URLs in your application and to forward them to actual pages. + + +### Reverse Routing + +The declarative routing configuration is more restrictive than free-form programmatic routing. +On the other hand, this notation allows LaxarJS to automatically generate URLs to any place, from just its ID and possibly a set of named parameters. +The widgets and activities in your application do not need to know about the URL patterns associated with their respective place, which makes them portable across pages and even application. -## Places -As said before the syntax for places is based on the URL template syntax from AngularJS and in fact AngularJS' routing is used internally. -Within the flow, those URL templates have some additional meaning as they are being used as an identifier for places. -Thus a few strict rules are added to the basic AngularJS URL template rules: +### Initiating navigation -* A URL always consists of one or more segments separated by slashes `/`. -* Each segment can either be a constant alphanumeric character string or a parameter, which is an alphanumeric character string prefixed by colon. -* A URL always starts with a unique non empty list of constant segments, which can optionally be followed by a list of parameters. -Parameters and constant segments may not appear interleaved. -* Wildcards are not supported +To initiate navigation, widgets have two options: -Examples of valid places thus are the following: -* `userListing` -* `user/:userId` -* `cars/vans/:manufacturer/:model` +Widgets may render regular HTML links and use the method *constructAbsoluteUrl* of the [axFlowService](./widget_services.md#axFlowService) to calculate the URLs of each link based on place ID and parameters. +Alternatively, widgets may initiate navigation by issuing a *navigateRequest* event expressing the desired new location within the application and providing values for place parameters. +How event-based navigation works in detail can be read in the separate manual covering [events](events.md). -In contrast these places would all be considered invalid: -* `:userId`: A place *must* start with a non-empty constant segment -* `user/:userId/car`: As soon as there is a parameter, no more constant segments may appear -* `user/:names*` or `user/:names?`: Wildcards are *not* supported +In [HTTP/REST](http://en.wikipedia.org/wiki/Representational_state_transfer)) terms, event-based navigation is used to express POST-like semantics, where an URL change is associated with an effectful user action (save, signup, purchase, etc.), while links should always follow GET semantics so that the user can safely switch back and forth between URLs. -These rules may seem very restrictive but they enable LaxarJS to make some assumptions and optimizations based on the URL template. -Additionally a URL should not encode too much sensitive information directly, as this might lead to security issues and bulky URLs. -Instead only some domain information should be passed on between pages, that enables the widgets of the next place to fulfill their specific tasks. +Even better, neither widgets nor pages need to deal with specific place-IDs, and can instead use logical *targets* to initiate navigation or to construct links, as explained in the next section. ## Targets -Navigation is triggered from within a widget by issuing a *navigateRequest* event expressing the desired next location within the application and providing values for place parameters. -How that works in practice can be read in the separate manual covering [events](events.md). -Using these events it is possible to always navigate directly from place to place. -Nevertheless this would instantly lead to a tight coupling between the widget triggering navigation events and the definition of places within the flow. -Instead a widget or a page (by means of the feature configuration for a widget) should only know about semantic navigation targets reachable from their current location (roughly comparable to *relations* in [REST](http://en.wikipedia.org/wiki/Representational_state_transfer)). +Using both events and links, it is possible to always navigate directly from place to place, simply by specifying the ID of the target place. +However, this approach causes a tight coupling between the widget triggering navigation on one hand and the flow definition on the other hand, hurting reuse. +Even more, this would smear knowledge about the navigational structure throughout the application, making it more difficult to later change this structure. -In LaxarJS this is achieved by the concept of *targets*: -Each place can define a mapping from semantic target identifier valid only for this place to the identifier of another place within the flow. +Instead, a widget or a page (via the feature configuration of its widgets) should specify semantic navigation *targets* such as *"next", "previous", "details"*, which are then resolved based on the current place and its definition in the application flow. +The idea is roughly comparable to *relations* in REST style architectures. +In LaxarJS, each place can define its own mapping from semantic target identifiers to the IDs of other places within the application flow. -An example (for brevity the `entry` place is omitted): +An example: ```JSON { "places": { - "introduction/:userId": { + "introduction": { + "patterns": [ "/introduction/:userId" ], "page": "introduction", "targets": { "next": "interests" } }, - "interests/:userId": { + "interests": { + "patterns": [ "/interests/:userId" ], "page": "interests", "targets": { "previous": "introduction", @@ -110,7 +125,8 @@ An example (for brevity the `entry` place is omitted): } }, - "profession/:userId": { + "profession": { + "patterns": [ "/profession/:userId" ], "page": "profession", "targets": { "previous": "interests", @@ -118,14 +134,16 @@ An example (for brevity the `entry` place is omitted): } }, - "interestsHelp/:userId": { + "interestsHelp": { + "patterns": [ "/interests-help/:userId" ], "page": "interests_help", "targets": { "back": "interests" } }, - "professionHelp/:userId": { + "professionHelp": { + "patterns": [ "/profession-help/:userId" ], "page": "profession_help", "targets": { "back": "profession" @@ -135,20 +153,20 @@ An example (for brevity the `entry` place is omitted): } ``` -This flow is typical for a wizard-like application, as it allows a forward and backward navigation, but only sparsely jumping in between pages. +This flow is typical for a wizard-like application, as it allows forward and backward navigation, but only sparsely jumping in between pages. The first place in the example is called *introduction*, which simply displays a page and just lets the user navigate to the *next* target, which would be resolved to the place *interests*. -Here a page is displayed where the user can input his interests, e.g. his hobbies or music taste. -As we are in the middle of a wizard, there is a *previous* target reachable now in addition to the *next* and *help* targets. -Unsurprisingly the *previous* target references the place *introduction* again. +Here a page is displayed where the user can input his interests, such as hobbies or taste in music. +As we are in the middle of the wizard now, a *previous* target is reachable in addition to the *next* and *help* targets. +Unsurprisingly the *previous* target references the first place, *introduction*. The *next* target instead leads us to another new place with identifier *profession*. The *profession* place may only lead us back to the *interests* place via the *previous* target. -May be some pages have some tricky input components or there are some advices for which things to share. -This is where the *help* targets come into play. -Both, the *interests* and the *profession* page, have such a target. -Nevertheless the places behind these targets are different depending on the source page. -This makes understanding of navigation concepts simple and provides contextual semantics. -Returning from the help pages works in a similar way via the *back* targets leading to the respective places. +Let us assume that our pages contain tricky input components, on which we would like to assist the user. +This is where the *help* target comes into play. +Both the *interests* and the *profession* page use this target, but the places behind these targets are different depending on the source page. +This allows you to provide contextual semantics to standard navigation controls, such as a row of back/forward/help buttons. +Returning from the help pages is familiar, via the *back* target leading to the respective places. -Using the simple mechanisms introduced here, most integration scenarios into external applications should be possible. -To learn how to trigger navigation from within widgets and activities, you should go on reading the [events documentation](events.md) and learn about the *navigateRequest* and *didNavigate* events. +Using the mechanisms introduced here, most navigation scenarios as well as integrations into external applications should be possible. +To find out how to construct links between pages, refer to the [axFlowService API](../api/services.md). +To learn how to trigger event-based navigation from within widgets and activities, you should go on reading the [events documentation](events.md) and learn about the *navigateRequest* and *didNavigate* events. diff --git a/docs/manuals/widget_services.md b/docs/manuals/widget_services.md index fc571b41..c9bbd6c4 100644 --- a/docs/manuals/widget_services.md +++ b/docs/manuals/widget_services.md @@ -92,8 +92,9 @@ The complete feature configuration for this instance, with defaults filled in fr ### `axFlowService` Offers the API of the flow service to widgets. -For widgets this API provides methods to e.g. create bookmarkable URLs to flow targets for use as `href` in an `a` tag. -So whenever real links are required instead of programmatical navigation in a LaxarJS application, this is the way to create the URLs. +This API provides a method *constructAbsoluteUrl* to create bookmarkable URLs to flow targets, also encoding place parameters as needed. +The generated URLs can then be used as `href` attribute in `a` tags. +So whenever real links are desired instead of programmatic, event-initiated navigation in a LaxarJS application, this is the way to create the URLs. ### `axGlobalEventBus` diff --git a/laxar.js b/laxar.js index 8c622217..0933c900 100644 --- a/laxar.js +++ b/laxar.js @@ -89,12 +89,12 @@ export function bootstrap( whenDocumentReady( () => { log.trace( `LaxarJS loading Flow: ${flowName}` ); services.pageService.createControllerFor( anchorElement ); - services.flowService.controller() + services.flowController .loadFlow() .then( () => { log.trace( 'Flow loaded' ); }, err => { - log.fatal( 'Failed to load' ); + log.fatal( 'LaxarJS failed to load flow.' ); log.fatal( 'Error [0].\nStack: [1]', err, err && err.stack ); } ); } ); diff --git a/lib/runtime/flow_controller.js b/lib/runtime/flow_controller.js new file mode 100644 index 00000000..c37f5e26 --- /dev/null +++ b/lib/runtime/flow_controller.js @@ -0,0 +1,296 @@ +/** + * Copyright 2016 aixigo AG + * Released under the MIT license. + * http://laxarjs.org/license + */ +import assert from '../utilities/assert'; +import { create as createJsonValidator } from '../utilities/json_validator'; +import { forEach, setPath } from '../utilities/object'; +import flowSchema from '../../static/schemas/flow'; + +const SESSION_KEY_TIMER = 'navigationTimer'; +const DEFAULT_PLACE = ''; + +export const TARGET_SELF = '_self'; + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Module providing the flow controller factory. + * + * @module flow_controller + * @private + */ + +/** + * Creates a flow controller to load a flow definition, to setup routes, and to navigate between places. The + * flow controller triggers handles router-initiated navigation as well as `navigateRequest` events and + * triggers instantiation/destruction of the associated pages. + * + * @param {ArtifactProvider} artifactProvider + * an artifact provider, needed to fetch the flow definition + * @param {Configuration} configuration + * a configuration instance, to determine the name of the flow to load + * @param {EventBus} eventBus + * an event bus instance, used to subscribe to navigateRequest events, and to publish will/did-responses + * @param {Logger} log + * a logger that is used for reporting flow validation and navigation problems + * @param {PageService} pageService + * the page service to use for actual page transitions (setup, teardown) during navigation + * @param {Router} router + * router to register places with, and to use for URL construction + * @param {Timer} timer + * timer to use for measuring page transitions + * + * @return {FlowController} + * a flow controller instance + */ +export function create( artifactProvider, configuration, eventBus, log, pageService, router, timer ) { + + const COLLABORATOR_ID = 'AxFlowController'; + const availablePlaces = {}; + + let activeParameters = {}; + let activePlace; + let navigationInProgress = false; + let requestedTarget = null; + + eventBus.subscribe( 'navigateRequest', ( { target, data } ) => { + if( navigationInProgress ) { return; } + requestedTarget = target; + navigateToTarget( target, { ...activeParameters, ...data } ); + }, { subscriber: COLLABORATOR_ID } ); + + return { + constructAbsoluteUrl, + loadFlow + }; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Starts loading the configured flow definition and configures the router. + * + * @return {Promise} + * a promise that is resolved when all routes have been registered + */ + function loadFlow() { + const flowName = configuration.ensure( 'flow.name' ); + return artifactProvider.forFlow( flowName ).definition() + .then( flow => { + validateFlowJson( flow ); + router.registerRoutes( + assembleRoutes( flow ), + createFallbackHandler( flow ) + ); + } ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Get the place definition for a given target or place. If the provided identifier is a target of the + * current place, the definition of the referenced place is returned. Otherwise, the current place is + * returned. + * + * @param {String} targetOrPlaceId + * a string identifying the target or place to obtain a definition for + * @param {Object} place + * the corresponding place definition + * + * @return {Object} + * a place definition with `targets` and `patterns` as specified in the flow definition, plus `id` + */ + function placeForTarget( targetOrPlaceId, place = activePlace ) { + let placeId = place ? place.targets[ targetOrPlaceId ] : null; + if( placeId == null ) { + placeId = targetOrPlaceId; + } + assert.state( + placeId in availablePlaces, + `Unknown target or place "${targetOrPlaceId}". Current place: "${place.id}"` + ); + return availablePlaces[ placeId ]; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Constructs an absolute URL to the given target or place using the given parameters. If a target is + * given as first argument, it is resolved using the currently active place. + * + * @param {String} targetOrPlace + * the target or place ID to construct a URL for + * @param {Object} [optionalParameters] + * optional map of place parameters. Missing parameters are filled base on the parameters that were + * passed to the currently active place + * + * @return {String} + * the generated absolute URL + * + * @memberof FlowService + */ + function constructAbsoluteUrl( targetOrPlace, optionalParameters ) { + const place = placeForTarget( targetOrPlace ); + return router.constructAbsoluteUrl( + place.patterns, + withoutRedundantParameters( place, optionalParameters ) + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function navigateToTarget( targetOrPlaceId, parameters, redirectFrom ) { + const place = placeForTarget( targetOrPlaceId, redirectFrom ); + router.navigateTo( + place.patterns, + withoutRedundantParameters( place, parameters ), + !!redirectFrom + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function handleRouteChange( place, routerParameters ) { + const parameters = { ...place.defaultParameters, ...routerParameters }; + if( activePlace && place.id === activePlace.id && equals( parameters, activeParameters ) ) { + navigationInProgress = false; + log.trace( `Canceling navigation to "${place.id}". Already there with same parameters.` ); + return Promise.resolve(); + } + if( navigationInProgress ) { + log.trace( `Canceling navigation to "${place.id}". Navigation already in progress.` ); + return Promise.resolve(); + } + navigationInProgress = true; + + const fromPlace = activePlace ? activePlace.targets[ TARGET_SELF ] : ''; + const navigationTimer = timer.started( { + label: `navigation (${fromPlace} -> ${place.targets[ TARGET_SELF ]})`, + persistenceKey: SESSION_KEY_TIMER + } ); + + const event = { + target: requestedTarget || place.id, + place: place.id, + data: parameters + }; + requestedTarget = null; + + const options = { sender: COLLABORATOR_ID }; + return eventBus.publish( `willNavigate.${event.target}`, event, options ) + .then( () => { + if( activePlace && place.id === activePlace.id ) { + activeParameters = parameters; + return Promise.resolve(); + } + + return pageService.controller().tearDownPage() + .then( () => { + log.setTag( 'PLCE', place.id ); + activeParameters = parameters; + activePlace = place; + return pageService.controller().setupPage( place.page ); + } ); + } ) + .then( () => { + navigationInProgress = false; + navigationTimer.stopAndLog( 'didNavigate' ); + return eventBus.publish( `didNavigate.${event.target}`, event, options ); + } ) + .catch( err => { + log.error( `Failed to navigate to place "${place.id}". Error: [0]\n`, err.stack ); + return Promise.reject( err ); + } ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function createFallbackHandler( flow ) { + const { redirectOn, places } = flow; + return path => { + log.warn( `Received request for unknown route "${path}".` ); + if( redirectOn.unknownPlace in places ) { + log.trace( `- Redirecting to error place ("${redirectOn.unknownPlace}").` ); + handleRouteChange( places[ redirectOn.unknownPlace ], {} ); + } + else if( DEFAULT_PLACE in places ) { + log.trace( `- Redirecting to default place ("${DEFAULT_PLACE}").` ); + handleRouteChange( places[ DEFAULT_PLACE ], {} ); + } + else { + log.trace( '- Got no unknownPlace redirect and no default place. Doing nothing.' ); + } + }; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function assembleRoutes( { places } ) { + const routeMap = {}; + forEach( places, ( place, placeId ) => { + place.id = placeId; + place.patterns = place.patterns || [ `/${placeId}` ]; + setPath( place, `targets.${TARGET_SELF}`, place.id ); + + const { id, patterns, page, redirectTo } = place; + availablePlaces[ id ] = place; + + if( redirectTo ) { + patterns.forEach( pattern => { + routeMap[ pattern ] = parameters => { + navigateToTarget( redirectTo, parameters, place ); + }; + } ); + return; + } + + if( !page ) { + log.error( `flow: invalid empty place: ${id}` ); + return; + } + + patterns.forEach( pattern => { + routeMap[ pattern ] = parameters => { + handleRouteChange( place, parameters ); + }; + } ); + } ); + return routeMap; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function validateFlowJson( flowJson ) { + const errors = createJsonValidator( flowSchema ).validate( flowJson ); + if( errors.length ) { + log.error( + 'LaxarJS flow controller: Failed validating flow definition:\n[0]', + errors.map( ({ message }) => ` - ${message}` ).join( '\n' ) + ); + throw new Error( 'Illegal flow.json format' ); + } + } + +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +function withoutRedundantParameters( place, parameters ) { + const { defaultParameters = {} } = place; + const remainingParameters = {}; + forEach( parameters, ( value, key ) => { + if( !( key in defaultParameters ) || defaultParameters[ key ] !== value ) { + remainingParameters[ key ] = value; + } + } ); + return remainingParameters; +} + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +function equals( a, b ) { + const aKeys = Object.keys( a ); + const bKeys = Object.keys( b ); + return aKeys.length === bKeys.length && aKeys.every( key => key in b && a[ key ] === b[ key ] ); +} diff --git a/lib/runtime/flow_service.js b/lib/runtime/flow_service.js index e6020877..6ba2cf16 100644 --- a/lib/runtime/flow_service.js +++ b/lib/runtime/flow_service.js @@ -3,47 +3,35 @@ * Released under the MIT license. * http://laxarjs.org/license */ -import pageRouter from 'page'; -import { create as createJsonValidator } from '../utilities/json_validator'; -import assert from '../utilities/assert'; -import * as object from '../utilities/object'; -import flowSchema from '../../static/schemas/flow'; -const SESSION_KEY_TIMER = 'navigationTimer'; - -const DEFAULT_PLACE = ''; - -export const TARGET_SELF = '_self'; - -export function create( - log, - timer, - artifactProvider, - eventBus, - configuration, - browser, - pageService, - router = pageRouter ) { - - const origin = originFromLocation( browser.location() ); - const routerConfiguration = configuration.get( 'flow.router', {} ); - const queryEnabled = configuration.get( 'flow.query.enabled', false ); +/** + * Module providing the flow service factory. + * + * @module flow_service + */ - const { - base = fallbackBase( routerConfiguration, origin ), - ...pageOptions - } = routerConfiguration; - const useHashbang = pageOptions.hashbang; - const absoluteBase = browser.resolve( base, origin ); +/** + * Creates a flow service that allows widgets to create valid URLs without knowledge about the current place, + * its routing patterns, or about the routing implementation. + * + * @param {FlowController} flowController + * a flow controller, needed to respect default parameter values when generating URLs + * + * @return {FlowService} + * a flow service instance + */ +export function create( flowController ) { - const flowController = createFlowController(); - const api = { - controller: () => flowController, + /** + * Creates and returns a new flow service instance from its dependencies. + * + * @name FlowService + * @constructor + */ + return { constructAbsoluteUrl }; - return api; - /////////////////////////////////////////////////////////////////////////////////////////////////////////// /** @@ -51,387 +39,18 @@ export function create( * given as first argument, it is resolved using the currently active place. * * @param {String} targetOrPlace - * the target or place id to construct a URL for + * the target or place ID to construct a URL for * @param {Object} [optionalParameters] - * optional map of place parameters. Missing parameters are taken from the parameters that were + * optional map of place parameters. Missing parameters are filled base on the parameters that were * passed to the currently active place * - * @return {string} - * the generated absolute URL - * - * @memberOf axFlowService - */ - function constructAbsoluteUrl( targetOrPlace, optionalParameters ) { - const routingPath = constructPath( targetOrPlace, optionalParameters ); - return useHashbang ? `${absoluteBase}#!${routingPath}` : `${absoluteBase}${routingPath}`; - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - /** - * Constructs a path that can be resolved within the current routing context. - * - * @param {String} targetOrPlace - * the target or place ID to construct the URL for - * @param {Object} [optionalParameters] - * optional map of place parameters. Missing parameters are taken from the parameters that were - * passed to the currently active place - * - * @return {string} - * the generated URL, with parameter values encoded into path segments and/or query parameters - * - * @memberOf axFlowService - * @private - */ - function constructPath( targetOrPlace, optionalParameters ) { - const newParameters = { ...flowController.parameters(), ...optionalParameters }; - const placeName = flowController.placeNameForNavigationTarget( targetOrPlace, flowController.place() ); - const place = flowController.places()[ placeName ]; - - const segments = placeName ? [ placeName ] : []; - place.expectedParameters.forEach( _ => { - segments.push( encodeSegment( newParameters[ _ ] ) ); - delete newParameters[ _ ]; - } ); - // normalize path: - while( segments[ segments.length - 1 ] === '_' ) { - segments.pop(); - } - const pathPrefix = `/${segments.join( '/' )}`; - - if( queryEnabled ) { - const query = []; - Object.keys( newParameters ).forEach( parameterName => { - const value = newParameters[ parameterName ]; - // TODO (#380) the `{}` should actually be provided by the JSON schema processor - const defaultValue = ( place.queryParameters || {} )[ parameterName ]; - if( value == null || value === defaultValue ) { - return; - } - const encodedKey = encodeURIComponent( parameterName ); - if( value === true ) { - query.push( encodedKey ); - return; - } - if( value === false && !defaultValue ) { - return; - } - query.push( `${encodedKey}=${encodeURIComponent( value )}` ); - } ); - - if( query.length ) { - return `${pathPrefix}?${query.join( '&' )}`; - } - } - - return pathPrefix; - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function createFlowController() { - - const COLLABORATOR_ID = 'AxFlowController'; - let availablePlaces = {}; - let activeParameters = {}; - let activePlace; - let navigationInProgress = false; - let requestedTarget = TARGET_SELF; - - const controllerApi = { - places: () => availablePlaces, - place: () => object.deepClone( activePlace ), - parameters: () => object.deepClone( activeParameters || {} ), - placeNameForNavigationTarget, - loadFlow: () => { - const flowName = configuration.ensure( 'flow.name' ); - return loadFlow( flowName, handleRouteChange ) - .then( places => { - availablePlaces = places; - router.base( base ); - router.start( pageOptions ); - return availablePlaces; - } ); - } - }; - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - eventBus.subscribe( 'navigateRequest', ( { target, data: parameters } ) => { - if( navigationInProgress ) { return; } - requestedTarget = target; - router( constructPath( target, parameters ) ); - }, { subscriber: COLLABORATOR_ID } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - function handleRouteChange( place, context ) { - const parameters = collectParameters( place, context ); - if( activePlace && place.id === activePlace.id && equals( parameters, activeParameters ) ) { - navigationInProgress = false; - log.trace( `Canceling navigation to "${place.id}". Already there with same parameters.` ); - return Promise.resolve(); - } - if( navigationInProgress ) { - log.trace( `Canceling navigation to "${place.id}". Navigation already in progress.` ); - return Promise.resolve(); - } - navigationInProgress = true; - - const fromPlace = activePlace ? activePlace.targets[ TARGET_SELF ] : ''; - const navigationTimer = timer.started( { - label: `navigation (${fromPlace} -> ${place.targets[ TARGET_SELF ]})`, - persistenceKey: SESSION_KEY_TIMER - } ); - - const navigateEvent = { - target: requestedTarget, - place: place.id, - data: parameters - }; - const options = { sender: COLLABORATOR_ID }; - return eventBus.publish( `willNavigate.${requestedTarget}`, navigateEvent, options ) - .then( () => { - if( activePlace && place.id === activePlace.id ) { - activeParameters = parameters; - return Promise.resolve(); - } - - return pageService.controller().tearDownPage() - .then( () => { - log.setTag( 'PLCE', place.id ); - activeParameters = parameters; - activePlace = place; - } ) - .then( () => pageService.controller().setupPage( place.page ) ); - } ) - .then( () => { - navigationInProgress = false; - navigationTimer.stopAndLog( 'didNavigate' ); - } ) - .then( () => eventBus.publish( `didNavigate.${requestedTarget}`, navigateEvent, options ) ) - .then( () => { - requestedTarget = TARGET_SELF; - }, err => { - log.error( `Failed to navigate to place "${place.id}". Error: [0]\n`, err.stack ); - return Promise.reject( err ); - } ); - } - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - function placeNameForNavigationTarget( targetOrPlaceName, place ) { - let placeName = place.targets[ targetOrPlaceName ]; - if( placeName == null ) { - placeName = targetOrPlaceName; - } - assert.state( - placeName in availablePlaces, - `Unknown target or place "${targetOrPlaceName}". Current place: "${place.id}"` - ); - return placeName; - } - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - return controllerApi; - - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - /** - * Calculate a fallback if `flow.router.base` is not configured. - * - * When using hashbang-URLs, the base prefix can be calculated from the current path. - * Otherwise, the directory of the document base-URL is used as a prefix for all routes - * (directory = everything up to the final slash). - * - * @param {Object} routerConfiguration - * the router configuration under path `flow.router` - * * @return {String} - * a base path to generate absolute links for application places + * the generated absolute URL * - * @private + * @memberof FlowService */ - function fallbackBase( routerConfiguration ) { - return routerConfiguration.hashbang ? - browser.location().pathname : - browser.resolve( '.' ).replace( /\/$/, '' ); + function constructAbsoluteUrl( targetOrPlace, optionalParameters = {} ) { + return flowController.constructAbsoluteUrl( targetOrPlace, optionalParameters ); } - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function loadFlow( flowName, routeRequestHandler ) { - - return artifactProvider.forFlow( flowName ).definition() - .then( flow => { - validateFlowJson( flow ); - const places = processPlaceParameters( flow.places ); - const globalRedirects = flow.redirectOn; - - object.forEach( places, ( place, routeName ) => { - if( place.redirectTo ) { - router.redirect( `/${routeName}`, `/${place.redirectTo}` ); - return; - } - - if( !place.page ) { - log.error( `flow: invalid empty place: ${place.id}` ); - return; - } - - router( `/${routeName}`, context => routeRequestHandler( place, context ) ); - } ); - - // setup handling of non existing routes - router( '*', context => { - log.warn( `Received request for unknown route "${context.path}".` ); - if( globalRedirects.unknownPlace in places ) { - log.trace( `- Redirecting to error place ("${globalRedirects.unknownPlace}").` ); - routeRequestHandler( places[ globalRedirects.unknownPlace ], context ); - } - else if( DEFAULT_PLACE in places ) { - log.trace( `- Redirecting to default place ("${DEFAULT_PLACE}").` ); - routeRequestHandler( places[ DEFAULT_PLACE ], context ); - } - else { - log.trace( '- Got no unknownPlace redirect and no default place. Doing nothing.' ); - } - } ); - - return places; - } ); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function validateFlowJson( flowJson ) { - const errors = createJsonValidator( flowSchema ).validate( flowJson ); - - if( errors.length ) { - log.error( 'Failed validating flow file:\n[0]', errors.map( _ => ` - ${_.message}` ).join( '\n' ) ); - throw new Error( 'Illegal flow.json format' ); - } - } - -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function originFromLocation({ protocol, hostname, port }) { - return `${protocol}://${hostname}${port ? `:${port}` : ''}`; -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function collectParameters( place, context ) { - const { querystring = '', params = {} } = context; - const { queryParameters = {} } = place; - - const parameters = {}; - Object.keys( queryParameters ).forEach( key => { - parameters[ key ] = queryParameters[ key ]; - } ); - if( querystring.length ) { - querystring.split( '&' ) - .map( _ => _.split( '=' ).map( decodeURIComponent ) ) - .forEach( ([ key, value ]) => { - parameters[ key ] = value !== undefined ? value : true; - } ); - } - Object.keys( params ).forEach( key => { - parameters[ key ] = decodeSegment( params[ key ] ); - } ); - return parameters; -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -/** - * Encode a value for use as a path segment in routing. - * - * Usually, values are simply URL-encoded, but there are special cases: - * - * - `null` and `undefined` are encoded as '_', - * - other non-string values are converted to strings before URL encoding, - * - slashes ('/') are double-encoded to '%252F', so that page.js ignores them during route matching. - * - * When decoded, for use in didNavigate events, the original values will be restored, except for: - * - non-string input values, which will always be decoded into strings, - * - `undefined` values which will be decoded to `null`. - * - * @param {*} value - * the parameter to encode - * @return {String} - * the encoded value, for use as a path segment in URLs - * - * @private - */ -function encodeSegment( value ) { - return value == null ? '_' : encodeURIComponent( value ).replace( /%2F/g, '%252F' ); -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -/** - * Decodes a place parameter value from a path segment, for use in didNavigate event. - * - * Usually, this reverses the application of {#encodeSegment} after the browser has decoded a URL, except for: - * - path-segments based on non-string input values, which will always be decoded into strings, - * - path-segments based on `undefined` values which will be decoded to `null`. - * - * Note that while the browser has already performed URL-decoding, this function replaces `%2F` into `/` to - * be compatible with the double-encoding performaed by {#encodeSegment}. - * - * @param {String} value - * the encoded parameter segment to decode - * @return {String} - * the decoded value, for use as a path segment in URLs - * - * @private - */ -function decodeSegment( value ) { - return value === '_' || value == null ? null : value.replace( /%2F/g, '/' ); -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -const ROUTE_PARAMS_MATCHER = /(^|\/):([^\/]+)/ig; - -function processPlaceParameters( places ) { - const processedRoutes = {}; - - object.forEach( places, ( place, placeName ) => { - place.expectedParameters = []; - place.id = placeName; - - if( !place.targets ) { - place.targets = {}; - } - if( !place.targets[ TARGET_SELF ] ) { - place.targets[ TARGET_SELF ] = placeName.split( /(^|\/):/ )[ 0 ]; - } - - let matches; - while( ( matches = ROUTE_PARAMS_MATCHER.exec( placeName ) ) ) { - const routeNameWithoutParams = placeName.substr( 0, matches.index ); - - place.expectedParameters.push( matches[ 2 ] ); - - processedRoutes[ routeNameWithoutParams ] = place; - } - processedRoutes[ placeName ] = place; - } ); - - return processedRoutes; -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function equals( a, b ) { - const aKeys = Object.keys( a ); - const bKeys = Object.keys( b ); - return aKeys.length === bKeys.length && aKeys.every( key => key in b && a[ key ] === b[ key ] ); } diff --git a/lib/runtime/pagejs_router.js b/lib/runtime/pagejs_router.js new file mode 100644 index 00000000..ab979ce6 --- /dev/null +++ b/lib/runtime/pagejs_router.js @@ -0,0 +1,175 @@ +/** + * Copyright 2016 aixigo AG + * Released under the MIT license. + * http://laxarjs.org/license + */ +import { forEach } from '../utilities/object'; + +const ROUTE_PARAM_MATCHER = /\/:([^\/]+)/g; +const TRAILING_SEGMENTS_MATCHER = /\/(_\/)*_?$/; + +export function create( pagejs, browser, configuration ) { + + const hashbang = configuration.get( 'router.pagejs.hashbang', false ); + const queryEnabled = configuration.ensure( 'router.query.enabled' ); + + const base = configuration.get( 'router.base' ) || fallbackBase( hashbang ); + const origin = originFromLocation( browser.location() ); + const absoluteBase = browser.resolve( base, origin ); + + return { + registerRoutes, + navigateTo, + constructAbsoluteUrl + }; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function registerRoutes( routeMap, fallbackHandler ) { + pagejs.base( base ); + forEach( routeMap, ( handler, pattern ) => { + pagejs( pattern, context => { + handler( collectParameters( context ) ); + } ); + } ); + pagejs( '*', context => { + fallbackHandler( context.path ); + } ); + pagejs.start( configuration.get( 'router.pagejs', {} ) ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function navigateTo( patterns, parameters, replaceHistory = false ) { + const path = constructPath( patterns, parameters ); + ( replaceHistory ? pagejs.redirect : pagejs.show )( path ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function constructAbsoluteUrl( patterns, parameters, parameterDefaults ) { + const routingPath = constructPath( patterns, parameters, parameterDefaults ); + return hashbang ? `${absoluteBase}#!${routingPath}` : `${absoluteBase}${routingPath}`; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function constructPath( patterns, parameters ) { + const bestPattern = patterns[ 0 ]; + const path = bestPattern + .replace( ROUTE_PARAM_MATCHER, ( $0, $param ) => { + const replacement = encodeSegment( parameters[ $param ] ); + delete parameters[ $param ]; + return `/${replacement}`; + } ) + .replace( TRAILING_SEGMENTS_MATCHER, '/' ); + + if( queryEnabled ) { + const query = []; + forEach( parameters, (value, parameterName) => { + const encodedKey = encodeURIComponent( parameterName ); + if( value === true ) { + query.push( encodedKey ); + return; + } + if( value === false || value == null ) { + return; + } + query.push( `${encodedKey}=${encodeURIComponent( value )}` ); + } ); + + if( query.length ) { + return `${path}?${query.join( '&' )}`; + } + } + + return path; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function collectParameters( context ) { + const { querystring = '', params = {} } = context; + const parameters = {}; + if( queryEnabled && querystring.length ) { + querystring.split( '&' ) + .map( _ => _.split( '=' ).map( decodeURIComponent ) ) + .forEach( ([ key, value ]) => { + parameters[ key ] = value !== undefined ? value : true; + } ); + } + forEach( params, (value, key) => { + parameters[ key ] = decodeSegment( value ); + } ); + return parameters; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Encode a parameter value for use as a path segment in routing. + * + * Usually, values are simply URL-encoded, but there are special cases: + * + * - `null` and `undefined` are encoded as '_', + * - other non-string values are converted to strings before URL encoding, + * - slashes ('/') are double-encoded to '%252F', so that page.js ignores them during route matching, + * - underscore ('_') is double-encoded to '%255F', to avoid confusion with '_' (null). + * + * When decoded, for use in didNavigate events, the original values will be restored, except for: + * - non-string input values, which will always be decoded into strings, + * - `undefined` values which will be decoded to `null`. + * + * @param {*} value + * the parameter to encode + * @return {String} + * the encoded value, for use as a path segment in URLs + * + * @private + */ + function encodeSegment( value ) { + return value == null ? + '_' : + encodeURIComponent( value ).replace( /%2F/g, '%252F' ).replace( /_/g, '%255F' ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Decodes a place parameter value from a path segment, to restore it for use in will/didNavigate events. + * + * Usually, this reverses the application of {#encodeSegment} after the browser has decoded a URL, except: + * - path-segments based on non-string input values, which will always be decoded into strings, + * - path-segments based on `undefined` values which will be decoded to `null`. + * + * Note that while the browser has already performed URL-decoding, this function replaces `%2F` into `/` + * and `%5F` to `_`, to be compatible with the double-encoding performaed by {#encodeSegment}. + * + * @param {String} value + * the encoded parameter segment to decode + * @return {String} + * the decoded value, for use as a path segment in URLs + * + * @private + */ + function decodeSegment( value ) { + return value === '_' || value == null ? + null : + value.replace( /%2F/g, '/' ).replace( /%5F/g, '_' ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function fallbackBase( hashbang ) { + if( hashbang ) { + return browser.location().pathname; + } + // relies on the HTML `base` element being present + const documentBase = browser.resolve( '.' ).replace( /\/$/, '' ); + return documentBase; + } +} + +function originFromLocation({ protocol, hostname, port }) { + return `${protocol}://${hostname}${port ? `:${port}` : ''}`; +} diff --git a/lib/runtime/services.js b/lib/runtime/services.js index b62b6a9a..ac80be2f 100644 --- a/lib/runtime/services.js +++ b/lib/runtime/services.js @@ -3,6 +3,7 @@ * Released under the MIT license. * http://laxarjs.org/license */ +import pagejs from 'page'; import assert from '../utilities/assert'; import { create as createConfiguration } from './configuration'; import { create as createBrowser } from './browser'; @@ -17,9 +18,11 @@ import { create as createThemeLoader } from '../loaders/theme_loader'; import { create as createWidgetLoader } from '../loaders/widget_loader'; import { create as createStorage } from './storage'; import { create as createTimer } from './timer'; +import { create as createFlowController } from './flow_controller'; import { create as createFlowService } from './flow_service'; import { create as createHeartbeat } from './heartbeat'; import { create as createPageService } from './page_service'; +import { create as createPagejsRouter } from './pagejs_router'; import { create as createLocaleEventManager } from './locale_event_manager'; import { create as createVisibilityEventManager } from './visibility_event_manager'; import { create as createWidgetServices } from './widget_services'; @@ -33,11 +36,14 @@ export function create( configurationSource, assets ) { const configurationDefaults = { baseHref: undefined, eventBusTimeoutMs: 120 * 1000, + router: { + query: { + enabled: false + } + // 'pagejs' is not configured here: + // any deviation from the page.js library defaults must be set by the application + }, flow: { - router: { - hashbang: true, - dispatch: true - }, entryPoint: { target: 'default', parameters: {} @@ -107,14 +113,19 @@ export function create( configurationSource, assets ) { collectors.pages ); - const flowService = createFlowService( - log, - timer, + const router = createPagejsRouter( pagejs, browser, configuration ); + + const flowController = createFlowController( artifactProvider, - globalEventBus, configuration, - browser, - pageService + globalEventBus, + log, + pageService, + router, + timer + ); + const flowService = createFlowService( + flowController ); const toolingProviders = createToolingProviders( collectors ); @@ -136,6 +147,7 @@ export function create( configurationSource, assets ) { configuration, cssLoader, artifactProvider, + flowController, flowService, globalEventBus, heartbeat, diff --git a/lib/runtime/spec/data/flow_data.js b/lib/runtime/spec/data/flow_data.js index a03a8840..47ee8171 100644 --- a/lib/runtime/spec/data/flow_data.js +++ b/lib/runtime/spec/data/flow_data.js @@ -4,99 +4,49 @@ * http://laxarjs.org/license */ export default { - sourceData: { - places: { - entry: { - redirectTo: 'editor' - }, - backdoor: { - redirectTo: 'editor' - }, - welcome: { - page: 'welcome', - targets: { - home: 'entry', - next: 'editor' - } - }, - 'editor/:dataId': { - page: 'editor', - targets: { - home: 'entry', - back: 'welcome', - next: 'evaluation' - } - }, - 'evaluation/:dataId/:method': { - page: 'evaluation', - targets: { - home: 'entry', - back: 'editor', - next: 'exit' - } - }, - 'step-with-options/:taskId': { - page: 'steps/step2', - targets: { - 'end': 'exit1' - }, - queryParameters: { - 'optionA': 'aDefault', - 'param-b': null, - 'c&d': 'some stuff' - } - } - } - }, - processed: { - entryPlace: { - redirectTo: 'editor', - expectedParameters: [], - id: 'entry', - targets: { - _self: 'entry' - } + places: { + entry: { + redirectTo: 'editor' }, - backdoorPlace: { - redirectTo: 'editor', - expectedParameters: [], - id: 'backdoor', - targets: { - _self: 'backdoor' - } + backdoor: { + redirectTo: 'editor' }, - welcomePlace: { - page: 'welcome', - expectedParameters: [], - id: 'welcome', + welcome: { + page: 'dir/welcome', targets: { - _self: 'welcome', home: 'entry', next: 'editor' } }, - editorPlace: { + editor: { + patterns: [ '/editor/:dataId' ], page: 'editor', - expectedParameters: [ 'dataId' ], - id: 'editor/:dataId', targets: { - _self: 'editor', home: 'entry', back: 'welcome', next: 'evaluation' } }, - evaluationPlace: { + evaluation: { + patterns: [ '/evaluation/:dataId/method/:method', '/evaluation/:dataId' ], page: 'evaluation', - expectedParameters: [ 'dataId', 'method' ], - id: 'evaluation/:dataId/:method', targets: { - _self: 'evaluation', home: 'entry', back: 'editor', - next: 'exit' + next: 'welcome' + } + }, + 'step-with-options': { + patterns: [ '/step-with-options/:taskId' ], + page: 'steps/step2', + targets: { + 'back': 'welcome' + }, + defaultParameters: { + 'optionA': 'aDefault', + 'param-b': null, + 'c&d': 'some stuff' } } } - }; diff --git a/lib/runtime/spec/flow_controller_spec.js b/lib/runtime/spec/flow_controller_spec.js new file mode 100644 index 00000000..1fa2374b --- /dev/null +++ b/lib/runtime/spec/flow_controller_spec.js @@ -0,0 +1,489 @@ +/** + * Copyright 2016 aixigo AG + * Released under the MIT license. + * http://laxarjs.org/license + */ +import * as flowControllerModule from '../flow_controller'; +import { deepClone, setPath } from '../../utilities/object'; +import { create as createConfigurationMock } from '../../testing/configuration_mock'; +import { create as createLogMock } from '../../testing/log_mock'; +import { create as createTimerMock } from '../../testing/timer_mock'; +import { create as createEventBusMock } from '../../testing/event_bus_mock'; +import { create as createArtifactProviderMock } from '../../testing/artifact_provider_mock'; +import { create as createRouterMock } from './mocks/router_mock'; + +import flowDataSource from './data/flow_data'; + +const anyFunc = jasmine.any( Function ); + +const configOverrides = {}; +const flowDataOverrides = {}; + +describe( 'A flow controller module', () => { + + it( 'defines a navigation target for the current placeName', () => { + expect( flowControllerModule.TARGET_SELF ).toEqual( '_self' ); + } ); + +} ); + +////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +describe( 'A flow controller instance', () => { + + let flowData; + let artifactProviderMock; + let configurationData; + let configurationMock; + let eventBusMock; + let flowController; + let logMock; + let pageControllerMock; + let routerMock; + let timerMock; + + beforeEach( () => { + flowData = deepClone( flowDataSource ); + Object.keys( flowDataOverrides ).forEach( _ => { + setPath( flowData, _, flowDataOverrides[ _ ] ); + } ); + artifactProviderMock = createArtifactProviderMock(); + artifactProviderMock.forFlow.mock( 'mainz', { definition: flowData } ); + + configurationData = { 'flow.name': 'mainz', ...configOverrides }; + configurationMock = createConfigurationMock( configurationData ); + + eventBusMock = createEventBusMock( { nextTick: f => { window.setTimeout( f, 0 ); } } ); + + logMock = createLogMock(); + + pageControllerMock = { + tearDownPage: jasmine.createSpy( 'tearDownPage' ).and.callFake( () => Promise.resolve() ), + setupPage: jasmine.createSpy( 'setupPage' ).and.callFake( () => Promise.resolve() ) + }; + const pageServiceMock = { controller: () => pageControllerMock }; + + routerMock = createRouterMock(); + + timerMock = createTimerMock(); + + flowController = flowControllerModule.create( + artifactProviderMock, + configurationMock, + eventBusMock, + logMock, + pageServiceMock, + routerMock.router, + timerMock + ); + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function startAtRoute( routeName, optionalContext = {} ) { + return flowController.loadFlow() + .then( routerMock.awaitRegisterRoutes ) + .then( () => routerMock.triggerRouteHandler( routeName, optionalContext ) ) + .then( awaitDidNavigate ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function awaitDidNavigate() { + return new Promise( resolve => { + const unsubscribe = eventBusMock.subscribe( 'didNavigate', event => { + resolve( event ); + unsubscribe(); + } ); + } ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'subscribes to navigateRequest events', () => { + expect( eventBusMock.subscribe ).toHaveBeenCalledWith( + 'navigateRequest', + jasmine.any( Function ), + { subscriber: 'AxFlowController' } + ); + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'validates the flow data to load', done => { + artifactProviderMock.forFlow.mock( 'mainz', { + definition: { its: 'me' } + } ); + flowController.loadFlow() + .then( done.fail, err => { + expect( err ).toEqual( new Error( 'Illegal flow.json format' ) ); + expect( logMock.error ).toHaveBeenCalledWith( + 'LaxarJS flow controller: Failed validating flow definition:\n[0]', + ' - Missing required property: places. Path: "$.places".\n' + + ' - Additional properties not allowed: its. Path: "$.its".' + ); + } ) + .then( done, done.fail ); + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when the flow is loaded', () => { + + beforeEach( done => { + startAtRoute( '/editor/:dataId' ).then( done, done.fail ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'registers router-handlers for places with the `redirectTo` property', () => { + expect( routerMock.routeMap[ '/entry' ] ).toEqual( anyFunc ); + expect( routerMock.routeMap[ '/backdoor' ] ).toEqual( anyFunc ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'registers route-handlers for patterns of places with the `page` property', () => { + expect( routerMock.routeMap[ '/editor/:dataId' ] ).toEqual( anyFunc ); + expect( routerMock.routeMap[ '/evaluation/:dataId/method/:method' ] ).toEqual( anyFunc ); + expect( routerMock.routeMap[ '/evaluation/:dataId' ] ).toEqual( anyFunc ); + expect( routerMock.routeMap[ '/step-with-options/:taskId' ] ).toEqual( anyFunc ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'uses a simple fallback pattern for places without explicit URL pattern', () => { + expect( routerMock.routeMap[ '/welcome' ] ).toEqual( anyFunc ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'loads the page for the currently resolved route', () => { + expect( pageControllerMock.setupPage ).toHaveBeenCalledWith( 'editor' ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'sets the log tag for the current place', () => { + expect( logMock.setTag ).toHaveBeenCalledWith( 'PLCE', 'editor' ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'logs the time that the navigation took to take place', () => { + expect( timerMock.started ).toHaveBeenCalled(); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'and then asked to construct a URL', () => { + + let url; + let routerCall; + + beforeEach( () => { + routerMock.router.constructAbsoluteUrl.and.callFake( () => { + return 'http://myserver/path'; + } ); + + url = flowController.constructAbsoluteUrl( 'step-with-options', { + param: 'a-param', + optionA: 'aDefault' + } ); + routerCall = routerMock.router.constructAbsoluteUrl.calls.mostRecent(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'resolves the URL based on the current place', () => { + expect( routerCall.args[ 0 ] ).toEqual( [ + '/step-with-options/:taskId' + ] ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'resolves the URL using the target place parameters (without defaults)', () => { + expect( routerCall.args[ 1 ] ).toEqual( { + param: 'a-param' + } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'returns the URL as resolved by the router', () => { + expect( url ).toEqual( 'http://myserver/path' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'and a different place is entered by the router', () => { + + beforeEach( done => { + eventBusMock.publish.calls.reset(); + routerMock.triggerRouteHandler( '/evaluation/:dataId/method/:method' ); + awaitDidNavigate().then( done, done.fail ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'publishes a corresponding willNavigate event', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate.evaluation', { + target: 'evaluation', + place: 'evaluation', + data: {} + }, jasmine.any( Object ) ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'eventually publishes a didNavigate event', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate.evaluation', { + target: 'evaluation', + place: 'evaluation', + data: {} + }, jasmine.any( Object ) ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'and the currently active place is entered by the router, with the same parameters', () => { + + beforeEach( () => { + eventBusMock.publish.calls.reset(); + routerMock.triggerRouteHandler( '/editor/:dataId' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does nothing', () => { + expect( eventBusMock.publish ).not.toHaveBeenCalled(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'logs this incident', () => { + expect( logMock.trace ).toHaveBeenCalledWith( + 'Canceling navigation to "editor". Already there with same parameters.' + ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'and a place with parameter-defaults is entered by the router', () => { + + beforeEach( done => { + eventBusMock.publish.calls.reset(); + routerMock.triggerRouteHandler( '/step-with-options/:taskId', { taskId: 'X' } ); + awaitDidNavigate().then( done, done.fail ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'publishes a corresponding willNavigate event, filling in default values', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate.step-with-options', { + target: 'step-with-options', + place: 'step-with-options', + data: { + taskId: 'X', + optionA: 'aDefault', + 'param-b': null, + 'c&d': 'some stuff' + } + }, jasmine.any( Object ) ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'eventually publishes a didNavigate event, filling in default values', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate.step-with-options', { + target: 'step-with-options', + place: 'step-with-options', + data: { + taskId: 'X', + optionA: 'aDefault', + 'param-b': null, + 'c&d': 'some stuff' + } + }, jasmine.any( Object ) ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'on navigate request to same target with different parameters', () => { + + beforeEach( done => { + pageControllerMock.tearDownPage.calls.reset(); + pageControllerMock.setupPage.calls.reset(); + eventBusMock.publish.calls.reset(); + + routerMock.router.navigateTo.and.callFake( () => { + // simulate an URL change + routerMock.triggerRouteHandler( '/editor/:dataId', { dataId: 'some data' } ); + } ); + + eventBusMock.publishAndGatherReplies( 'navigateRequest._self', { + target: '_self', + data: { + dataId: 'some data' + } + } ).then( done ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'asks the router to navigate to the current place patterns, with the new parameters', () => { + expect( routerMock.router.navigateTo ).toHaveBeenCalledWith( [ '/editor/:dataId' ], { + dataId: 'some data' + }, false ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does not ask the page controller to tear down the current page', () => { + expect( pageControllerMock.tearDownPage ).not.toHaveBeenCalled(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does not ask the page controller to setup a page', () => { + expect( pageControllerMock.setupPage ).not.toHaveBeenCalled(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'sends a willNavigate event to indicate the start of navigation', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate._self', { + target: '_self', + place: 'editor', + data: { dataId: 'some data' } + }, { sender: 'AxFlowController' } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'eventually sends a didNavigate event to indicate end of navigation', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate._self', { + target: '_self', + place: 'editor', + data: { dataId: 'some data' } + }, { sender: 'AxFlowController' } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'keeps the same page as before', () => { + expect( pageControllerMock.setupPage ).not.toHaveBeenCalled(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'stops the navigation timer', () => { + expect( timerMock._mockTimer.stopAndLog ).toHaveBeenCalled(); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'on navigate request to a different target', () => { + + beforeEach( done => { + pageControllerMock.tearDownPage.calls.reset(); + pageControllerMock.setupPage.calls.reset(); + eventBusMock.publish.calls.reset(); + + routerMock.router.navigateTo.and.callFake( () => { + // simulate an URL change + routerMock.triggerRouteHandler( '/welcome', {} ); + } ); + + eventBusMock.publishAndGatherReplies( 'navigateRequest.welcome', { + target: 'welcome', + data: {} + } ).then( done ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'sends a willNavigate event to indicate the start of navigation', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate.welcome', { + target: 'welcome', + place: 'welcome', + data: {} + }, { sender: 'AxFlowController' } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'asks the page controller to tear down the current page', () => { + expect( pageControllerMock.tearDownPage ).toHaveBeenCalled(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'sends a didNavigate event to indicate the end of navigation', () => { + expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate.welcome', { + target: 'welcome', + place: 'welcome', + data: {} + }, { sender: 'AxFlowController' } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'changes the active page to the requested target\'s page', () => { + expect( pageControllerMock.setupPage ).toHaveBeenCalledWith( 'dir/welcome' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'sets the log tag for the new place to', () => { + expect( logMock.setTag ).toHaveBeenCalledWith( 'PLCE', 'welcome' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'on navigate request to a different target omitting parameters', () => { + + beforeEach( done => { + routerMock.triggerRouteHandler( '/editor/:dataId', { dataId: 100 } ); + eventBusMock.publish.calls.reset(); + + awaitDidNavigate() + .then( () => { + routerMock.router.navigateTo.and.callFake( ( patterns, params ) => { + // simulate the URL change + routerMock.triggerRouteHandler( patterns[ 0 ], params ); + } ); + + return eventBusMock.publishAndGatherReplies( 'navigateRequest.next', { + target: 'next', + data: {} + } ); + } ) + .then( done, done.fail ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'uses previous parameter values where available', () => { + const { data } = eventBusMock.publish.calls.mostRecent().args[ 1 ]; + expect( data.dataId ).toEqual( 100 ); + } ); + + } ); + + } ); + +} ); diff --git a/lib/runtime/spec/flow_service_spec.js b/lib/runtime/spec/flow_service_spec.js index 600cd68e..01d2f3a5 100644 --- a/lib/runtime/spec/flow_service_spec.js +++ b/lib/runtime/spec/flow_service_spec.js @@ -3,1013 +3,54 @@ * Released under the MIT license. * http://laxarjs.org/license */ -import * as flowServiceModule from '../flow_service'; -import { deepClone, setPath } from '../../utilities/object'; -import { create as createBrowserMock } from '../../testing/browser_mock'; -import { create as createConfigurationMock } from '../../testing/configuration_mock'; -import { create as createLogMock } from '../../testing/log_mock'; -import { create as createTimerMock } from '../../testing/timer_mock'; -import { create as createEventBusMock } from '../../testing/event_bus_mock'; -import { create as createArtifactProviderMock } from '../../testing/artifact_provider_mock'; -import { create as createPageRouterMock } from './mocks/page_router_mock'; +import { create as createFlowService } from '../flow_service'; -import flowDataSource from './data/flow_data'; +const anyFunc = jasmine.any( Function ); -const configOverrides = {}; -const flowDataOverrides = {}; +describe( 'A flow service', () => { -describe( 'A flow service module', () => { - - it( 'defines a navigation target for the current placeName', () => { - expect( flowServiceModule.TARGET_SELF ).toEqual( '_self' ); - } ); - -} ); - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -describe( 'A flow service instance', () => { - - let flowData; - - let entryPlace; - let backdoorPlace; - let welcomePlace; - let editorPlace; - let evaluationPlace; - - beforeEach( () => { - flowData = deepClone( flowDataSource ); - Object.keys( flowDataOverrides ).forEach( _ => { - setPath( flowData, _, flowDataOverrides[ _ ] ); - } ); - - entryPlace = flowData.processed.entryPlace; - backdoorPlace = flowData.processed.backdoorPlace; - welcomePlace = flowData.processed.welcomePlace; - editorPlace = flowData.processed.editorPlace; - evaluationPlace = flowData.processed.evaluationPlace; - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - let artifactProviderMock; - let browserMock; - let configurationData; - let configurationMock; - let eventBusMock; let flowService; - let locationMock; - let logMock; - let pageServiceMock; - let pageRouterMock; - let pageControllerMock; - let timerMock; + let flowControllerMock; + let fakeUrl; beforeEach( () => { - eventBusMock = createEventBusMock( { nextTick: f => { window.setTimeout( f, 0 ); } } ); - - logMock = createLogMock(); - timerMock = createTimerMock(); - locationMock = createLocationMock(); - browserMock = createBrowserMock( { locationMock } ); - - artifactProviderMock = createArtifactProviderMock(); - artifactProviderMock.forFlow.mock( 'mainz', { - definition: flowData.sourceData - } ); - - configurationData = { ...createMockConfigurationData(), ...configOverrides }; - configurationMock = createConfigurationMock( configurationData ); - - pageRouterMock = createPageRouterMock(); - pageControllerMock = { - tearDownPage: jasmine.createSpy( 'tearDownPage' ).and.callFake( () => Promise.resolve() ), - setupPage: jasmine.createSpy( 'setupPage' ).and.callFake( () => Promise.resolve() ) - }; - pageServiceMock = { controller: () => pageControllerMock }; - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// + fakeUrl = 'https://fake.url/yo'; - function createLocationMock() { - return { - hash: '#!/editor', - href: 'https://server:4711/path?q=13#!/editor', - pathname: '/path', - hostname: 'server', - port: 4711, - protocol: 'https' + flowControllerMock = { + constructAbsoluteUrl: jasmine.createSpy( 'flowControllerMock.constructAbsoluteUrl' ) + .and.callFake( () => fakeUrl ) }; - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function createMockConfigurationData() { - return { - 'flow.router': { - hashbang: true - }, - 'flow.name': 'mainz' - }; - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function createFlowService() { - return flowServiceModule.create( - logMock, - timerMock, - artifactProviderMock, - eventBusMock, - configurationMock, - browserMock, - pageServiceMock, - pageRouterMock.page - ); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function startAtRoute( routeName, optionalContext ) { - return flowService.controller().loadFlow() - .then( pageRouterMock.awaitStart ) - .then( () => pageRouterMock.triggerRoute( routeName, optionalContext ) ) - .then( awaitDidNavigate ); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - function awaitDidNavigate() { - return new Promise( resolve => { - const unsubscribe = eventBusMock.subscribe( 'didNavigate', event => { - resolve( event ); - unsubscribe(); - } ); - } ); - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'using hashbang URLs, at a given place', () => { - - beforeEach( done => { - flowService = createFlowService(); - startAtRoute( '/welcome' ).then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from target and parameters, using a hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'next', { dataId: 42 } ) ) - .toEqual( 'https://server:4711/path#!/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from place name and parameters, using a hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 42 } ) ) - .toEqual( 'https://server:4711/path#!/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from place name without parameters, using a hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', {} ) ) - .toEqual( 'https://server:4711/path#!/editor' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'escapes parameters when calculating a hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'nefarious hackery' } ) ) - .toEqual( 'https://server:4711/path#!/editor/nefarious%20hackery' ); - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'insiduous?tampering=true' } ) ) - .toEqual( 'https://server:4711/path#!/editor/insiduous%3Ftampering%3Dtrue' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'double-encodes slashes when calculating a hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'evil/manipulation' } ) ) - .toEqual( 'https://server:4711/path#!/editor/evil%252Fmanipulation' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'with query-support enabled', () => { - - overrideConfig( 'flow.query.enabled', true ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'encodes additional place parameters into the query string', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { - dataId: 'theUsual', - aKey: 'some-value', - x: 'y' - } ) ) - .toEqual( 'https://server:4711/path#!/editor/theUsual?aKey=some-value&x=y' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'escapes URL syntax in parameters and values', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { - dataId: 'theUsual', - 'oth/er key': 's&me vALu/e' - } ) ) - .toEqual( 'https://server:4711/path#!/editor/theUsual?oth%2Fer%20key=s%26me%20vALu%2Fe' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'encodes boolean values as value-less parameters', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { - dataId: 'theUse', aFlag: true, bFlag: false - } ) ) - .toEqual( 'https://server:4711/path#!/editor/theUse?aFlag' ); - } ); - - } ); - - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'using pushState/HTML5 URLs with explicit base, at a given place', () => { - - beforeEach( done => { - configurationData[ 'flow.router.hashbang' ] = false; - configurationData[ 'flow.router.base' ] = 'http://server:9001/some/path'; - flowService = createFlowService(); - startAtRoute( '/welcome' ).then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from target and parameters, without hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'next', { dataId: 42 } ) ) - .toEqual( 'http://server:9001/some/path/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from place name and parameters, without hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 42 } ) ) - .toEqual( 'http://server:9001/some/path/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'calculates absolute URLs from place name without parameters, without hashbang fragment', () => { - expect( flowService.constructAbsoluteUrl( 'editor', {} ) ) - .toEqual( 'http://server:9001/some/path/editor' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'escapes parameters when calculating the path', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'nefarious hackery' } ) ) - .toEqual( 'http://server:9001/some/path/editor/nefarious%20hackery' ); - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'insiduous?tampering=true' } ) ) - .toEqual( 'http://server:9001/some/path/editor/insiduous%3Ftampering%3Dtrue' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'double-encodes slashes in path segments when calculating the path', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 'evil/manipulation' } ) ) - .toEqual( 'http://server:9001/some/path/editor/evil%252Fmanipulation' ); - } ); - + flowService = createFlowService( flowControllerMock ); } ); /////////////////////////////////////////////////////////////////////////////////////////////////////////// - describe( 'using hashbang, with explicit base', () => { - - beforeEach( done => { - configurationData[ 'flow.router.base' ] = 'http://server:9001/some/path'; - flowService = createFlowService(); - startAtRoute( '/welcome' ).then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'uses the document base URL to calculate absolute URLs from place name and parameters', () => { - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 42 } ) ) - .toEqual( 'http://server:9001/some/path#!/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'uses the document base URL to calculate URLs from place name without parameters', () => { - expect( flowService.constructAbsoluteUrl( 'editor', {} ) ) - .toEqual( 'http://server:9001/some/path#!/editor' ); - } ); - + it( 'provides a method to generate absolute URLs', () => { + expect( flowService.constructAbsoluteUrl ).toEqual( anyFunc ); } ); /////////////////////////////////////////////////////////////////////////////////////////////////////////// - describe( 'using pushState/HTML5 URLs, without explicit base', () => { - - const fakeBase = 'https://otherserver:9101/other/path'; + describe( 'asked to generate an absolute URL', () => { - beforeEach( done => { - configurationData[ 'flow.router.hashbang' ] = false; - // fake an external base href - browserMock.resolve.and.callFake( ( url, base ) => { - browserMock.resolve.and.callFake( ( url, base ) => { - expect( url ).toEqual( fakeBase ); - expect( base ).toEqual( 'https://server:4711' ); - return url; - } ); - expect( url ).toEqual( '.' ); - expect( base ).not.toBeDefined(); - return fakeBase; - } ); - flowService = createFlowService(); - startAtRoute( '/welcome' ).then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'uses the document base URL to calculate absolute URLs from place name and parameters', () => { - expect( browserMock.resolve.calls.count() ).toEqual( 2 ); - expect( flowService.constructAbsoluteUrl( 'editor', { dataId: 42 } ) ) - .toEqual( 'https://otherserver:9101/other/path/editor/42' ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'uses the document base URL to calculate URLs from place name without parameters', () => { - expect( flowService.constructAbsoluteUrl( 'editor', {} ) ) - .toEqual( 'https://otherserver:9101/other/path/editor' ); - } ); - - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'provides a flow controller', () => { + let url; beforeEach( () => { - flowService = createFlowService(); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'that has no places when the flow is not loaded', () => { - expect( flowService.controller().places() ).toEqual( {} ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'that subscribes to navigateRequest events', () => { - expect( eventBusMock.subscribe ).toHaveBeenCalledWith( - 'navigateRequest', - jasmine.any( Function ), - { subscriber: 'AxFlowController' } - ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'that validates the flow data to load', done => { - artifactProviderMock.forFlow.mock( 'mainz', { - definition: { its: 'me' } - } ); - flowService.controller().loadFlow() - .then( done.fail, err => { - expect( err ).toEqual( new Error( 'Illegal flow.json format' ) ); - expect( logMock.error ).toHaveBeenCalledWith( - 'Failed validating flow file:\n[0]', - ' - Missing required property: places. Path: "$.places".\n' + - ' - Additional properties not allowed: its. Path: "$.its".' - ); - } ) - .then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'that when the flow is loaded', () => { - - beforeEach( done => { - startAtRoute( '/editor/:dataId' ).then( done, done.fail ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'provides the loaded places', () => { - const places = flowService.controller().places(); - expect( places[ 'entry' ] ).toEqual( entryPlace ); - expect( places[ 'backdoor' ] ).toEqual( backdoorPlace ); - expect( places[ 'entry' ] ).toEqual( entryPlace ); - expect( places[ 'welcome' ] ).toEqual( welcomePlace ); - expect( places[ 'editor' ] ).toEqual( editorPlace ); - expect( places[ 'editor/:dataId' ] ).toEqual( editorPlace ); - expect( places[ 'evaluation' ] ).toEqual( evaluationPlace ); - expect( places[ 'evaluation/:dataId' ] ).toEqual( evaluationPlace ); - expect( places[ 'evaluation/:dataId/:method' ] ).toEqual( evaluationPlace ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'allows to return the place for a given navigation target or place', () => { - const { placeNameForNavigationTarget } = flowService.controller(); - expect( placeNameForNavigationTarget( 'next', welcomePlace ) ).toEqual( 'editor' ); - expect( placeNameForNavigationTarget( 'home', welcomePlace ) ).toEqual( 'entry' ); - expect( placeNameForNavigationTarget( '_self', welcomePlace ) ).toEqual( 'welcome' ); - expect( placeNameForNavigationTarget( 'editor', welcomePlace ) ).toEqual( 'editor' ); - expect( placeNameForNavigationTarget( 'next', editorPlace ) ).toEqual( 'evaluation' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'creates a redirect for places with redirectTo property', () => { - expect( pageRouterMock.page.redirect ).toHaveBeenCalledWith( '/entry', '/editor' ); - expect( pageRouterMock.page.redirect ).toHaveBeenCalledWith( '/backdoor', '/editor' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'loads the place for the currently resolved route', () => { - expect( flowService.controller().place() ).toEqual( editorPlace ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sets the log tag for the place', () => { - expect( logMock.setTag ).toHaveBeenCalledWith( 'PLCE', 'editor/:dataId' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'logs the time the navigation took to take place', () => { - expect( timerMock.started ).toHaveBeenCalled(); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'when a different place is entered through the router (e.g. because of a link)', () => { - - beforeEach( done => { - eventBusMock.publish.calls.reset(); - awaitDidNavigate().then( done, done.fail ); - pageRouterMock.triggerRoute( '/evaluation' ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'publishes a corresponding willNavigate event', () => { - // TODO (#373) the event should be 'willNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: 'evaluation/:dataId/:method', - data: {} - }, jasmine.any( Object ) ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'eventually publishes a didNavigate event', () => { - // TODO (#373) the event should be 'didNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: 'evaluation/:dataId/:method', - data: {} - }, jasmine.any( Object ) ); - } ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'with HTML5 routing and custom base', () => { - - overrideConfig( 'flow.router.hashbang', false ); - overrideConfig( 'flow.router.base', 'http://server:9001/custom/base' ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'when a different place is entered through the router (e.g. because of a link)', () => { - - beforeEach( done => { - eventBusMock.publish.calls.reset(); - awaitDidNavigate().then( done, done.fail ); - pageRouterMock.triggerRoute( '/evaluation' ); - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'publishes a corresponding willNavigate event', () => { - // TODO (#373) the event should be 'willNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: 'evaluation/:dataId/:method', - data: {} - }, jasmine.any( Object ) ); - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'eventually publishes a didNavigate event', () => { - // TODO (#373) the event should be 'didNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: 'evaluation/:dataId/:method', - data: {} - }, jasmine.any( Object ) ); - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'reflects the updated location', () => { - expect( flowService.constructAbsoluteUrl( '_self' ) ) - .toEqual( 'http://server:9001/custom/base/evaluation' ); - } ); - - } ); - - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'with support for query parameters', () => { - - overrideConfig( 'flow.query.enabled', true ); - - let lastData; - beforeEach( () => { - eventBusMock.subscribe( 'didNavigate', event => { - lastData = event.data; - } ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'publishes defaults for query parameters upon direct navigation', done => { - pageRouterMock.triggerRoute( '/step-with-options' ); - awaitDidNavigate() - .then( () => { - expect( lastData ).toEqual( { - 'optionA': 'aDefault', - 'param-b': null, - 'c&d': 'some stuff' - } ); - } ) - .then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'supports navigation with query parameters', done => { - pageRouterMock.triggerRoute( '/step-with-options/:taskId', { - params: { taskId: 'taskX' }, - querystring: 'param-b=yeah' - } ); - - awaitDidNavigate() - .then( () => { - expect( lastData ).toEqual( { - taskId: 'taskX', - optionA: 'aDefault', - 'param-b': 'yeah', - 'c&d': 'some stuff' - } ); - } ) - .then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'supports navigation with boolean place parameters', done => { - pageRouterMock.triggerRoute( '/step-with-options/:taskId', { - params: { taskId: 'taskX' }, - querystring: 'optionA' - } ); - - awaitDidNavigate() - .then( () => { - expect( lastData ).toEqual( { - taskId: 'taskX', - optionA: true, - 'param-b': null, - 'c&d': 'some stuff' - } ); - } ) - .then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'supports navigation with url-encoded parameters', done => { - pageRouterMock.triggerRoute( '/step-with-options/:taskId', { - params: { taskId: 'taskX' }, - querystring: 'c%26d=e%20f%26g' - } ); - - awaitDidNavigate() - .then( () => { - expect( lastData ).toEqual( { - taskId: 'taskX', - optionA: 'aDefault', - 'param-b': null, - 'c&d': 'e f&g' - } ); - } ) - .then( done, done.fail ); - } ); - - } ); - - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'configured with a single parameter-only placee', () => { - - overrideFlowData( 'sourceData.places', { ':just-a-param': { page: 'welcome' } } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'when the flow is loaded', () => { - - beforeEach( done => { - startAtRoute( '/:just-a-param' ).then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'detects the parameters for that place, when using self-navigation', () => { - expect( flowService.constructAbsoluteUrl( '_self', { - 'just-a-param': 'hey' - } ) ).toBe( 'https://server:4711/path#!/hey' ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'detects the parameters for that place, when using explicit navigation', () => { - expect( flowService.constructAbsoluteUrl( '', { - 'just-a-param': 'hey' - } ) ).toBe( 'https://server:4711/path#!/hey' ); - } ); - - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'when an empty URL is entered through the router', () => { - - beforeEach( done => { - eventBusMock.publish.calls.reset(); - startAtRoute( '/:just-a-param' ).then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'publishes a corresponding willNavigate event', () => { - // TODO (#373) the event should be 'willNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: ':just-a-param', - data: {} - }, jasmine.any( Object ) ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'eventually publishes a dNavigate event', () => { - // TODO (#373) the event should be 'didNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: ':just-a-param', - data: {} - }, jasmine.any( Object ) ); - } ); - - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'when a parameter value is provided by the router on entry', () => { - - beforeEach( done => { - eventBusMock.publish.calls.reset(); - startAtRoute( '/:just-a-param', { - params: { 'just-a-param': 'some%2Fvalue' } - } ).then( done, done.fail ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'publishes a corresponding willNavigate event', () => { - // TODO (#373) the event should be 'willNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: ':just-a-param', - data: { 'just-a-param': 'some/value' } - }, jasmine.any( Object ) ); - } ); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'eventually publishes a dNavigate event', () => { - // TODO (#373) the event should be 'didNavigate' without target, but the flow controller - // inserts a target based on the current place, so we have to use `jasmine.any`. - expect( eventBusMock.publish ).toHaveBeenCalledWith( jasmine.any( String ), { - target: jasmine.any( String ), - place: ':just-a-param', - data: { 'just-a-param': 'some/value' } - }, jasmine.any( Object ) ); - } ); - - } ); - - - } ); - - } ); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'with completely set-up flow controller', () => { - - const PUBLISH_OPTIONS = { sender: 'AxFlowController' }; - - beforeEach( done => { - flowService = createFlowService(); - locationMock.hash = '#!/editor/13'; - locationMock.href = `http://server:8080/${locationMock.hash}`; - - flowService.controller().loadFlow() - .then( pageRouterMock.awaitStart ) - .then( () => pageRouterMock.triggerRoute( '/editor/:dataId', { params: { dataId: '13' } } ) ) - .then( awaitDidNavigate ) - .then( () => { - pageControllerMock.tearDownPage.calls.reset(); - pageControllerMock.setupPage.calls.reset(); - } ) - .then( done, done.fail ); - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'on navigate request to the currently active place and same parameters', () => { - - beforeEach( done => { - pageRouterMock.awaitShow( '/editor/13' ) - .then( () => { - eventBusMock.publish.calls.reset(); - pageRouterMock.triggerRoute( '/editor/:dataId', { params: { dataId: '13' } } ); - } ) - .then( done, done.fail ); - - eventBusMock.publishAndGatherReplies( `navigateRequest.${flowServiceModule.TARGET_SELF}`, { - target: flowServiceModule.TARGET_SELF, - data: { dataId: 13 } - } ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'does nothing', () => { - expect( eventBusMock.publish ).not.toHaveBeenCalled(); + url = flowService.constructAbsoluteUrl( 'next', { + param: 'a-param' } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'logs this incident', () => { - expect( logMock.trace ).toHaveBeenCalledWith( - 'Canceling navigation to "editor/:dataId". Already there with same parameters.' - ); - } ); - } ); //////////////////////////////////////////////////////////////////////////////////////////////////////// - describe( 'on navigate request to a different target', () => { - - beforeEach( done => { - pageRouterMock.awaitShow( '/welcome' ) - .then( () => { - eventBusMock.publish.calls.reset(); - pageRouterMock.triggerRoute( '/welcome' ); - } ) - .then( awaitDidNavigate ) - .then( done, done.fail ); - - eventBusMock.publishAndGatherReplies( 'navigateRequest.welcome', { target: 'welcome' } ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sends a willNavigate event to indicate start of navigation', () => { - expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate.welcome', { - target: 'welcome', - place: 'welcome', - data: {} - }, PUBLISH_OPTIONS ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'asks the page controller to tear down the current page', () => { - expect( pageControllerMock.tearDownPage ).toHaveBeenCalled(); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'asks the page controller to setup the next page', () => { - expect( pageControllerMock.setupPage ).toHaveBeenCalledWith( welcomePlace.page ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sends a didNavigate event to indicate end of navigation', () => { - expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate.welcome', { - target: 'welcome', - place: 'welcome', - data: {} - }, PUBLISH_OPTIONS ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'changes the active place to the place navigated to', () => { - expect( flowService.controller().place() ).toEqual( welcomePlace ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sets the log tag for the place navigated to', () => { - expect( logMock.setTag ).toHaveBeenCalledWith( 'PLCE', 'welcome' ); - } ); - - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'on navigate request to same target with different parameters', () => { - - beforeEach( done => { - pageRouterMock.awaitShow() - .then( () => { - eventBusMock.publish.calls.reset(); - pageRouterMock.triggerRoute( '/editor/:dataId', { - params: { dataId: 'potentially/evil?data' } - } ); - } ) - .then( awaitDidNavigate ) - .then( done, done.fail ); - - eventBusMock.publishAndGatherReplies( `navigateRequest.${flowServiceModule.TARGET_SELF}`, { - target: flowServiceModule.TARGET_SELF, - data: { - dataId: 'potentially/evil?data' - } - } ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sends a willNavigate event to indicate start of navigation', () => { - expect( eventBusMock.publish ).toHaveBeenCalledWith( 'willNavigate._self', { - target: '_self', - place: 'editor/:dataId', - data: { dataId: 'potentially/evil?data' } - }, PUBLISH_OPTIONS ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'does not ask the page controller to tear down the current page', () => { - expect( pageControllerMock.tearDownPage ).not.toHaveBeenCalled(); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'does not ask the page controller to setup a page', () => { - expect( pageControllerMock.setupPage ).not.toHaveBeenCalled(); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sends a didNavigate event to indicate end of navigation', () => { - expect( eventBusMock.publish ).toHaveBeenCalledWith( 'didNavigate._self', { - target: '_self', - place: 'editor/:dataId', - data: { dataId: 'potentially/evil?data' } - }, PUBLISH_OPTIONS ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'keeps the same place as before', () => { - expect( flowService.controller().place() ).toEqual( editorPlace ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'sets the log tag to the same place as before', () => { - expect( logMock.setTag ).toHaveBeenCalledWith( 'PLCE', 'editor/:dataId' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'stops the navigation timer', () => { - expect( timerMock._mockTimer.stopAndLog ).toHaveBeenCalled(); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'encodes URI syntax in parameters values', () => { - const [ url ] = pageRouterMock.page.show.calls.mostRecent().args; - expect( url ).toEqual( '/editor/potentially%252Fevil%3Fdata' ); - } ); - - } ); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - - describe( 'on navigate request to a different target omitting parameters', () => { - - beforeEach( done => { - pageRouterMock.awaitShow() - .then( () => { - pageRouterMock.triggerRoute( '/evaluation/:dataId/:method', { - params: { dataId: '13', method: '_' } - } ); - } ) - .then( awaitDidNavigate ) - .then( done, done.fail ); - - eventBusMock.publishAndGatherReplies( 'navigateRequest.next', { target: 'next' } ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'encodes null parameters using an underscore', () => { - const [ url ] = pageRouterMock.page.show.calls.mostRecent().args; - expect( url ).toEqual( '/evaluation/13' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'uses previous parameter values where available', () => { - const { data } = eventBusMock.publish.calls.mostRecent().args[ 1 ]; - expect( data.dataId ).toEqual( '13' ); - } ); - - ///////////////////////////////////////////////////////////////////////////////////////////////////// - - it( 'returns null otherwise', () => { - const { data } = eventBusMock.publish.calls.mostRecent().args[ 1 ]; - expect( data.method ).toBe( null ); + it( 'resolves the URL using the flow controller', () => { + expect( flowControllerMock.constructAbsoluteUrl ).toHaveBeenCalledWith( 'next', { + param: 'a-param' } ); + expect( url ).toEqual( 'https://fake.url/yo' ); } ); } ); } ); - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -function overrideConfig( path, value ) { - beforeAll( () => { - configOverrides[ path ] = value; - } ); - afterAll( () => { - delete configOverrides[ path ]; - } ); -} - -////////////////////////////////////////////////////////////////////////////////////////////////////////////// - -/** - * Overrides (parts of) the flow definition, before initializing a group of tests. - * - * @param {String} path - * flow part to add/override - * @param {String} value - * flow definition to add/modify - */ -function overrideFlowData( path, value ) { - beforeAll( () => { - flowDataOverrides[ path ] = value; - } ); - afterAll( () => { - delete flowDataOverrides[ path ]; - } ); -} diff --git a/lib/runtime/spec/mocks/page_router_mock.js b/lib/runtime/spec/mocks/pagejs_mock.js similarity index 62% rename from lib/runtime/spec/mocks/page_router_mock.js rename to lib/runtime/spec/mocks/pagejs_mock.js index 007bcb30..5ade7372 100644 --- a/lib/runtime/spec/mocks/page_router_mock.js +++ b/lib/runtime/spec/mocks/pagejs_mock.js @@ -8,29 +8,12 @@ import assert from '../../../utilities/assert'; export function create() { const routeHandlers = {}; - const showReactions = {}; - - let started = false; - let resolveStartPromise; const page = jasmine.createSpy( 'pageRouterMock' ).and.callFake( normalizePageInvocation ); page.redirect = jasmine.createSpy( 'pageRouterMock.redirect' ); page.base = jasmine.createSpy( 'pageRouterMock.base' ); - - page.start = jasmine.createSpy( 'pageRouterMock.start' ).and.callFake( () => { - started = true; - if( resolveStartPromise ) { - resolveStartPromise(); - } - } ); - - page.show = jasmine.createSpy( 'pageRouterMock.show' ).and.callFake( url => { - const reaction = showReactions[ url ] || showReactions[ '*' ]; - assert.state( !!reaction, `page.show was called without matching expectation (url: ${url})` ); - reaction( url ); - delete showReactions[ url ]; - delete showReactions[ '*' ]; - } ); + page.start = jasmine.createSpy( 'pageRouterMock.start' ); + page.show = jasmine.createSpy( 'pageRouterMock.show' ); const mock = { // the actual mock-implementation for use in the flow controller @@ -38,21 +21,12 @@ export function create() { // instrumentation properties for use in spec tests routeHandlers, - showReactions, configureRouteSpy: jasmine.createSpy( 'configureRouteSpy' ) .and.callFake( ( routeName, handler ) => { routeHandlers[ routeName ] = handler; } ), triggerRoute( routeName, handlerContext ) { routeHandlers[ routeName ]( handlerContext || { params: {} } ); - }, - awaitStart() { - return started ? - Promise.resolve() : - new Promise( _ => { resolveStartPromise = _; } ); - }, - awaitShow( url ) { - return new Promise( _ => { showReactions[ url || '*' ] = _; } ); } }; @@ -81,7 +55,7 @@ export function create() { return page.start( a ); } - assert.codeIsUnreachable( 'page.js invocation not recognized: [0], [1]', a, b ); + assert.codeIsUnreachable( `page.js invocation not recognized: ${a}, ${b}` ); return null; } diff --git a/lib/runtime/spec/mocks/router_mock.js b/lib/runtime/spec/mocks/router_mock.js new file mode 100644 index 00000000..2b026dca --- /dev/null +++ b/lib/runtime/spec/mocks/router_mock.js @@ -0,0 +1,36 @@ +/** + * Copyright 2016 aixigo AG + * Released under the MIT license. + * http://laxarjs.org/license + */ + +export function create() { + + let resolveRoutes; + const routeMapAvailablePromise = new Promise( _ => { resolveRoutes = _; } ); + + const mock = { + router: { + navigateTo: jasmine.createSpy( 'navigateTo' ), + registerRoutes: jasmine.createSpy( 'registerRoutes' ).and.callFake( storeRoutes ), + constructAbsoluteUrl: jasmine.createSpy( 'constructAbsoluteUrl' ) + }, + awaitRegisterRoutes() { + return routeMapAvailablePromise; + }, + triggerRouteHandler( routePattern, parameters ) { + mock.routeMap[ routePattern ]( parameters ); + }, + triggerFallbackHandler( path ) { + mock.fallbackHandler( path ); + } + }; + + function storeRoutes( routeMap, fallbackHandler ) { + mock.routeMap = routeMap; + mock.fallbackHandler = fallbackHandler; + resolveRoutes(); + } + + return mock; +} diff --git a/lib/runtime/spec/pagejs_router_spec.js b/lib/runtime/spec/pagejs_router_spec.js new file mode 100644 index 00000000..a0ebbd38 --- /dev/null +++ b/lib/runtime/spec/pagejs_router_spec.js @@ -0,0 +1,514 @@ +/** + * Copyright 2016 aixigo AG + * Released under the MIT license. + * http://laxarjs.org/license + */ +import { create } from '../pagejs_router'; +import { create as createPagejsMock } from './mocks/pagejs_mock'; +import { create as createBrowserMock } from '../../testing/browser_mock'; +import { create as createConfigurationMock } from '../../testing/configuration_mock'; + +const anyFunc = jasmine.any( Function ); + +describe( 'A page.js router', () => { + + let pagejsMock; + let locationMock; + let browserMock; + + const configurationOverrides = {}; + let fakeDocumentBaseHref; + let configurationData; + let configurationMock; + + let spyOneBinary; + let spyOneUnary; + let spyTwo; + let baseRouteMap; + let fallbackHandlerSpy; + + let router; + + beforeEach( () => { + spyOneBinary = jasmine.createSpy( 'spyOneBinary' ); + spyOneUnary = jasmine.createSpy( 'spyOneUnary' ); + spyTwo = jasmine.createSpy( 'spyTwo' ); + + baseRouteMap = { + '/prefixOne/:paramA/:paramB': spyOneBinary, + '/prefixOne/:p': spyOneUnary, + '/prefixTwo': spyTwo + }; + fallbackHandlerSpy = jasmine.createSpy( 'fallbackHandlerSpy' ); + + pagejsMock = createPagejsMock(); + locationMock = createLocationMock(); + browserMock = createBrowserMock( { locationMock } ); + if( fakeDocumentBaseHref ) { + setupFakeDocumentBaseHref( fakeDocumentBaseHref ); + } + + configurationData = { + 'router.query.enabled': false, + ...configurationOverrides + }; + configurationMock = createConfigurationMock( configurationData ); + + router = create( pagejsMock.page, browserMock, configurationMock ); + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'allows to register routes', () => { + expect( () => { router.registerRoutes( baseRouteMap, () => {} ); } ).not.toThrow(); + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'without route map, intended for constructing URLs', () => { + + describe( 'with the hash-based routing configuration', () => { + + overrideConfig( 'router.pagejs.hashbang', true ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'allows to construct hash-based absolute URLs for a list of patterns', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixTwo' ], {} ) + ).toEqual( 'https://server:4711/path#!/prefixTwo' ); + + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:param', '/prefixTwo' ], { param: 'vanilla-ice' } ) + ).toEqual( 'https://server:4711/path#!/prefixOne/vanilla-ice' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'allows to interleave named segments and parameter placeholders', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:x/x/y/:y/:z' ], { x: 'XxX', z: 'ZzZ' } ) + ).toEqual( 'https://server:4711/path#!/prefixOne/XxX/x/y/_/ZzZ' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'ignores additional parameters', () => { + expect( + router.constructAbsoluteUrl( [ '/:x/y' ], { x: 'XX', z: 'ZZ' } ) + ).toEqual( 'https://server:4711/path#!/XX/y' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'if configured to use the query string', () => { + + overrideConfig( 'router.query.enabled', true ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'incorporates them into the query string', () => { + expect( + router.constructAbsoluteUrl( [ '/:x/y' ], { x: 'XX', z: 'ZZ' } ) + ).toEqual( 'https://server:4711/path#!/XX/y?z=ZZ' ); + } ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'incorporates additional `true` options as value-less items into the query string', () => { + expect( + router.constructAbsoluteUrl( [ '/:x' ], { x: 'XX', y: true, z: 'ZZ' } ) + ).toEqual( 'https://server:4711/path#!/XX?y&z=ZZ' ); + } ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'escapes URL syntax in query parameters and values', () => { + expect( + router.constructAbsoluteUrl( [ '/:x' ], { x: 'ha?z=3', 'oth/er key': 's&me vALu/e' } ) + ).toEqual( 'https://server:4711/path#!/ha%3Fz%3D3?oth%2Fer%20key=s%26me%20vALu%2Fe' ); + } ); + + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'configured to work without hashbang in URLs', () => { + + overrideConfig( 'router.base', '/the-base' ); + overrideConfig( 'router.pagejs.hashbang', false ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'allows to construct "real" URLs from patterns', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixTwo' ], {} ) + ).toEqual( 'https://server:4711/the-base/prefixTwo' ); + + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:param', '/prefixTwo' ], { param: 'vanilla-ice' } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/vanilla-ice' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'escapes URL syntax in parameter values', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:param' ], { param: 'nefarious hackery' } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/nefarious%20hackery' ); + + expect( + router.constructAbsoluteUrl( [ '/prefix/:a/:b' ], { a: 'a?b=x', b: '/s' } ) + ).toEqual( 'https://server:4711/the-base/prefix/a%3Fb%3Dx/%252Fs' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'double-encodes slashes in url segments', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:paramA/:paramB' ], { paramA: '/', paramB: '/s' } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/%252F/%252Fs' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'encodes empty segments as _', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:paramA/:paramB' ], { paramB: 'Hey!' } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/_/Hey!' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'double-encodes the _ in segment values to distinguish it from an empty segment', () => { + expect( + router.constructAbsoluteUrl( [ '/:a/:b/:c/:d' ], { a: '', b: '_', c: null, d: '__' } ) + ).toEqual( 'https://server:4711/the-base//%255F/_/%255F%255F' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'strips trailing empty segments from URLs', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:param' ], { param: null } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/' ); + + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:paramA/:paramB' ], { paramA: 'Hey!' } ) + ).toEqual( 'https://server:4711/the-base/prefixOne/Hey!/' ); + + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:paramA/:paramB' ], {} ) + ).toEqual( 'https://server:4711/the-base/prefixOne/' ); + + expect( + router.constructAbsoluteUrl( [ '/:segment' ], {} ) + ).toEqual( 'https://server:4711/the-base/' ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does not harm empty strings by stripping empty segments', () => { + expect( + router.constructAbsoluteUrl( [ '//:a/:b/' ], { a: '', b: null } ) + ).toEqual( 'https://server:4711/the-base///' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'configured to work without hashbang in URLs, and without a configured router base', () => { + + overrideConfig( 'router.base', null ); + beforeAll( () => { fakeDocumentBaseHref = 'http://fake-base.server/path'; } ); + afterAll( () => { fakeDocumentBaseHref = null; } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'falls back to the document base URL to generate URLs', () => { + expect( + router.constructAbsoluteUrl( [ '/prefixTwo' ], {} ) + ).toEqual( 'http://fake-base.server/path/prefixTwo' ); + + expect( + router.constructAbsoluteUrl( [ '/prefixOne/:param', '/prefixTwo' ], { param: 'vanilla-ice' } ) + ).toEqual( 'http://fake-base.server/path/prefixOne/vanilla-ice' ); + + expect( + router.constructAbsoluteUrl( [ '/' ], {} ) + ).toEqual( 'http://fake-base.server/path/' ); + } ); + + } ); + + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'initialized with a map of routes', () => { + + beforeEach( () => { + router.registerRoutes( baseRouteMap, fallbackHandlerSpy ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'starts page.js to kick off navigation', () => { + expect( pagejsMock.page.start ).toHaveBeenCalled(); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'while using hash-based URLs', () => { + + overrideConfig( 'router.pagejs.hashbang', true ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'initializes page.js with the current document path as base', () => { + expect( pagejsMock.page.base ).toHaveBeenCalledWith( '/path' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'while not using hash-based URLs', () => { + + beforeAll( () => { fakeDocumentBaseHref = 'http://some/href'; } ); + afterAll( () => { fakeDocumentBaseHref = null; } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'initializes page.js with the current document base URL as base', () => { + expect( pagejsMock.page.base ).toHaveBeenCalledWith( 'http://some/href' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'with configured base URL', () => { + + overrideConfig( 'router.base', 'http://someserver/base' ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'initializes page.js with that base', () => { + expect( pagejsMock.page.base ).toHaveBeenCalledWith( 'http://someserver/base' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'configures page.js to use each of the routes', () => { + expect( pagejsMock.configureRouteSpy ).toHaveBeenCalledWith( '/prefixOne/:paramA/:paramB', anyFunc ); + expect( pagejsMock.configureRouteSpy ).toHaveBeenCalledWith( '/prefixOne/:p', anyFunc ); + expect( pagejsMock.configureRouteSpy ).toHaveBeenCalledWith( '/prefixTwo', anyFunc ); + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when page.js handles a route change', () => { + + beforeEach( () => { + pagejsMock.triggerRoute( '/prefixTwo', { params: {} } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'runs the correct handler', () => { + expect( spyTwo ).toHaveBeenCalled(); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when page.js handles a route change with parameters', () => { + + beforeEach( () => { + pagejsMock.triggerRoute( '/prefixOne/:p', { params: { p: 'x' } } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'runs the correct handler with the respective parameters', () => { + expect( spyOneUnary ).toHaveBeenCalledWith( { p: 'x' } ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when page.js handles a route change with underscore for segment parameters', () => { + + beforeEach( () => { + pagejsMock.triggerRoute( + '/prefixOne/:paramA/:paramB', + { params: { paramA: '_', paramB: 'X' } } + ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'transforms the missing parameters to null', () => { + expect( spyOneBinary ).toHaveBeenCalledWith( { paramA: null, paramB: 'X' } ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when page.js handles a route change, passing encoded slashes or underscores', () => { + + beforeEach( () => { + pagejsMock.triggerRoute( + '/prefixOne/:paramA/:paramB', + { params: { paramA: '%2F', paramB: '%5F' } } + ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'reverts the double-encoding', () => { + expect( spyOneBinary ).toHaveBeenCalledWith( { paramA: '/', paramB: '_' } ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'when page.js handles a route change, passing query parameters', () => { + + let decodedParams; + + beforeEach( () => { + pagejsMock.triggerRoute( + '/prefixOne/:paramA/:paramB', + { params: { paramA: '_', paramB: 'XYZ' }, querystring: 'paramA=1000¶mC=5000¶mD' } + ); + decodedParams = spyOneBinary.calls.mostRecent().args[ 0 ]; + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'ignores them by default', () => { + expect( decodedParams.paramA ).toEqual( null ); + expect( decodedParams.paramC ).not.toBeDefined(); + expect( decodedParams.paramD ).not.toBeDefined(); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'if query parameters are enabled', () => { + + overrideConfig( 'router.query.enabled', true ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'adds them to the handler parameters', () => { + expect( decodedParams.paramC ).toEqual( '5000' ); + } ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does not override the segment parameters', () => { + expect( decodedParams.paramA ).toEqual( null ); + } ); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'does not override the segment parameters', () => { + expect( decodedParams.paramD ).toEqual( true ); + } ); + + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'asked to navigate based on a patterns', () => { + + beforeEach( () => { + router.navigateTo( [ '/prefixTwo' ], {} ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'triggers page.js to load the routing path for the first pattern', () => { + expect( pagejsMock.page.show ).toHaveBeenCalledWith( '/prefixTwo' ); + } ); + + } ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + + describe( 'asked to navigate based on a list of patterns, substituting paremeters', () => { + + beforeEach( () => { + router.navigateTo( [ '/prefixOne/:param', '/prefixTwo' ], { + param: 'vanilla-ice' + } ); + } ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + + it( 'triggers page.js to load the routing path for the first pattern', () => { + expect( pagejsMock.page.show ).toHaveBeenCalledWith( '/prefixOne/vanilla-ice' ); + } ); + + } ); + + } ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function overrideConfig( path, value ) { + beforeAll( () => { + configurationOverrides[ path ] = value; + } ); + afterAll( () => { + delete configurationOverrides[ path ]; + } ); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + function createLocationMock() { + return { + protocol: 'https', + hostname: 'server', + port: 4711, + pathname: '/path', + hash: '#!/editor', + href: 'https://server:4711/path#!/editor' + }; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // Allow to fake that the document contains a base element with an (external) base href, + // so that it is used if the router base is not defined. + function setupFakeDocumentBaseHref( fakeBase ) { + // First call: to determine the base href: + browserMock.resolve.and.callFake( ( url, base ) => { + // Second call: to resolve the (already absolute) router base against the document base: + browserMock.resolve.and.callFake( ( url, origin ) => { + const fakeOrigin = `${locationMock.protocol}://${locationMock.hostname}:${locationMock.port}`; + expect( url ).toEqual( fakeBase ); + expect( origin ).toEqual( fakeOrigin ); + return url; + } ); + expect( url ).toEqual( '.' ); + expect( base ).not.toBeDefined(); + return fakeBase; + } ); + } + +} ); diff --git a/lib/runtime/spec/services_spec.js b/lib/runtime/spec/services_spec.js index 5c1abdd0..b543a357 100644 --- a/lib/runtime/spec/services_spec.js +++ b/lib/runtime/spec/services_spec.js @@ -93,7 +93,6 @@ describe( 'The services factory', () => { describe( 'has a flowService property', () => { it( 'which is a flow service instance', () => { - expect( services.flowService.controller ).toEqual( jasmine.any( Function ) ); expect( services.flowService.constructAbsoluteUrl ).toEqual( jasmine.any( Function ) ); } ); diff --git a/lib/runtime/spec/spec-runner.js b/lib/runtime/spec/spec-runner.js index 1f622667..8c71f34a 100644 --- a/lib/runtime/spec/spec-runner.js +++ b/lib/runtime/spec/spec-runner.js @@ -8,11 +8,13 @@ import './area_helper_spec'; import './artifact_provider_spec'; import './browser_spec'; import './event_bus_spec'; +import './flow_controller_spec'; import './flow_service_spec'; import './heartbeat_spec'; import './layout_widget_adapter_spec'; import './log_spec'; import './page_service_spec'; +import './pagejs_router_spec'; import './plain_adapter_spec'; import './services_spec'; import './timer_spec'; diff --git a/lib/runtime/spec/widget_services_spec.js b/lib/runtime/spec/widget_services_spec.js index b145fd22..518b9e0a 100644 --- a/lib/runtime/spec/widget_services_spec.js +++ b/lib/runtime/spec/widget_services_spec.js @@ -47,7 +47,7 @@ describe( 'widget services', () => { 'x': { module: 'moduleX' } } ); eventBusMock = createEventBusMock(); - flowServiceMock = { what(){}, ever(){}, controller(){} }; + flowServiceMock = { constructAbsoluteUrl() {} }; logMock = createLogMock(); heartbeatMock = {}; pageServiceMock = { diff --git a/lib/runtime/widget_services.js b/lib/runtime/widget_services.js index e0f9c90d..b711db4a 100644 --- a/lib/runtime/widget_services.js +++ b/lib/runtime/widget_services.js @@ -54,10 +54,6 @@ export function create( const services = { ...instances }; const releaseHandlers = []; - - const axFlowService = { ...flowService }; - delete axFlowService.controller; - registerServiceFactory( 'axAreaHelper', () => createAreaHelperForWidget( widgetId ), @@ -79,7 +75,7 @@ export function create( () => { instances.axEventBus.release(); } ); registerService( 'axFeatures', features ); - registerService( 'axFlowService', axFlowService ); + registerService( 'axFlowService', flowService ); registerService( 'axGlobalEventBus', globalEventBus ); registerService( 'axGlobalLog', log ); registerService( 'axGlobalStorage', storage ); diff --git a/static/schemas/flow.js b/static/schemas/flow.js index 28679272..1abf43d0 100644 --- a/static/schemas/flow.js +++ b/static/schemas/flow.js @@ -26,43 +26,49 @@ export default { "places": { "type": "object", - "description": "The places for this flow.", - "patternProperties": { - "^([a-z][a-zA-Z0-9_]*)?": { - "type": "object", - "properties": { + "format": "topic-map", + "description": "The places for this flow. Keys (that is, place names) must be valid event topics.", + "additionalProperties": { + "type": "object", + "properties": { - "redirectTo": { - "type": "string", - "description": "The place to redirect to when hitting this place." + "patterns": { + "type": "array", + "description": "Non-empty list of URL patterns to route to this place. If omitted, the place name (prefixed with a slash) is used as the sole pattern.", + "minItems": 1, + "items": { + "type": "string" + } + }, + "page": { + "type": "string", + "description": "The page to render for this place." + }, + "redirectTo": { + "type": "string", + "description": "The place to redirect to when hitting this place." + }, + "defaultParameters": { + "type": "object", + "default": {}, + "additionalProperties": { + "type": [ "string", "boolean", "null" ] }, - "page": { + "description": "Default values for optional (query) parameters." + }, + "targets": { + "type": "object", + "format": "topic-map", + "additionalProperties": { "type": "string", - "description": "The page to render for this place." + "format": "topic" }, - "queryParameters": { - "type": "object", - "default": {}, - "additionalProperties": { - "type": [ "string", "boolean", "null" ] - }, - "description": "Default values for optional query parameters." - }, - "targets": { - "type": "object", - "patternProperties": { - "[a-z][a-zA-Z0-9_]*": { - "type": "string" - } - }, - "description": "A map of symbolic targets to places reachable from this place." - } + "description": "A map of symbolic targets to place-names reachable from this place." + } - }, - "additionalProperties": false - } - }, - "additionalProperties": false + }, + "additionalProperties": false + } } },