diff --git a/src/client/Client.js b/src/client/Client.js index 75fcc3b..04b84fb 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -239,6 +239,12 @@ module.exports = Class( 'Client' ) */ 'private _rootContext': null, + /** + * DataApi Manager + * @type {DataApiManager} + */ + 'private _dapiManager': null, + /** * User-visible validation error messages * @type {Object} @@ -338,6 +344,13 @@ module.exports = Class( 'Client' ) this.ui = this._createUi( this.nav ); + this._factory.createDataApiMediator( + this.ui, + this._dataValidator, + this.program.dapimap, + () => this.getQuote() + ).monitor( this._dapiManager ); + this._eventHandler = this._factory.createClientEventHandler( this, this._dataValidator, this.elementStyler, this.dataProxy, jQuery ); @@ -756,11 +769,11 @@ module.exports = Class( 'Client' ) try { - var dapi_manager = this._factory.createDataApiManager(); + this._dapiManager = this._factory.createDataApiManager(); var program = this._factory.createProgram( this.programId, - dapi_manager + this._dapiManager ); } catch ( e ) @@ -779,98 +792,7 @@ module.exports = Class( 'Client' ) } ); // handle field updates - dapi_manager - .on( 'fieldLoading', function( name, index ) - { - var group = _self.getUi().getCurrentStep().getElementGroup( - name - ); - - if ( !group ) - { - return; - } - - // -1 represents "all indexes" - if ( index === -1 ) - { - index = undefined; - } - } ) - .on( 'updateFieldData', function( name, index, data, fdata ) - { - var group = _self.getUi().getCurrentStep().getElementGroup( - name - ); - - if ( !group ) - { - return; - } - - var cur_data = _self._quote.getDataByName( name ); - if ( +index === -1 ) - { - // -1 is the "combined" index, representing every field - indexes = cur_data; - } - else - { - indexes = []; - indexes[ index ] = index; - } - - - var update = []; - for ( var i in indexes ) - { - var cur = undefined; - - if ( data.length ) - { - cur = cur_data[ i ]; - - update[ i ] = ( fdata[ cur ] ) - ? cur - : data[ 0 ].value; - } - else - { - update[ i ] = ''; - } - - // populate and enable field *only if* results were returned - // and if the quote has not been locked; but first, give the - // UI a chance to finish updating - ( function( index, cur ) - { - setTimeout( function() - { - group.setOptions( name, index, data, cur ); - }, 0 ); - } )( i, cur ); - } - - update.length && _self._quote.setDataByName( name, update ); - } ) - .on( 'clearFieldData', function( name, index ) - { - if ( !_self.getUi().getCurrentStep().getElementGroup( name ) ) - { - return; - } - - // clear and disable the field (if there's no value, then there - // is no point in allowing them to do something with it) - _self.getUi().getCurrentStep().getElementGroup( name ) - .clearOptions( name, index ); - } ) - .on( 'fieldLoaded', ( name, index ) => - { - _self._dataValidator.clearFailures( { - [name]: [ index ], - } ); - } ) + this._dapiManager .on( 'error', function( e ) { _self.handleError( e ); diff --git a/src/client/ClientDependencyFactory.js b/src/client/ClientDependencyFactory.js index 2ca86eb..73c8e7f 100644 --- a/src/client/ClientDependencyFactory.js +++ b/src/client/ClientDependencyFactory.js @@ -97,6 +97,8 @@ var Step = require( '../step/Step' ), diffStore = require( 'liza/system/client' ).data.diffStore, + DataApiMediator = require( './dapi/DataApiMediator' ), + Class = require( 'easejs' ).Class; @@ -371,4 +373,6 @@ module.exports = Class( 'ClientDependencyFactory', 'action$cvv2Dialog': requireh( 'Cvv2DialogEventHandler' )( jquery ) } ); }, + + createDataApiMediator: DataApiMediator, } ); diff --git a/src/client/dapi/DataApiMediator.js b/src/client/dapi/DataApiMediator.js new file mode 100644 index 0000000..bdca195 --- /dev/null +++ b/src/client/dapi/DataApiMediator.js @@ -0,0 +1,315 @@ +/** + * Data API mediator + * + * Copyright (C) 2018 R-T Specialty, LLC. + * + * This file is part of the Liza Data Collection Framework + * + * Liza is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +'use strict'; + +const { Class } = require( 'easejs' ); +const MissingDataError = require( '../../dapi/MissingDataError' ); + + +/** + * Mediate updates to system state based on DataAPI request status and + * results + * + * The UI will be updated to reflect the options returned by DataAPI + * requests. When a field is cleared of all options, any errors on that + * field will be cleared. + */ +module.exports = Class( 'DataApiMediator', +{ + /** + * UI + * @type {Ui} + */ + 'private _ui': null, + + /** + * Data validator for clearing failures + * @type {DataValidator} + */ + 'private _data_validator': null, + + /** + * DataAPI source/destination field map + * @type {Object} + */ + 'private _dapi_map': null, + + /** + * Function returning active quote + * @type {function():Quote} + */ + 'private _quotef': null, + + + /** + * Initialize mediator + * + * The provided DataValidator DATA_VALIDATOR must be the same validator + * used to produce errors on fields to ensure that its state can be + * appropriately cleared. + * + * DAPI_MAP stores destination:source field mappings, where source is + * the result of the DataAPI call and destination is the target field in + * which to store those data. + * + * Since the active quote changes at runtime, this constructor accepts a + * quote function QUOTEF to return the active quote. + * + * @param {Ui} ui UI + * @param {DataValidator} data_validator data validator + * @param {Object} dapi_map field source and destination map + * @param {function():Quote} quotef nullary function returning quote + */ + constructor( ui, data_validator, dapi_map, quotef ) + { + if ( typeof dapi_map !== 'object' ) + { + throw TypeError( "dapi_map must be a key/value object" ); + } + + this._ui = ui; + this._data_validator = data_validator; + this._dapi_map = dapi_map; + this._quotef = quotef; + }, + + + /** + * Hook given DataApiManager + * + * Handled events are updateFieldData, clearFieldData, and fieldLoaded. + * + * @param {DataApiManager} dapi_manager manager to hook + * + * @return {DataApiMediator} self + */ + 'public monitor'( dapi_manager ) + { + const handlers = [ + [ 'updateFieldData', this._updateFieldData ], + [ 'clearFieldData', this._clearFieldOptions ], + [ 'fieldLoaded', this._clearFieldFailures ], + ] + + handlers.forEach( ( [ event, handler ] ) => + dapi_manager.on( event, handler.bind( this, dapi_manager ) ) + ); + + return this; + }, + + + /** + * Set field options + * + * If the bucket value associated with NAME and INDEX are in the result + * set RESULTS, then it will be selected. Otherwise, the first result + * in RESULTS will be selected, if any. If there are no results in + * RESULTS, the set value will be the empty string. + * + * @param {DataApiManager} dapi_manager DataAPI manager + * @param {string} name field name + * @param {number} index field index + * @param {Object} val_label value and label + * @param {Object} results DataAPI result set + * + * @return {undefined} + */ + 'private _updateFieldData'( dapi_manager, name, index, val_label, results ) + { + const group = this._ui.getCurrentStep().getElementGroup( name ); + + if ( !group ) + { + return; + } + + const quote = this._quotef(); + const existing = quote.getDataByName( name ) || []; + + let indexes = []; + + // index of -1 indicates that all indexes should be affected + if ( index === -1 ) + { + indexes = existing; + } + else + { + indexes[ index ] = index; + } + + // keep existing value if it exists in the result set, otherwise + // use the first value of the set + const field_update = indexes.map( ( _, i ) => + ( results[ existing[ i ] ] ) + ? existing[ i ] + : this._getDefaultValue( val_label ) + ); + + indexes.forEach( ( _, i ) => + group.setOptions( name, i, val_label, existing[ i ] ) + ); + + + const update = this._populateWithMap( + dapi_manager, name, indexes, quote + ); + + update[ name ] = field_update; + + quote.setData( update ); + }, + + + /** + * Generate bucket update with field expansion data + * + * If multiple indexes are provided, updates will be merged. If + * expansion data are missing, then the field will be ignored. If a + * destination field is populated such that auto-expanding would + * override that datum, then that field will be excluded from the + * expansion. + * + * @param {DataApiManager} dapi_manager manager responsible for fields + * @param {string} name field name + * @param {Array} indexes field indexes + * @param {Quote} quote source quote + * + * @return {undefined} + */ + 'private _populateWithMap'( dapi_manager, name, indexes, quote ) + { + const map = this._dapi_map[ name ]; + + // calculate field expansions for each index, which contains an + // object suitable as-is for use with Quote#setData + const expansions = indexes.map( ( _, i ) => + { + try + { + return dapi_manager.getDataExpansion( + name, i, quote, map, false, {} + ); + } + catch ( e ) + { + if ( e instanceof MissingDataError ) + { + // this value is ignored below + return undefined; + } + + throw e; + } + } ); + + // produce a final update that merges each of the expansions + return expansions.reduce( ( update, expansion, i ) => + { + // it's important that we check here instead of using #filter on + // the array so that we maintain index association + if ( expansion === undefined ) + { + return update; + } + + // merge each key individually + Object.keys( expansion ).forEach( key => + { + const existing = ( quote.getDataByName( key ) || [] )[ i ]; + + // if set and non-empty, then it's already populated and we + // must leave the value alone (so as not to override + // something the user directly entered) + if ( existing !== undefined && existing !== "" ) + { + return; + } + + update[ key ] = update[ key ] || []; + update[ key ][ i ] = expansion[ key ][ i ]; + } ); + + return update; + }, {} ); + }, + + + /** + * Clear field options + * + * @param {DataApiManager} dapi_manager DataAPI manager + * @param {string} name field name + * @param {number} index field index + * + * @return {undefined} + */ + 'private _clearFieldOptions'( dapi_manager, name, index ) + { + const group = this._ui.getCurrentStep().getElementGroup( name ); + + // ignore unknown fields + if ( group === undefined ) + { + return; + } + + group.clearOptions( name, index ); + }, + + + /** + * Clear field failures + * + * @param {DataApiManager} dapi_manager DataAPI manager + * @param {string} name field name + * @param {number} index field index + * + * @return {undefined} + */ + 'private _clearFieldFailures'( dapi_manager, name, index ) + { + this._data_validator.clearFailures( { + [name]: [ index ], + } ); + }, + + + /** + * Determine default value for result set + * + * @param {Object} val_label value and label + * + * @return {string} default value for result set + */ + 'private _getDefaultValue'( val_label ) + { + // default to the empty string if no results were returned + if ( val_label.length === 0 ) + { + return ""; + } + + return ( val_label[ 0 ] || {} ).value; + }, +} ); diff --git a/src/dapi/DataApiManager.js b/src/dapi/DataApiManager.js index 9679058..73e8343 100644 --- a/src/dapi/DataApiManager.js +++ b/src/dapi/DataApiManager.js @@ -1,7 +1,7 @@ /** * Manages DataAPI requests and return data * - * Copyright (C) 2016 R-T Specialty, LLC. + * Copyright (C) 2016, 2018 R-T Specialty, LLC. * * This file is part of the Liza Data Collection Framework * @@ -19,8 +19,9 @@ * along with this program. If not, see . */ -var Class = require( 'easejs' ).Class, - EventEmitter = require( 'events' ).EventEmitter; +const { Class } = require( 'easejs' ); +const { EventEmitter } = require( 'events' ); +const MissingDataError = require( './MissingDataError' ); /** @@ -613,14 +614,14 @@ module.exports = Class( 'DataApiManager' ) ) { var field_data = ( this._fieldData[ name ] || {} )[ index ], - data = {}; + data = {}, field_value = ( diff[ name ] || bucket.getDataByName( name ) )[ index ]; // if it's undefined, then the change probably represents a delete if ( field_value === undefined ) { ( this._fieldDataEmitted[ name ] || [] )[ index ] = false; - return; + return {}; } // if we have no field data, try the "combined" index @@ -638,9 +639,9 @@ module.exports = Class( 'DataApiManager' ) if ( !predictive && !( data ) && ( field_value !== '' ) ) { // hmm..that's peculiar. - this.emit( 'error', Error( + throw MissingDataError( 'Data missing for field ' + name + '[' + index + ']!' - ) ); + ); } else if ( !data ) { diff --git a/src/dapi/MissingDataError.js b/src/dapi/MissingDataError.js new file mode 100644 index 0000000..0c72611 --- /dev/null +++ b/src/dapi/MissingDataError.js @@ -0,0 +1,26 @@ +/** + * MissingDataError + * + * Copyright (C) 2018 R-T Specialty, LLC. + * + * This file is part of the Liza Data Collection Framework + * + * liza is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const { Class } = require( 'easejs' ); + + +module.exports = Class( 'MissingDataError' ) + .extend( Error, {} ); diff --git a/test/client/dapi/DataApiMediatorTest.js b/test/client/dapi/DataApiMediatorTest.js new file mode 100644 index 0000000..fee5f56 --- /dev/null +++ b/test/client/dapi/DataApiMediatorTest.js @@ -0,0 +1,525 @@ +/** + * Tests DataApiMediator + * + * Copyright (C) 2018 R-T Specialty, LLC. + * + * This file is part of the Liza Data Collection Framework + * + * Liza is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +'use strict'; + +const { expect } = require( 'chai' ); + +const { + client: { dapi: { DataApiMediator: Sut } }, + dapi: { MissingDataError }, +} = require( '../../../' ); + + +describe( "DataApiMediator", () => +{ + it( "returns self on #monitor", () => + { + const dapi_manager = createStubDapiManager(); + const sut = Sut( {}, {}, {} ); + + expect( sut.monitor( dapi_manager ) ).to.equal( sut ); + } ); + + + describe( "updateFieldData event", () => + { + it( "ignores unknown fields", () => + { + const dapi_manager = createStubDapiManager(); + + const getQuote = () => ( { + getDataByName: () => {}, + setDataByName: () => {}, + } ); + + const ui = createStubUi( {} ); // no field groups + const sut = Sut( ui, {}, {}, getQuote ).monitor( dapi_manager ); + + dapi_manager.emit( 'updateFieldData', '', 0, {}, {} ); + } ); + + [ + { + label: "keeps existing value if in result set (first index)", + name: 'foo', + index: 0, + value: { foo: [ "first", "second" ] }, + expected: { + foo: [ "first" ], + dest1: [ "src1data" ], + dest2: [ "src2data" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ { + dest1: [ "src1data" ], + dest2: [ "src2data" ], + } ], + }, + { + label: "keeps existing value if in result set (second index)", + name: 'bar', + index: 1, + value: { bar: [ "first", "second" ] }, + expected: { + bar: [ , "second" ], + dest1: [ , "src1data_2" ], + dest2: [ , "src2data_2" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { + first: {}, + second: { src1: "src1data_2", src2: "src2data_2" }, + }, + + expansion: [ , { + dest1: [ , "src1data_2" ], + dest2: [ , "src2data_2" ], + } ], + }, + { + label: "keeps existing value if in result set (all indexes)", + name: 'bar', + index: -1, + value: { bar: [ "first", "second" ] }, + expected: { + bar: [ "first", "second" ], + dest1: [ "src1data", "src1data_2" ], + dest2: [ "src2data", "src2data_2" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: { src1: "src1data_2", src2: "src2data_2" }, + }, + + expansion: [ + { + dest1: [ "src1data" ], + dest2: [ "src2data" ], + }, + { + dest1: [ , "src1data_2" ], + dest2: [ , "src2data_2" ], + }, + ], + }, + + { + label: "uses first value of result if existing not in result set (first index)", + name: 'foo', + index: 0, + value: { foo: [ "does not", "exist" ] }, + expected: { + foo: [ "first result" ], + desta: [ "src1data" ], + destb: [ "src2data" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ { + desta: [ "src1data" ], + destb: [ "src2data" ], + } ], + }, + { + label: "uses first value of result if existing not in result set (second index)", + name: 'foo', + index: 1, + value: { foo: [ "does not", "exist" ] }, + expected: { + foo: [ , "first result" ], + desta: [ , "src1data" ], + destb: [ , "src2data" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ , { + desta: [ , "src1data" ], + destb: [ , "src2data" ], + } ], + }, + { + label: "uses first value of result if existing not in result set (all indexes)", + name: 'foo', + index: -1, + value: { foo: [ "does not", "exist" ] }, + expected: { + foo: [ "first result", "first result" ], + desta: [ "src1data", "src1data" ], + destb: [ "src1data", "src2data" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ + { + desta: [ "src1data" ], + destb: [ "src1data" ], + }, + { + desta: [ , "src1data" ], + destb: [ , "src2data" ], + }, + ], + }, + + { + label: "uses empty string if empty result set (first index)", + name: 'foo', + index: 0, + value: { foo: [ "foo" ] }, + expected: { + foo: [ "" ], + dest1: [ "" ], + }, + + val_label: [], + results: {}, + expansion: [ { + dest1: [ "" ], + } ], + }, + { + label: "uses empty string if empty result set (second index)", + name: 'foo', + index: 1, + value: { foo: [ "foo", "bar" ] }, + expected: { + foo: [ , "" ], + dest1: [ , "" ], + }, + + val_label: [], + results: {}, + expansion: [ , { + dest1: [ , "" ], + } ], + }, + { + label: "uses empty string if empty result set (all indexes)", + name: 'foo', + index: -1, + value: { foo: [ "foo", "bar" ] }, + expected: { + foo: [ "", "" ], + dest1: [ "", "" ], + dest2: [ "", "" ], + }, + + val_label: [], + results: {}, + expansion: [ + { + dest1: [ "" ], + dest2: [ "" ], + }, + { + dest1: [ , "" ], + dest2: [ , "" ], + }, + ], + }, + + { + label: "does not auto-expand into non-empty fields", + name: 'foo', + index: 0, + value: { + foo: [ "first", "second" ], + dest1: [ "leave alone" ], + dest2: [ "" ], + }, + expected: { + foo: [ "first" ], + // dest1 missing because it is already populated + dest2: [ "src2data" ], + }, + + val_label: [ + { value: "first result", label: "first" }, + ], + + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ { + dest1: [ "src1data" ], + dest2: [ "src2data" ], + } ], + } + ].forEach( ( { + label, name, index, value, expected, val_label, results, expansion + } ) => + { + it( label, done => + { + let set_options = false; + + const quote = { + getDataByName( given_name ) + { + return value[ given_name ]; + }, + + setData( given_data ) + { + expect( given_data ).to.deep.equal( expected ); + + // should have called setOptions by now + expect( set_options ).to.be.true; + + done(); + }, + }; + + const getQuote = () => quote; + + const dapi_manager = createStubDapiManager( expansion ); + + // this isn't a valid map, but comparing the objects will + // ensure that the map is actually used + const dapimap = { foo: {}, bar: {} }; + + dapi_manager.getDataExpansion = ( + given_name, given_index, given_quote, given_map, + predictive, diff + ) => + { + expect( given_name ).to.equal( name ); + expect( given_quote ).to.equal( quote ); + expect( given_map ).to.deep.equal( dapimap ); + expect( predictive ).to.be.false; + expect( diff ).to.deep.equal( {} ); + + return expansion[ given_index ]; + }; + + const field_groups = { + [name]: { + setOptions( given_name, given_index, given_data, given_cur ) + { + // index is implicitly tested by the given_cur line + expect( given_name ).to.equal( name ); + expect( given_data ).to.deep.equal( val_label ); + expect( given_cur ).to.equal( value[ given_name ][ given_index ] ); + + set_options = true; + }, + }, + }; + + const ui = createStubUi( field_groups ); + + const sut = Sut( ui, {}, { [name]: dapimap }, getQuote ) + .monitor( dapi_manager ); + + dapi_manager.emit( + 'updateFieldData', name, index, val_label, results + ); + } ); + } ); + + + it( 'does not perform expansion if data are not available', done => + { + const dapi_manager = createStubDapiManager(); + + dapi_manager.getDataExpansion = () => + { + throw MissingDataError( + 'this should happen, but should be caught' + ); + }; + + const name = 'foo'; + const value = 'bar'; + + const getQuote = () => ( { + getDataByName: () => [ value ], + setData( given_data ) + { + // only the value should be set with no expansion data + expect( given_data ).to.deep.equal( { + [name]: [ value ], + } ); + + done(); + }, + } ); + + const field_groups = { [name]: { setOptions() {} } }; + + const ui = createStubUi( field_groups ); + const sut = Sut( ui, {}, {}, getQuote ).monitor( dapi_manager ); + + const val_label = [ + { value: value, label: "bar" }, + ]; + + dapi_manager.emit( 'updateFieldData', name, 0, val_label, {} ); + } ); + } ); + + + describe( "on clearFieldData event", () => + { + it( "ignores unknown fields", () => + { + const dapi_manager = createStubDapiManager(); + + const field_groups = {}; // no groups + + const ui = createStubUi( field_groups ); + const sut = Sut( ui, {}, {} ).monitor( dapi_manager ); + + dapi_manager.emit( 'clearFieldData', 'unknown', 0 ); + } ); + + it( "clears field", done => + { + const dapi_manager = createStubDapiManager(); + + const name = 'foo'; + const index = 3; + + const field_groups = { + [name]: { + clearOptions( given_name, given_index ) + { + expect( given_name ).to.equal( name ); + expect( given_index ).to.equal( index ); + + done(); + }, + }, + }; + + const ui = createStubUi( field_groups ); + const sut = Sut( ui, {}, {} ).monitor( dapi_manager ); + + dapi_manager.emit( 'clearFieldData', name, index ); + } ); + } ); + + + describe( "on fieldLoaded event", () => + { + it( "clears failures for field", done => + { + const dapi_manager = createStubDapiManager(); + + const name = 'bar'; + const index = 2; + + const data_validator = { + clearFailures( data ) + { + expect( data[ name ] ).to.deep.equal( [ index ] ); + done(); + }, + }; + + const ui = {}; // unused by this event + const sut = Sut( ui, data_validator, {} ).monitor( dapi_manager ); + + dapi_manager.emit( 'fieldLoaded', name, index ); + } ); + } ); +} ); + + +function createStubDapiManager() +{ + const callbacks = {}; + + return { + on( name, callback ) + { + callbacks[ name ] = callback; + }, + + emit( name ) + { + // we don't support rest yet in our version of node + const data = Array.prototype.slice.call( arguments, 1 ); + + callbacks[ name ].apply( null, data ); + }, + }; +} + + +function createStubUi( field_groups ) +{ + return { + getCurrentStep: () => ( { + getElementGroup: name => field_groups[ name ] + } ) + }; +}