From e25bec5ac06960c3f5ade5c1eaa8e47ec379e2ad Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 3 Jul 2018 09:37:59 -0400 Subject: [PATCH 1/4] DataApiMediator: New class This extracts existing code from Client and adds tests. The glue code is far from ideal and highlights the amount of work needed to decouple Client from so many parts of the system. * src/client/Client.js (_dapiManager): New field. (_init): Use DataApiMediator. (_createProgram): Assign `_dapiManager' (this is not at all ideal). Remove hooks from it: fieldLoading, updateFieldData, clearFieldData. * src/client/ClientDependencyFactory.js (createDataApiMediator): New alias to DataApiMediator constructor. * src/client/dapi/DataApiMediator.js: New class. * test/client/dapi/DataApiMediatorTest.js: New test case. DEV-3257 --- src/client/Client.js | 109 ++------ src/client/ClientDependencyFactory.js | 4 + src/client/dapi/DataApiMediator.js | 213 ++++++++++++++++ test/client/dapi/DataApiMediatorTest.js | 319 ++++++++++++++++++++++++ 4 files changed, 551 insertions(+), 94 deletions(-) create mode 100644 src/client/dapi/DataApiMediator.js create mode 100644 test/client/dapi/DataApiMediatorTest.js diff --git a/src/client/Client.js b/src/client/Client.js index 75fcc3b..75d5c56 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,12 @@ module.exports = Class( 'Client' ) this.ui = this._createUi( this.nav ); + this._factory.createDataApiMediator( + this.ui, + this._dataValidator, + () => this.getQuote() + ).monitor( this._dapiManager ); + this._eventHandler = this._factory.createClientEventHandler( this, this._dataValidator, this.elementStyler, this.dataProxy, jQuery ); @@ -756,11 +768,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 +791,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..67738ae --- /dev/null +++ b/src/client/dapi/DataApiMediator.js @@ -0,0 +1,213 @@ +/** + * 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' ); + + +/** + * 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, + + /** + * 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. + * + * 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 {function():Quote} quotef nullary function returning quote + */ + constructor( ui, data_validator, quotef ) + { + this._ui = ui; + this._data_validator = data_validator; + 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 ) ) + ); + + 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 {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'( 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 update = indexes.map( ( _, i ) => + ( results[ existing[ i ] ] ) + ? existing[ i ] + : this._getDefaultValue( val_label ) + ); + + indexes.forEach( ( _, i ) => + group.setOptions( name, i, val_label, existing[ i ] ) + ); + + quote.setDataByName( name, update ); + }, + + + /** + * Clear field options + * + * @param {string} name field name + * @param {number} index field index + * + * @return {undefined} + */ + 'private _clearFieldOptions'( name, index ) + { + const group = this._ui.getCurrentStep().getElementGroup( name ); + + // ignore unknown fields + if ( group === undefined ) + { + return; + } + + group.clearOptions( name, index ); + }, + + + /** + * Clear field failures + * + * @param {string} name field name + * @param {number} index field index + * + * @return {undefined} + */ + 'private _clearFieldFailures'( 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/test/client/dapi/DataApiMediatorTest.js b/test/client/dapi/DataApiMediatorTest.js new file mode 100644 index 0000000..60e9c9d --- /dev/null +++ b/test/client/dapi/DataApiMediatorTest.js @@ -0,0 +1,319 @@ +/** + * 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 Sut = require( '../../../' ).client.dapi.DataApiMediator; + + +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: [ "first", "second" ], + expected: [ "first" ], + + val_label: [ + { value: "first result", label: "first" }, + ], + + results: { first: {}, second: {} }, + }, + { + label: "keeps existing value if in result set (second index)", + name: 'bar', + index: 1, + value: [ "first", "second" ], + expected: [ , "second" ], + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { first: {}, second: {} }, + }, + { + label: "keeps existing value if in result set (all indexes)", + name: 'bar', + index: -1, + value: [ "first", "second" ], + expected: [ "first", "second" ], + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: { first: {}, second: {} }, + }, + + { + label: "uses first value of result if existing not in result set (first index)", + name: 'foo', + index: 0, + value: [ "does not", "exist" ], + expected: [ "first result" ], + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: {}, + }, + { + label: "uses first value of result if existing not in result set (second index)", + name: 'foo', + index: 1, + value: [ "does not", "exist" ], + expected: [ , "first result" ], + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: {}, + }, + { + label: "uses first value of result if existing not in result set (all indexes)", + name: 'foo', + index: -1, + value: [ "does not", "exist" ], + expected: [ "first result", "first result" ], + + val_label: [ + { value: "first result", label: "first" }, + { value: "second result", label: "second" }, + ], + + results: {}, + }, + + { + label: "uses empty string if empty result set (first index)", + name: 'foo', + index: 0, + value: [ "foo" ], + expected: [ "" ], + + val_label: [], + results: {}, + }, + { + label: "uses empty string if empty result set (second index)", + name: 'foo', + index: 1, + value: [ "foo", "bar" ], + expected: [ , "" ], + + val_label: [], + results: {}, + }, + { + label: "uses empty string if empty result set (all indexes)", + name: 'foo', + index: -1, + value: [ "foo", "bar" ], + expected: [ "", "" ], + + val_label: [], + results: {}, + }, + ].forEach( ( { label, name, index, value, expected, val_label, results }, i ) => + { + it( label, done => + { + let set_options = false; + + const getQuote = () => ( { + getDataByName( given_name ) + { + expect( given_name ).to.equal( name ); + return value; + }, + + setDataByName( given_name, given_data ) + { + expect( given_name ).to.equal( name ); + expect( given_data ).to.deep.equal( expected ); + + // should have called setOptions by now + expect( set_options ).to.be.true; + + done(); + }, + } ); + + const dapi_manager = createStubDapiManager(); + + 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_index ] ); + + set_options = true; + }, + }, + }; + + const ui = createStubUi( field_groups ); + const sut = Sut( ui, {}, getQuote ).monitor( dapi_manager ); + + dapi_manager.emit( + 'updateFieldData', name, index, val_label, results + ); + } ); + } ); + } ); + + + 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 ] + } ) + }; +} From 160ab01f9a7b99dbea904b20c5711c358f190187 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Fri, 6 Jul 2018 11:57:10 -0400 Subject: [PATCH 2/4] DataApiMediator: setData{ByName=>} to prepare for multi-field set See following commit. * src/client/dapi/DataApiMediator.js (_updateFieldData): `setData{ByName=>}'. * test/client/dapi/DataApiMediatorTest.js: Update respective tests. DEV-3257 --- src/client/dapi/DataApiMediator.js | 2 +- test/client/dapi/DataApiMediatorTest.js | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/client/dapi/DataApiMediator.js b/src/client/dapi/DataApiMediator.js index 67738ae..2778f58 100644 --- a/src/client/dapi/DataApiMediator.js +++ b/src/client/dapi/DataApiMediator.js @@ -151,7 +151,7 @@ module.exports = Class( 'DataApiMediator', group.setOptions( name, i, val_label, existing[ i ] ) ); - quote.setDataByName( name, update ); + quote.setData( { [name]: update } ); }, diff --git a/test/client/dapi/DataApiMediatorTest.js b/test/client/dapi/DataApiMediatorTest.js index 60e9c9d..8ad4d31 100644 --- a/test/client/dapi/DataApiMediatorTest.js +++ b/test/client/dapi/DataApiMediatorTest.js @@ -59,7 +59,7 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "first", "second" ], - expected: [ "first" ], + expected: { foo: [ "first" ] }, val_label: [ { value: "first result", label: "first" }, @@ -72,7 +72,7 @@ describe( "DataApiMediator", () => name: 'bar', index: 1, value: [ "first", "second" ], - expected: [ , "second" ], + expected: { bar: [ , "second" ] }, val_label: [ { value: "first result", label: "first" }, @@ -86,7 +86,7 @@ describe( "DataApiMediator", () => name: 'bar', index: -1, value: [ "first", "second" ], - expected: [ "first", "second" ], + expected: { bar: [ "first", "second" ] }, val_label: [ { value: "first result", label: "first" }, @@ -101,7 +101,7 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "does not", "exist" ], - expected: [ "first result" ], + expected: { foo: [ "first result" ] }, val_label: [ { value: "first result", label: "first" }, @@ -115,7 +115,7 @@ describe( "DataApiMediator", () => name: 'foo', index: 1, value: [ "does not", "exist" ], - expected: [ , "first result" ], + expected: { foo: [ , "first result" ] }, val_label: [ { value: "first result", label: "first" }, @@ -129,7 +129,7 @@ describe( "DataApiMediator", () => name: 'foo', index: -1, value: [ "does not", "exist" ], - expected: [ "first result", "first result" ], + expected: { foo: [ "first result", "first result" ] }, val_label: [ { value: "first result", label: "first" }, @@ -144,7 +144,7 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "foo" ], - expected: [ "" ], + expected: { foo: [ "" ] }, val_label: [], results: {}, @@ -154,7 +154,7 @@ describe( "DataApiMediator", () => name: 'foo', index: 1, value: [ "foo", "bar" ], - expected: [ , "" ], + expected: { foo: [ , "" ] }, val_label: [], results: {}, @@ -164,7 +164,7 @@ describe( "DataApiMediator", () => name: 'foo', index: -1, value: [ "foo", "bar" ], - expected: [ "", "" ], + expected: { foo: [ "", "" ] }, val_label: [], results: {}, @@ -182,9 +182,8 @@ describe( "DataApiMediator", () => return value; }, - setDataByName( given_name, given_data ) + setData( given_data ) { - expect( given_name ).to.equal( name ); expect( given_data ).to.deep.equal( expected ); // should have called setOptions by now From 839952a56d76b2236a70ec2dbc5c193ecbb72504 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 17 Jul 2018 11:39:08 -0400 Subject: [PATCH 3/4] [DEV-3257] DataApiMediator: Auto-expand into fields on reply [*] [*] You should not use this commit directly since this may wipe out data in fields the user has changed. See future commit where this situation is properly handled. * src/client/Client (_init): Provide dapimap to DataApiMediator instance. * src/client/dapi/DataApiMediator.js (_dapi_map): New field. (constructor): Accept dapimap. BC break (which is okay, since this is still part of a topic branch). Assing to _dapi_map. Update docblock. (monitor): Bind `dapi_manager' to first argument of handlers. (_updateFieldData): Accept `dapi_manager' as first argument. Use `_populateWithMap' to generate additional update data. (_populateWithMap): New method. (_clearFieldFailures): Accept `dapi_manager' as first argument. * src/dapi/DataApiManager.js: Update copyright year. (getDataExpansion): Return empty object (consistent with interface) rather than `undefined' when field value is undefined. Use {Error=>MissingDataError} when field data are missing. Throw instead of emit. Fix missing comma in var declarations. * src/dapi/MissingDataError.js: New class. * test/client/dapi/DataApiMediatorTest.js: Update test data to test field expansion. New test against ignoring field expansion when data are not available. Update Sut constructors of other tests for new dapimap parameter. DEV-3257 --- src/client/Client.js | 1 + src/client/dapi/DataApiMediator.js | 117 ++++++++++-- src/dapi/DataApiManager.js | 15 +- src/dapi/MissingDataError.js | 26 +++ test/client/dapi/DataApiMediatorTest.js | 230 +++++++++++++++++++++--- 5 files changed, 342 insertions(+), 47 deletions(-) create mode 100644 src/dapi/MissingDataError.js diff --git a/src/client/Client.js b/src/client/Client.js index 75d5c56..04b84fb 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -347,6 +347,7 @@ module.exports = Class( 'Client' ) this._factory.createDataApiMediator( this.ui, this._dataValidator, + this.program.dapimap, () => this.getQuote() ).monitor( this._dapiManager ); diff --git a/src/client/dapi/DataApiMediator.js b/src/client/dapi/DataApiMediator.js index 2778f58..2a757d2 100644 --- a/src/client/dapi/DataApiMediator.js +++ b/src/client/dapi/DataApiMediator.js @@ -21,7 +21,8 @@ 'use strict'; -const { Class } = require( 'easejs' ); +const { Class } = require( 'easejs' ); +const MissingDataError = require( '../../dapi/MissingDataError' ); /** @@ -46,6 +47,12 @@ module.exports = Class( 'DataApiMediator', */ 'private _data_validator': null, + /** + * DataAPI source/destination field map + * @type {Object} + */ + 'private _dapi_map': null, + /** * Function returning active quote * @type {function():Quote} @@ -60,17 +67,28 @@ module.exports = Class( 'DataApiMediator', * 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, quotef ) + 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; }, @@ -93,7 +111,7 @@ module.exports = Class( 'DataApiMediator', ] handlers.forEach( ( [ event, handler ] ) => - dapi_manager.on( event, handler.bind( this ) ) + dapi_manager.on( event, handler.bind( this, dapi_manager ) ) ); return this; @@ -108,14 +126,15 @@ module.exports = Class( 'DataApiMediator', * in RESULTS will be selected, if any. If there are no results in * RESULTS, the set value will be the empty string. * - * @param {string} name field name - * @param {number} index field index - * @param {Object} val_label value and label - * @param {Object} results DataAPI result set + * @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'( name, index, val_label, results ) + 'private _updateFieldData'( dapi_manager, name, index, val_label, results ) { const group = this._ui.getCurrentStep().getElementGroup( name ); @@ -141,7 +160,7 @@ module.exports = Class( 'DataApiMediator', // keep existing value if it exists in the result set, otherwise // use the first value of the set - const update = indexes.map( ( _, i ) => + const field_update = indexes.map( ( _, i ) => ( results[ existing[ i ] ] ) ? existing[ i ] : this._getDefaultValue( val_label ) @@ -151,19 +170,88 @@ module.exports = Class( 'DataApiMediator', group.setOptions( name, i, val_label, existing[ i ] ) ); - quote.setData( { [name]: update } ); + + 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. + * + * @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 => + { + update[ key ] = update[ key ] || []; + update[ key ][ i ] = expansion[ key ][ i ]; + } ); + + return update; + }, {} ); }, /** * Clear field options * - * @param {string} name field name - * @param {number} index field index + * @param {DataApiManager} dapi_manager DataAPI manager + * @param {string} name field name + * @param {number} index field index * * @return {undefined} */ - 'private _clearFieldOptions'( name, index ) + 'private _clearFieldOptions'( dapi_manager, name, index ) { const group = this._ui.getCurrentStep().getElementGroup( name ); @@ -180,12 +268,13 @@ module.exports = Class( 'DataApiMediator', /** * Clear field failures * + * @param {DataApiManager} dapi_manager DataAPI manager * @param {string} name field name * @param {number} index field index * * @return {undefined} */ - 'private _clearFieldFailures'( name, index ) + 'private _clearFieldFailures'( dapi_manager, name, index ) { this._data_validator.clearFailures( { [name]: [ index ], 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 index 8ad4d31..5272fe4 100644 --- a/test/client/dapi/DataApiMediatorTest.js +++ b/test/client/dapi/DataApiMediatorTest.js @@ -22,7 +22,11 @@ 'use strict'; const { expect } = require( 'chai' ); -const Sut = require( '../../../' ).client.dapi.DataApiMediator; + +const { + client: { dapi: { DataApiMediator: Sut } }, + dapi: { MissingDataError }, +} = require( '../../../' ); describe( "DataApiMediator", () => @@ -48,7 +52,7 @@ describe( "DataApiMediator", () => } ); const ui = createStubUi( {} ); // no field groups - const sut = Sut( ui, {}, getQuote ).monitor( dapi_manager ); + const sut = Sut( ui, {}, {}, getQuote ).monitor( dapi_manager ); dapi_manager.emit( 'updateFieldData', '', 0, {}, {} ); } ); @@ -59,41 +63,83 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "first", "second" ], - expected: { foo: [ "first" ] }, + expected: { + foo: [ "first" ], + dest1: [ "src1data" ], + dest2: [ "src2data" ], + }, val_label: [ { value: "first result", label: "first" }, ], - results: { first: {}, second: {} }, + 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: [ "first", "second" ], - expected: { bar: [ , "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: {} }, + 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: [ "first", "second" ], - expected: { 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: {}, 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" ], + }, + ], }, { @@ -101,42 +147,84 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "does not", "exist" ], - expected: { foo: [ "first result" ] }, + expected: { + foo: [ "first result" ], + desta: [ "src1data" ], + destb: [ "src2data" ], + }, val_label: [ { value: "first result", label: "first" }, { value: "second result", label: "second" }, ], - results: {}, + 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: [ "does not", "exist" ], - expected: { foo: [ , "first result" ] }, + expected: { + foo: [ , "first result" ], + desta: [ , "src1data" ], + destb: [ , "src2data" ], + }, val_label: [ { value: "first result", label: "first" }, { value: "second result", label: "second" }, ], - results: {}, + 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: [ "does not", "exist" ], - expected: { foo: [ "first result", "first result" ] }, + 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: {}, + results: { + first: { src1: "src1data", src2: "src2data" }, + second: {}, + }, + + expansion: [ + { + desta: [ "src1data" ], + destb: [ "src1data" ], + }, + { + desta: [ , "src1data" ], + destb: [ , "src2data" ], + }, + ], }, { @@ -144,38 +232,66 @@ describe( "DataApiMediator", () => name: 'foo', index: 0, value: [ "foo" ], - expected: { 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", "bar" ], - expected: { foo: [ , "" ] }, + expected: { + foo: [ , "" ], + dest1: [ , "" ], + }, val_label: [], results: {}, + expansion: [ , { + dest1: [ , "" ], + } ], }, { label: "uses empty string if empty result set (all indexes)", name: 'foo', index: -1, value: [ "foo", "bar" ], - expected: { foo: [ "", "" ] }, + expected: { + foo: [ "", "" ], + dest1: [ "", "" ], + dest2: [ "", "" ], + }, val_label: [], results: {}, + expansion: [ + { + dest1: [ "" ], + dest2: [ "" ], + }, + { + dest1: [ , "" ], + dest2: [ , "" ], + }, + ], }, - ].forEach( ( { label, name, index, value, expected, val_label, results }, i ) => + ].forEach( ( { + label, name, index, value, expected, val_label, results, expansion + } ) => { it( label, done => { let set_options = false; - const getQuote = () => ( { + const quote = { getDataByName( given_name ) { expect( given_name ).to.equal( name ); @@ -191,9 +307,29 @@ describe( "DataApiMediator", () => done(); }, - } ); + }; - const dapi_manager = createStubDapiManager(); + 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]: { @@ -209,14 +345,56 @@ describe( "DataApiMediator", () => }, }; - const ui = createStubUi( field_groups ); - const sut = Sut( ui, {}, getQuote ).monitor( dapi_manager ); + 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, {} ); + } ); } ); @@ -229,7 +407,7 @@ describe( "DataApiMediator", () => const field_groups = {}; // no groups const ui = createStubUi( field_groups ); - const sut = Sut( ui ).monitor( dapi_manager ); + const sut = Sut( ui, {}, {} ).monitor( dapi_manager ); dapi_manager.emit( 'clearFieldData', 'unknown', 0 ); } ); @@ -254,7 +432,7 @@ describe( "DataApiMediator", () => }; const ui = createStubUi( field_groups ); - const sut = Sut( ui ).monitor( dapi_manager ); + const sut = Sut( ui, {}, {} ).monitor( dapi_manager ); dapi_manager.emit( 'clearFieldData', name, index ); } ); @@ -279,7 +457,7 @@ describe( "DataApiMediator", () => }; const ui = {}; // unused by this event - const sut = Sut( ui, data_validator ).monitor( dapi_manager ); + const sut = Sut( ui, data_validator, {} ).monitor( dapi_manager ); dapi_manager.emit( 'fieldLoaded', name, index ); } ); From abc2564d9c864dc186a002a277e64ee6a9b3be87 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 17 Jul 2018 15:28:01 -0400 Subject: [PATCH 4/4] DataApiMediator: Do not auto-expand into populated fields * src/client/dapi/DataApiMediator.js (_populateWithMap): Update docblock. Ignore field during expansion if it would overwrite an existing value. * test/client/dapi/DataApiMediatorTest.js: Update tests data to include values for all bucket fields, not just `name'. Add test for new condition. DEV-3257 --- src/client/dapi/DataApiMediator.js | 15 ++++++- test/client/dapi/DataApiMediatorTest.js | 53 +++++++++++++++++++------ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/client/dapi/DataApiMediator.js b/src/client/dapi/DataApiMediator.js index 2a757d2..bdca195 100644 --- a/src/client/dapi/DataApiMediator.js +++ b/src/client/dapi/DataApiMediator.js @@ -185,7 +185,10 @@ module.exports = Class( 'DataApiMediator', * 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. + * 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 @@ -233,6 +236,16 @@ module.exports = Class( 'DataApiMediator', // 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 ]; } ); diff --git a/test/client/dapi/DataApiMediatorTest.js b/test/client/dapi/DataApiMediatorTest.js index 5272fe4..fee5f56 100644 --- a/test/client/dapi/DataApiMediatorTest.js +++ b/test/client/dapi/DataApiMediatorTest.js @@ -62,7 +62,7 @@ describe( "DataApiMediator", () => label: "keeps existing value if in result set (first index)", name: 'foo', index: 0, - value: [ "first", "second" ], + value: { foo: [ "first", "second" ] }, expected: { foo: [ "first" ], dest1: [ "src1data" ], @@ -87,7 +87,7 @@ describe( "DataApiMediator", () => label: "keeps existing value if in result set (second index)", name: 'bar', index: 1, - value: [ "first", "second" ], + value: { bar: [ "first", "second" ] }, expected: { bar: [ , "second" ], dest1: [ , "src1data_2" ], @@ -113,7 +113,7 @@ describe( "DataApiMediator", () => label: "keeps existing value if in result set (all indexes)", name: 'bar', index: -1, - value: [ "first", "second" ], + value: { bar: [ "first", "second" ] }, expected: { bar: [ "first", "second" ], dest1: [ "src1data", "src1data_2" ], @@ -146,7 +146,7 @@ describe( "DataApiMediator", () => label: "uses first value of result if existing not in result set (first index)", name: 'foo', index: 0, - value: [ "does not", "exist" ], + value: { foo: [ "does not", "exist" ] }, expected: { foo: [ "first result" ], desta: [ "src1data" ], @@ -172,7 +172,7 @@ describe( "DataApiMediator", () => label: "uses first value of result if existing not in result set (second index)", name: 'foo', index: 1, - value: [ "does not", "exist" ], + value: { foo: [ "does not", "exist" ] }, expected: { foo: [ , "first result" ], desta: [ , "src1data" ], @@ -198,7 +198,7 @@ describe( "DataApiMediator", () => label: "uses first value of result if existing not in result set (all indexes)", name: 'foo', index: -1, - value: [ "does not", "exist" ], + value: { foo: [ "does not", "exist" ] }, expected: { foo: [ "first result", "first result" ], desta: [ "src1data", "src1data" ], @@ -231,7 +231,7 @@ describe( "DataApiMediator", () => label: "uses empty string if empty result set (first index)", name: 'foo', index: 0, - value: [ "foo" ], + value: { foo: [ "foo" ] }, expected: { foo: [ "" ], dest1: [ "" ], @@ -247,7 +247,7 @@ describe( "DataApiMediator", () => label: "uses empty string if empty result set (second index)", name: 'foo', index: 1, - value: [ "foo", "bar" ], + value: { foo: [ "foo", "bar" ] }, expected: { foo: [ , "" ], dest1: [ , "" ], @@ -263,7 +263,7 @@ describe( "DataApiMediator", () => label: "uses empty string if empty result set (all indexes)", name: 'foo', index: -1, - value: [ "foo", "bar" ], + value: { foo: [ "foo", "bar" ] }, expected: { foo: [ "", "" ], dest1: [ "", "" ], @@ -283,6 +283,36 @@ describe( "DataApiMediator", () => }, ], }, + + { + 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 } ) => @@ -294,8 +324,7 @@ describe( "DataApiMediator", () => const quote = { getDataByName( given_name ) { - expect( given_name ).to.equal( name ); - return value; + return value[ given_name ]; }, setData( given_data ) @@ -338,7 +367,7 @@ describe( "DataApiMediator", () => // 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_index ] ); + expect( given_cur ).to.equal( value[ given_name ][ given_index ] ); set_options = true; },