<commit_before>// @flow
import _ from 'lodash';
import { denormalize as denormalizrDenormalize } from 'denormalizr';
import createNormalizrSchema from './createNormalizrSchema';
import trimSchema from './trimSchema';
import type Entity from 'types/Entity';
import type EntityId from 'types/EntityId';
import type CollectionName from 'types/CollectionName';
import type NormalizedEntityDictionary from 'types/NormalizedEntityDictionary';
import type SchemasDictionary from 'types/SchemasDictionary';

/**
 * Construct nested object or array of nested objects from entities dictionary
 *
 * @param {EntityId | Array<EntityId> | Entity | Array<Entity>} ids - entities spec
 * @param {CollectionName} collectionName
 * @param {NormalizedEntityDictionary} entityDictionary
 * @param {SchemasDictionary} schemas
 * @param {?number} maxLevel - max level of nesting when denormalizing
 */
export default function denormalize(ids:EntityId | Array<EntityId> | Entity | Array<Entity>,
									collectionName:CollectionName,
									entityDictionary:NormalizedEntityDictionary,
									schemas:SchemasDictionary,
									maxLevel:number = 1) {
	// TODO check params
	const normalizrCollectionSchema = createNormalizrSchema(collectionName, schemas);
	const entitySchema = schemas[collectionName];

	let finalNormalizrCollectionSchema = normalizrCollectionSchema;
	if (typeof maxLevel !== 'undefined') {
		finalNormalizrCollectionSchema = trimSchema(normalizrCollectionSchema, maxLevel);
	}

	let result;
	if (_.isArray(ids)) {
		result = _.map(ids, id => {
			const entityId = _.isObject(id) ? id[entitySchema.idFieldName] : id;
			return denormalizrDenormalize(
				entityDictionary[collectionName][entityId],
				entityDictionary,
				finalNormalizrCollectionSchema
			);
		});
	} else {
		const entityId = _.isObject(ids) ? ids[entitySchema.idFieldName] : ids;
		result = denormalizrDenormalize(
			entityDictionary[collectionName][entityId],
			entityDictionary,
			finalNormalizrCollectionSchema
		);
	}

	return result;
}
<commit_msg>fix(modules/entityDescriptors/utils/denormalize): typecheck return value<commit_after>// @flow
import _ from 'lodash';
import { denormalize as denormalizrDenormalize } from 'denormalizr';
import createNormalizrSchema from './createNormalizrSchema';
import trimSchema from './trimSchema';
import type Entity from 'types/Entity';
import type EntityId from 'types/EntityId';
import type CollectionName from 'types/CollectionName';
import type NormalizedEntityDictionary from 'types/NormalizedEntityDictionary';
import type SchemasDictionary from 'types/SchemasDictionary';

/**
 * Construct nested object or array of nested objects from entities dictionary
 *
 * @param {EntityId | Array<EntityId> | Entity | Array<Entity>} ids - entities spec
 * @param {CollectionName} collectionName
 * @param {NormalizedEntityDictionary} entityDictionary
 * @param {SchemasDictionary} schemas
 * @param {?number} maxLevel - max level of nesting when denormalizing
 */
export default function denormalize(ids:EntityId | Array<EntityId> | Entity | Array<Entity>,
									collectionName:CollectionName,
									entityDictionary:NormalizedEntityDictionary,
									schemas:SchemasDictionary,
									maxLevel:number = 1):Entity|Array<Entity> {
	// TODO check params
	const normalizrCollectionSchema = createNormalizrSchema(collectionName, schemas);
	const entitySchema = schemas[collectionName];

	let finalNormalizrCollectionSchema = normalizrCollectionSchema;
	if (typeof maxLevel !== 'undefined') {
		finalNormalizrCollectionSchema = trimSchema(normalizrCollectionSchema, maxLevel);
	}

	let result;
	if (_.isArray(ids)) {
		result = _.map(ids, id => {
			const entityId = _.isObject(id) ? id[entitySchema.idFieldName] : id;
			return denormalizrDenormalize(
				entityDictionary[collectionName][entityId],
				entityDictionary,
				finalNormalizrCollectionSchema
			);
		});
	} else {
		const entityId = _.isObject(ids) ? ids[entitySchema.idFieldName] : ids;
		result = denormalizrDenormalize(
			entityDictionary[collectionName][entityId],
			entityDictionary,
			finalNormalizrCollectionSchema
		);
	}

	return result;
}
<|endoftext|>


<commit_before>angular.module('anol.featurepopup')
/**
 * @ngdoc directive
 * @name anol.featurepopup.directive:anolFeaturePopup
 *
 * @restrict A
 *
 * @param {string} templateUrl Url to template to use instead of default one
 * @param {anol.layer.Feature} layers Layers to show popup for
 * @param {number} tolerance Click tolerance in pixel
 *
 * @description
 * Shows a popup for selected feature
 */
.directive('anolFeaturePopup', ['$timeout', 'MapService', 'LayersService', 'ControlsService', function($timeout, MapService, LayersService, ControlsService) {
    return {
        restrict: 'A',
        scope: {
            'layers': '=',
            'tolerance': '='
        },
        replace: true,
        transclude: true,
        templateUrl: function(tElement, tAttrs) {
            var defaultUrl = 'src/modules/featurepopup/templates/popup.html';
            return tAttrs.templateUrl || defaultUrl;
        },
        link: function(scope, element, attrs) {
            // TODO find solution for multiple directives in one page
            var multiselect = angular.isDefined(attrs.multiselect);
            var clickPointSelect = angular.isDefined(attrs.clickPointSelect);
            scope.map = MapService.getMap();
            scope.popupVisible = false;

            scope.feature = undefined;
            scope.layer = undefined;
            scope.selectes = {};

            if(angular.isUndefined(scope.layers)) {
                scope.layers = [];
                scope.$watch(function() {
                    return LayersService.flattedLayers;
                }, function(layers) {
                    scope.layers.length = 0;
                    angular.forEach(layers, function(layer) {
                        if(!(layer instanceof anol.layer.Feature)) {
                            return;
                        }
                        scope.layers.push(layer);
                    });
                });
            }

            scope.overlayOptions = {
                element: element[0],
                autoPan: true,
                autoPanAnimation: {
                    duration: 250
                }

            };

            scope.popup = new ol.Overlay(scope.overlayOptions);
            scope.map.addOverlay(scope.popup);

            var selectInteraction;
            var interactions = [];

            var handleMultiSelect = function(evt) {
                scope.selects = {};
                if(evt.selected.length === 0) {
                    return false;
                }
                angular.forEach(evt.selected, function(feature) {
                    var layer = selectInteraction.getLayer(feature).get('anolLayer');
                    if(scope.selects[layer.name] === undefined) {
                        scope.selects[layer.name] = {
                            layer: layer,
                            features: [feature]
                        };
                    } else {
                        scope.selects[layer.name].features.push(feature);
                    }
                });
                return true;
            };
            var handleSingleSelect = function(evt) {
                scope.feature = undefined;
                scope.layer = undefined;
                if(evt.selected.length === 0) {
                    return false;
                }
                scope.feature = evt.selected[0];
                scope.layer = selectInteraction.getLayer(scope.feature).get('anolLayer');
                return true;
            };

            var handleSelect = function(evt) {
                scope.popupVisible = false;
                scope.$digest();
                var _handleSelect = multiselect === true ? handleMultiSelect : handleSingleSelect;

                if(_handleSelect(evt) === true) {
                    scope.popupVisible = true;
                    scope.$digest();
                    // wait until scope changes applied ($digest cycle completed) before set popup position
                    // otherwise Overlay.autoPan is not work correctly
                    $timeout(function() {
                        // popup could have been closed by $scope.close()
                        if(scope.popupVisible === true) {
                            scope.popup.setPosition(evt.mapBrowserEvent.coordinate);
                        }
                    });
                }
            };

            var handleClick = function(evt) {
                scope.popupVisible = false;
                scope.$digest();
                var features = [];
                var extent = [
                    evt.coordinate[0] - (scope.tolerance || 0),
                    evt.coordinate[1] - (scope.tolerance || 0),
                    evt.coordinate[0] + (scope.tolerance || 0),
                    evt.coordinate[1] + (scope.tolerance || 0)
                ];
                scope.selects = {};
                var found = false;
                angular.forEach(scope.layers, function(layer) {
                    var _features = layer.olLayer.getSource().getFeaturesInExtent(extent);
                    if(_features.length > 0) {
                        found = true;
                        scope.selects[layer.name] = {
                            layer: layer,
                            features: _features
                        };
                    }
                });
                if(found === true) {
                    scope.popupVisible = true;
                    scope.$digest();
                    // wait until scope changes applied ($digest cycle completed) before set popup position
                    // otherwise Overlay.autoPan is not work correctly
                    $timeout(function() {
                        // popup could have been closed by $scope.close()
                        if(scope.popupVisible === true) {
                            scope.popup.setPosition(evt.coordinate);
                        }
                    });
                }
            };

            var recreateInteractions = function() {
                scope.popupVisible = false;

                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(false);
                    scope.map.removeInteraction(interaction);
                });

                var olLayers = [];
                var snapInteractions = [];
                angular.forEach(scope.layers, function(layer) {
                    olLayers.push(layer.olLayer);
                    snapInteractions.push(new ol.interaction.Snap({
                        source: layer.olLayer.getSource(),
                        pixelTolerance: scope.tolerance
                    }));
                });
                selectInteraction = new ol.interaction.Select({
                    toggleCondition: ol.events.condition.never,
                    multi: multiselect,
                    layers: olLayers,
                    style: function(feature) {
                        // prevents changing feature style
                        // TODO find better solution
                        var style = feature.getStyle() || selectInteraction.getLayer(feature).getStyle();
                        if(angular.isFunction(style)) {
                            return style(feature);
                        }
                        return style;
                    }
                });
                selectInteraction.on('select', handleSelect);

                interactions = [selectInteraction].concat(snapInteractions);
                angular.forEach(interactions, function(interaction) {
                    scope.map.addInteraction(interaction);
                });
            };

            var changeCursor = function(evt) {
                var pixel = scope.map.getEventPixel(evt.originalEvent);

                var hit = scope.map.hasFeatureAtPixel(pixel, function(layer) {
                    return scope.layers.indexOf(layer.get('anolLayer')) !== -1;
                });

                scope.map.getTarget().style.cursor = hit ? 'pointer' : '';
            };
            var changeCursorEvtKey;

            var bindCursorChange = function() {
                if((scope.layers === undefined || scope.layers.length === 0) && changeCursorEvtKey !== undefined) {
                    scope.map.unByKey(changeCursorEvtKey);
                } else if(scope.layers !== undefined && scope.layers.length !== 0) {
                    changeCursorEvtKey = scope.map.on('pointermove', changeCursor);
                }
            };

            var control = new anol.control.Control({
                subordinate: true,
                olControl: null
            });
            control.onDeactivate(function() {
                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(false);
                });
                scope.map.unByKey(changeCursorEvtKey);
                scope.map.getTarget().style.cursor = '';
            });
            control.onActivate(function() {
                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(true);
                });
                changeCursorEvtKey = scope.map.on('pointermove', changeCursor);
            });

            if(clickPointSelect === true) {
                scope.map.on('singleclick', handleClick, this);
            } else {
                recreateInteractions();
                scope.$watch('layers', recreateInteractions);
            }

            control.activate();

            ControlsService.addControl(control);

            scope.$watch('layers', bindCursorChange);
            scope.$watch('popupVisible', function(visible) {
                if(!visible) {
                    if(selectInteraction !== undefined) {
                        selectInteraction.getFeatures().clear();
                    }
                    scope.layer = undefined;
                    scope.feature = undefined;
                }
            });
        },
        controller: function($scope, $element, $attrs) {
            this.close = function() {
                $scope.popupVisible = false;
            };
            $scope.close = this.close;
        }
    };
}]);
<commit_msg>Added openFor attribute to featurePopup<commit_after>angular.module('anol.featurepopup')
/**
 * @ngdoc directive
 * @name anol.featurepopup.directive:anolFeaturePopup
 *
 * @restrict A
 *
 * @param {string} templateUrl Url to template to use instead of default one
 * @param {anol.layer.Feature} layers Layers to show popup for
 * @param {number} tolerance Click tolerance in pixel
 * @param {object} openFor Accepts an object with layer and feature property. If changed, a popup is shown for given value
 *
 * @description
 * Shows a popup for selected feature
 */
.directive('anolFeaturePopup', ['$timeout', 'MapService', 'LayersService', 'ControlsService', function($timeout, MapService, LayersService, ControlsService) {
    return {
        restrict: 'A',
        scope: {
            'layers': '=',
            'tolerance': '=',
            'openFor': '='
        },
        replace: true,
        transclude: true,
        templateUrl: function(tElement, tAttrs) {
            var defaultUrl = 'src/modules/featurepopup/templates/popup.html';
            return tAttrs.templateUrl || defaultUrl;
        },
        link: function(scope, element, attrs) {
            // TODO find solution for multiple directives in one page
            var multiselect = angular.isDefined(attrs.multiselect);
            var clickPointSelect = angular.isDefined(attrs.clickPointSelect);
            scope.map = MapService.getMap();
            scope.popupVisible = false;

            scope.feature = undefined;
            scope.layer = undefined;
            scope.selectes = {};

            if(angular.isUndefined(scope.layers)) {
                scope.layers = [];
                scope.$watch(function() {
                    return LayersService.flattedLayers;
                }, function(layers) {
                    scope.layers.length = 0;
                    angular.forEach(layers, function(layer) {
                        if(!(layer instanceof anol.layer.Feature)) {
                            return;
                        }
                        scope.layers.push(layer);
                    });
                });
            }

            scope.overlayOptions = {
                element: element[0],
                autoPan: true,
                autoPanAnimation: {
                    duration: 250
                }

            };

            scope.popup = new ol.Overlay(scope.overlayOptions);
            scope.map.addOverlay(scope.popup);

            var selectInteraction;
            var interactions = [];

            var handleMultiSelect = function(evt) {
                scope.selects = {};
                if(evt.selected.length === 0) {
                    return false;
                }
                angular.forEach(evt.selected, function(feature) {
                    var layer = selectInteraction.getLayer(feature).get('anolLayer');
                    if(scope.selects[layer.name] === undefined) {
                        scope.selects[layer.name] = {
                            layer: layer,
                            features: [feature]
                        };
                    } else {
                        scope.selects[layer.name].features.push(feature);
                    }
                });
                return true;
            };
            var handleSingleSelect = function(evt) {
                scope.feature = undefined;
                scope.layer = undefined;
                if(evt.selected.length === 0) {
                    return false;
                }
                scope.feature = evt.selected[0];
                scope.layer = selectInteraction.getLayer(scope.feature).get('anolLayer');
                return true;
            };

            var handleSelect = function(evt) {
                scope.popupVisible = false;
                scope.$digest();
                var _handleSelect = multiselect === true ? handleMultiSelect : handleSingleSelect;

                if(_handleSelect(evt) === true) {
                    scope.popupVisible = true;
                    scope.$digest();
                    // wait until scope changes applied ($digest cycle completed) before set popup position
                    // otherwise Overlay.autoPan is not work correctly
                    $timeout(function() {
                        // popup could have been closed by $scope.close()
                        if(scope.popupVisible === true) {
                            scope.popup.setPosition(evt.mapBrowserEvent.coordinate);
                        }
                    });
                }
            };

            var handleClick = function(evt) {
                scope.popupVisible = false;
                scope.$digest();
                var features = [];
                var extent = [
                    evt.coordinate[0] - (scope.tolerance || 0),
                    evt.coordinate[1] - (scope.tolerance || 0),
                    evt.coordinate[0] + (scope.tolerance || 0),
                    evt.coordinate[1] + (scope.tolerance || 0)
                ];
                scope.selects = {};
                var found = false;
                angular.forEach(scope.layers, function(layer) {
                    var _features = layer.olLayer.getSource().getFeaturesInExtent(extent);
                    if(_features.length > 0) {
                        found = true;
                        scope.selects[layer.name] = {
                            layer: layer,
                            features: _features
                        };
                    }
                });
                if(found === true) {
                    scope.popupVisible = true;
                    scope.$digest();
                    // wait until scope changes applied ($digest cycle completed) before set popup position
                    // otherwise Overlay.autoPan is not work correctly
                    $timeout(function() {
                        // popup could have been closed by $scope.close()
                        if(scope.popupVisible === true) {
                            scope.popup.setPosition(evt.coordinate);
                        }
                    });
                }
            };

            var recreateInteractions = function() {
                scope.popupVisible = false;

                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(false);
                    scope.map.removeInteraction(interaction);
                });

                var olLayers = [];
                var snapInteractions = [];
                angular.forEach(scope.layers, function(layer) {
                    olLayers.push(layer.olLayer);
                    snapInteractions.push(new ol.interaction.Snap({
                        source: layer.olLayer.getSource(),
                        pixelTolerance: scope.tolerance
                    }));
                });
                selectInteraction = new ol.interaction.Select({
                    toggleCondition: ol.events.condition.never,
                    multi: multiselect,
                    layers: olLayers,
                    style: function(feature) {
                        // prevents changing feature style
                        // TODO find better solution
                        var style = feature.getStyle() || selectInteraction.getLayer(feature).getStyle();
                        if(angular.isFunction(style)) {
                            return style(feature);
                        }
                        return style;
                    }
                });
                selectInteraction.on('select', handleSelect);

                interactions = [selectInteraction].concat(snapInteractions);
                angular.forEach(interactions, function(interaction) {
                    scope.map.addInteraction(interaction);
                });
            };

            var changeCursor = function(evt) {
                var pixel = scope.map.getEventPixel(evt.originalEvent);

                var hit = scope.map.hasFeatureAtPixel(pixel, function(layer) {
                    return scope.layers.indexOf(layer.get('anolLayer')) !== -1;
                });

                scope.map.getTarget().style.cursor = hit ? 'pointer' : '';
            };
            var changeCursorEvtKey;

            var bindCursorChange = function() {
                if((scope.layers === undefined || scope.layers.length === 0) && changeCursorEvtKey !== undefined) {
                    scope.map.unByKey(changeCursorEvtKey);
                } else if(scope.layers !== undefined && scope.layers.length !== 0) {
                    changeCursorEvtKey = scope.map.on('pointermove', changeCursor);
                }
            };

            var control = new anol.control.Control({
                subordinate: true,
                olControl: null
            });
            control.onDeactivate(function() {
                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(false);
                });
                scope.map.unByKey(changeCursorEvtKey);
                scope.map.getTarget().style.cursor = '';
            });
            control.onActivate(function() {
                angular.forEach(interactions, function(interaction) {
                    interaction.setActive(true);
                });
                changeCursorEvtKey = scope.map.on('pointermove', changeCursor);
            });

            if(clickPointSelect === true) {
                scope.map.on('singleclick', handleClick, this);
            } else {
                recreateInteractions();
                scope.$watch('layers', recreateInteractions);
            }

            control.activate();

            ControlsService.addControl(control);

            scope.$watch('layers', bindCursorChange);
            scope.$watch('popupVisible', function(visible) {
                if(!visible) {
                    if(selectInteraction !== undefined) {
                        selectInteraction.getFeatures().clear();
                    }
                    scope.layer = undefined;
                    scope.feature = undefined;
                }
            });
            scope.$watch('openFor', function(openFor) {
                if(angular.isDefined(openFor)) {
                    scope.popupVisible = false;
                    // wait until digest cycle complete
                    $timeout(function() {
                        scope.layer = openFor.layer;
                        scope.feature = openFor.feature;
                        scope.openFor = undefined;
                        scope.popupVisible = true;
                        $timeout(function() {
                            scope.popup.setPosition(scope.feature.getGeometry().getLastCoordinate());
                        });
                    });
                }
            });
        },
        controller: function($scope, $element, $attrs) {
            this.close = function() {
                $scope.popupVisible = false;
            };
            $scope.close = this.close;
        }
    };
}]);
<|endoftext|>


<commit_before>import ReactDOM from 'react-dom';
import SpecSugar from '../support/spec_sugar.jsx';
import {serviceTypesIndex, eventNoteTypesIndex} from '../fixtures/database_constants.jsx';
import FixtureStudents from '../fixtures/students.jsx';
import SlicePanels from '../../../app/assets/javascripts/components/SlicePanels';

const helpers = {
  renderInto(el, props = {}) {
    const mergedProps = {
      filters: [],
      serviceTypesIndex,
      eventNoteTypesIndex,
      school: {
        school_type: 'ES'
      },
      students: [],
      allStudents: [],
      onFilterToggled: jest.fn(),
      ...props
    };
    ReactDOM.render(<SlicePanels {...mergedProps} />, el);
  },

  // Returns a matrix of the kinds of things that users can slice by in each
  // column (eg., disability, STAR reading quartile).
  columnTitlesMatrix(el) {
    const columnEls = $(el).find('.column').toArray();
    return columnEls.map(columnEl => {
      return $(columnEl).find('.fixed-table-title').toArray().map(titleEl => {
        return $(titleEl).text();
      });
    });
  },

  // Count how many attributes there are for each table in each column
  // (e.g., how many "Disability" options are there, and how many
  // "STAR Reading" options are there for slicing by.
  rowsPerColumnMatrix(el) {
    const columnEls = $(el).find('.column').toArray();
    return columnEls.map(columnEl => {
      return $(columnEl).find('table').toArray().map(tableEl => {
        return $(tableEl).find('tbody tr').length;
      });
    });
  }
};

SpecSugar.withTestEl('high-level integration tests', (container) => {
  it('renders everything on the happy path', () => {
    const el = container.testEl;
    helpers.renderInto(el);

    expect($(el).find('.SlicePanels').length).toEqual(1);
    expect($(el).find('.column').length).toEqual(6);
    expect(helpers.columnTitlesMatrix(el)).toEqual([
      [ 'Disability', 'Low Income', 'LEP', 'Race', 'Hispanic/Latino', 'Gender' ],
      [ 'Grade', 'Years enrolled', 'Risk level' ],
      [ 'STAR Reading', 'MCAS ELA Score', 'MCAS ELA SGP' ],
      [ 'STAR Math', 'MCAS Math Score', 'MCAS Math SGP' ],
      [ 'Discipline incidents', 'Absences', 'Tardies' ],
      [ 'Services', 'Summer', 'Notes', 'Program', 'Homeroom' ]
    ]);
  });

  it('renders everything on the happy path for high school', () => {
    const el = container.testEl;
    helpers.renderInto(
      el,
      {
        school: {
          school_type: 'HS',
          local_id: 'SHS'
        }
      }
    );

    expect($(el).find('.SlicePanels').length).toEqual(1);
    expect($(el).find('.column').length).toEqual(6);
    expect(helpers.columnTitlesMatrix(el)).toEqual([
      [ 'Disability', 'Low Income', 'LEP', 'Race', 'Hispanic/Latino', 'Gender' ],
      [ 'Grade', 'House', 'Counselor', 'Years enrolled', 'Risk level' ],
      [ 'STAR Reading', 'MCAS ELA Score', 'MCAS ELA SGP' ],
      [ 'STAR Math', 'MCAS Math Score', 'MCAS Math SGP' ],
      [ 'Discipline incidents', 'Absences', 'Tardies' ],
      [ 'Services', 'Summer', 'Notes', 'Program', 'Homeroom' ]
    ]);
  });

  it('renders attributes for slicing based on student data', () => {
    const el = container.testEl;
    helpers.renderInto(el, {
      students: FixtureStudents,
      allStudents: FixtureStudents
    });

    expect(helpers.rowsPerColumnMatrix(el)).toEqual([
      [ 5, 1, 1, 2, 3, 0 ],
      [ 1, 1, 5 ],
      [ 5, 5, 5 ],
      [ 5, 5, 5 ],
      [ 5, 5, 5 ],
      [ 4, 1, 4, 2, 1 ]
    ]);
  });
});<commit_msg>Add JS test coverage<commit_after>import _ from 'lodash';
import ReactDOM from 'react-dom';
import SpecSugar from '../support/spec_sugar.jsx';
import {serviceTypesIndex, eventNoteTypesIndex} from '../fixtures/database_constants.jsx';
import FixtureStudents from '../fixtures/students.jsx';
import SlicePanels from '../../../app/assets/javascripts/components/SlicePanels';

const helpers = {
  renderInto(el, props = {}) {
    const mergedProps = {
      filters: [],
      serviceTypesIndex,
      eventNoteTypesIndex,
      school: {
        school_type: 'ES'
      },
      students: [],
      allStudents: [],
      onFilterToggled: jest.fn(),
      ...props
    };
    ReactDOM.render(<SlicePanels {...mergedProps} />, el);
  },

  // Returns a matrix of the kinds of things that users can slice by in each
  // column (eg., disability, STAR reading quartile).
  columnTitlesMatrix(el) {
    const columnEls = $(el).find('.column').toArray();
    return columnEls.map(columnEl => {
      return $(columnEl).find('.fixed-table-title').toArray().map(titleEl => {
        return $(titleEl).text();
      });
    });
  },

  // Count how many attributes there are for each table in each column
  // (e.g., how many "Disability" options are there, and how many
  // "STAR Reading" options are there for slicing by.
  rowsPerColumnMatrix(el) {
    const columnEls = $(el).find('.column').toArray();
    return columnEls.map(columnEl => {
      return $(columnEl).find('table').toArray().map(tableEl => {
        return $(tableEl).find('tbody tr').length;
      });
    });
  }
};

SpecSugar.withTestEl('high-level integration tests', (container) => {
  it(`renders everything on the happy path for elementary school with
      no student registration dates`, () => {
    const el = container.testEl;
    helpers.renderInto(el);

    expect($(el).find('.SlicePanels').length).toEqual(1);
    expect($(el).find('.column').length).toEqual(6);
    expect(helpers.columnTitlesMatrix(el)).toEqual([
      [ 'Disability', 'Low Income', 'LEP', 'Race', 'Hispanic/Latino', 'Gender' ],
      [ 'Grade', 'Risk level' ],
      [ 'STAR Reading', 'MCAS ELA Score', 'MCAS ELA SGP' ],
      [ 'STAR Math', 'MCAS Math Score', 'MCAS Math SGP' ],
      [ 'Discipline incidents', 'Absences', 'Tardies' ],
      [ 'Services', 'Summer', 'Notes', 'Program', 'Homeroom' ]
    ]);
  });

  it(`renders everything on the happy path for elementary school with
       student registration dates`, () => {
    const el = container.testEl;
    const studentsWithRegistration = FixtureStudents.map((student) => {
      return _.merge(student, {registration_date: '2018-02-13T22:17:30.338Z'})
    })

    helpers.renderInto(el, {
      students: studentsWithRegistration,
      allStudents: studentsWithRegistration
    });

    expect($(el).find('.SlicePanels').length).toEqual(1);
    expect($(el).find('.column').length).toEqual(6);
    expect(helpers.columnTitlesMatrix(el)).toEqual([
      [ 'Disability', 'Low Income', 'LEP', 'Race', 'Hispanic/Latino', 'Gender' ],
      [ 'Grade', 'Years enrolled', 'Risk level' ],
      [ 'STAR Reading', 'MCAS ELA Score', 'MCAS ELA SGP' ],
      [ 'STAR Math', 'MCAS Math Score', 'MCAS Math SGP' ],
      [ 'Discipline incidents', 'Absences', 'Tardies' ],
      [ 'Services', 'Summer', 'Notes', 'Program', 'Homeroom' ]
    ]);
  });

  it('renders everything on the happy path for high school', () => {
    const el = container.testEl;
    helpers.renderInto(
      el,
      {
        school: {
          school_type: 'HS',
          local_id: 'SHS'
        }
      }
    );

    expect($(el).find('.SlicePanels').length).toEqual(1);
    expect($(el).find('.column').length).toEqual(6);
    expect(helpers.columnTitlesMatrix(el)).toEqual([
      [ 'Disability', 'Low Income', 'LEP', 'Race', 'Hispanic/Latino', 'Gender' ],
      [ 'Grade', 'House', 'Counselor', 'Risk level' ],
      [ 'STAR Reading', 'MCAS ELA Score', 'MCAS ELA SGP' ],
      [ 'STAR Math', 'MCAS Math Score', 'MCAS Math SGP' ],
      [ 'Discipline incidents', 'Absences', 'Tardies' ],
      [ 'Services', 'Summer', 'Notes', 'Program', 'Homeroom' ]
    ]);
  });

  it('renders attributes for slicing based on student data', () => {
    const el = container.testEl;
    helpers.renderInto(el, {
      students: FixtureStudents,
      allStudents: FixtureStudents
    });

    expect(helpers.rowsPerColumnMatrix(el)).toEqual([
      [ 5, 1, 1, 2, 3, 0 ],
      [ 1, 1, 5 ],
      [ 5, 5, 5 ],
      [ 5, 5, 5 ],
      [ 5, 5, 5 ],
      [ 4, 1, 4, 2, 1 ]
    ]);
  });
});
<|endoftext|>


<commit_before>/* eslint-disable prefer-promise-reject-errors */

// This file does not contain anything React-specific. If need JS client for
// another framework this file could be extracted into its own package and be
// re-used.

import unfetch from "isomorphic-unfetch";

import { applyUriDefaults, parseUri, stringifyUri } from "./uri";
import Cache from "./Cache";

const DEFAULT_CACHE_TTL = typeof window === "undefined" ? 20e3 : Infinity; // ms
const DOCUMENT_DOMAIN_REGEX = /\bdocument\.domain\s*=\s*(["'])([^'"\s]+)\1/;

/*
This class fetches and caches nodes, provides global options, and keeps
`window.DJEDI_NODES` up-to-date.
*/
export class Djedi {
  constructor() {
    this.options = makeDefaultOptions();

    // `Cache<uri: string, Node>`. Cache of all fetched nodes.
    this._nodes = new Cache({ ttl: DEFAULT_CACHE_TTL });

    // `Map<uri: string, Node>`. Everything that `reportPrefetchableNode` has
    // reported. The nodes contain default values (if any).
    this._prefetchableNodes = new Map();

    // `{ [uri: string]: string }`. The return value of the last `prefetch` call.
    // Mutated by `get` and `getBatched`. The values come from fetched nodes.
    this._lastPrefetch = {};

    // Queue for `getBatched`.
    this._batch = makeEmptyBatch();

    // `Map<uri: string, Node>`. Everything that `reportRenderedNode` has
    // reported. The nodes contain default values (if any). Used to keep
    // `_DJEDI_NODES` up-to-date.
    this._renderedNodes = new Map();

    // `{ [uri: string]: string }`. The values are default values (if any). //
    // The admin sidebar expects the following mapping of all rendered nodes on
    // the page: `window.DJEDI_NODES = { "<uri>": "<default>" }`.
    this._DJEDI_NODES = {};

    if (typeof window !== "undefined") {
      if (window.DJEDI_NODES == null) {
        window.DJEDI_NODES = {};
      }
      this._DJEDI_NODES = window.DJEDI_NODES;
    }
  }

  resetOptions() {
    this.options = makeDefaultOptions();
  }

  resetState() {
    if (this._batch.timeoutId != null) {
      clearTimeout(this._batch.timeoutId);
    }

    this._nodes = new Cache({ ttl: DEFAULT_CACHE_TTL });
    this._prefetchableNodes = new Map();
    this._lastPrefetch = {};
    this._batch = makeEmptyBatch();
    this._renderedNodes = new Map();
    this._DJEDI_NODES = {};

    if (typeof window !== "undefined") {
      window.DJEDI_NODES = this._DJEDI_NODES;
    }
  }

  setCache(value) {
    if (typeof value === "number") {
      if (this._nodes instanceof Cache) {
        this._nodes.ttl = value;
      } else {
        this._nodes = new Cache({ ttl: value });
      }
    } else {
      this._nodes = value;
    }
  }

  get(passedNode, callback) {
    const node = this._normalizeNode(passedNode);
    const { uri } = node;
    const existingNode = this._nodes.get(uri);

    if (existingNode != null) {
      this._callback(callback, existingNode);
      return;
    }

    this._fetchMany({ [node.uri]: node.value }).then(
      () => {
        this._callback(callback, this._nodes.get(uri) || missingUriError(uri));
      },
      error => {
        this._callback(callback, error);
      }
    );
  }

  getBatched(passedNode, callback) {
    if (this.options.batchInterval <= 0) {
      this.get(passedNode, callback);
      return;
    }

    const node = this._normalizeNode(passedNode);
    const existingNode = this._nodes.get(node.uri);

    if (existingNode != null) {
      this._callback(callback, existingNode);
      return;
    }

    const previous = this._batch.queue.get(node.uri) || {
      node,
      callbacks: [],
    };
    previous.callbacks.push(callback);
    this._batch.queue.set(node.uri, previous);

    if (this._batch.timeoutId != null) {
      return;
    }

    this._batch.timeoutId = setTimeout(
      this._flushBatch.bind(this),
      this.options.batchInterval
    );
  }

  reportPrefetchableNode(passedNode) {
    const node = this._normalizeNode(passedNode);
    const previous = this._prefetchableNodes.get(node.uri);
    if (previous != null && previous.value !== node.value) {
      console.warn(
        "djedi-react: Encountered two nodes with the same URI but with different default values. Using the previous value and ignoring the next.",
        {
          uri: node.uri,
          prev: previous.value,
          next: node.value,
        }
      );
      return;
    }
    this._prefetchableNodes.set(node.uri, node);
  }

  prefetch({ filter = undefined, extra = [] } = {}) {
    const nodes = {};
    this._prefetchableNodes.forEach(node => {
      if (
        this._nodes.get(node.uri) == null &&
        (filter == null ||
          filter(this._parseUri(node.uri, { applyDefaults: false })))
      ) {
        nodes[node.uri] = node.value;
      }
    });
    extra.forEach(node => {
      const uri = this._normalizeUri(node.uri);
      if (this._nodes.get(uri) == null) {
        nodes[uri] = node.value;
      }
    });

    const promise =
      Object.keys(nodes).length === 0
        ? Promise.resolve({})
        : this._fetchMany(nodes);

    return promise.then(results => {
      this._lastPrefetch = results;
      return results;
    });
  }

  // Needed to pick up the results from `prefetch` after server-side rendering.
  addNodes(nodes) {
    Object.keys(nodes).forEach(uri => {
      const uriObject = this._parseUri(uri);
      const value = nodes[uri];

      const node = { uri: this._stringifyUri(uriObject), value };
      this._nodes.set(node.uri, node);

      // If the returned node URI has a version, also set the versionless URI to
      // the same node. A request for `home/text.md` can return a URI ending
      // with for example `home/text.md#1` if the user has edited the node.
      if (uriObject.version) {
        const versionlessUri = this._stringifyUri({
          ...uriObject,
          version: "",
        });

        // If the versionless URI has already been set, don’t update it so that
        // if a node re-renders it doesn’t unexpectedly change its text.
        if (this._nodes.get(versionlessUri) == null) {
          this._nodes.set(versionlessUri, node);
        }
      }
    });
  }

  injectAdmin() {
    if (typeof document === "undefined") {
      return Promise.resolve(false);
    }

    const url = `${this.options.baseUrl}/embed/`;

    return unfetch(url, { credentials: "include" }).then(response => {
      if (response.status >= 200 && response.status < 400) {
        return response.text().then(html => {
          // Browsers don’t allow <script> tags inserted as part of an HTML
          // chunk to modify `document.domain`, so cut out the domain and set it
          // manually.
          const [, , domain] = DOCUMENT_DOMAIN_REGEX.exec(html) || [];
          if (domain != null) {
            document.domain = domain;
          }
          document.body.insertAdjacentHTML("beforeend", html);
          return true;
        });
      }
      if (response.status === 403) {
        return false;
      }
      return Promise.reject(
        createStatusCodeError(
          response.status,
          "200 <= status < 400 or status = 403"
        )
      );
    });
  }

  reportRenderedNode(passedNode) {
    const node = this._normalizeNode(passedNode);
    const previous = this._renderedNodes.get(node.uri);

    if (previous != null) {
      if (previous.value !== node.value) {
        console.warn(
          "djedi-react: Rendering a node with with a different default value. That default will be ignored.",
          {
            uri: node.uri,
            prev: previous.value,
            next: node.value,
          }
        );
      }
      previous.numInstances++;
      return;
    }

    this._renderedNodes.set(node.uri, {
      value: node.value,
      numInstances: 1,
    });

    this._DJEDI_NODES[this._djediNodesUri(node.uri)] = node.value;
  }

  reportRemovedNode(passedUri) {
    const uri = this._normalizeUri(passedUri);
    const previous = this._renderedNodes.get(uri);

    if (previous == null) {
      return;
    }

    previous.numInstances--;

    if (previous.numInstances <= 0) {
      this._renderedNodes.delete(uri);
      delete this._DJEDI_NODES[this._djediNodesUri(uri)];
    }
  }

  element(uri) {
    const uriObject = this._parseUri(uri);
    return {
      tag: "span",
      attributes: {
        "data-i18n": this._stringifyUri({
          ...uriObject,
          scheme: "",
          ext: "",
          version: "",
        }),
      },
    };
  }

  // Calls `callback(node)` and also updates the last return value of
  // `djedi.prefetch()`. This is really ugly but needed for server-side
  // rendering.
  _callback(callback, node) {
    if (!(node instanceof Error)) {
      this._lastPrefetch[node.uri] = node.value;
    }
    callback(node);
  }

  _parseUri(uri, { applyDefaults = true } = {}) {
    const { defaults, namespaceByScheme, separators } = this.options.uri;
    const uriObject = parseUri(uri, separators);
    return applyDefaults
      ? applyUriDefaults(uriObject, defaults, namespaceByScheme)
      : uriObject;
  }

  _stringifyUri(uriObject) {
    return stringifyUri(uriObject, this.options.uri.separators);
  }

  _normalizeUri(uri) {
    return this._stringifyUri(this._parseUri(uri));
  }

  _normalizeNode(node) {
    return {
      ...node,
      uri: this._normalizeUri(node.uri),
    };
  }

  _djediNodesUri(uri) {
    const uriObject = this._parseUri(uri);
    return this._stringifyUri({ ...uriObject, version: "" });
  }

  _flushBatch() {
    const { queue } = this._batch;

    const nodes = {};
    queue.forEach((data, uri) => {
      nodes[uri] = data.node.value;
    });

    this._batch = makeEmptyBatch();

    this._fetchMany(nodes).then(
      () => {
        queue.forEach((data, uri) => {
          const node = this._nodes.get(uri) || missingUriError(uri);
          data.callbacks.forEach(callback => {
            this._callback(callback, node);
          });
        });
      },
      error => {
        queue.forEach(data => {
          data.callbacks.forEach(callback => {
            this._callback(callback, error);
          });
        });
      }
    );
  }

  _fetchMany(nodes) {
    // `JSON.stringify` excludes keys whose values are `undefined`. Change them
    // to `null` so that all keys are sent to the backend.
    const nodesWithNull = Object.keys(nodes).reduce((result, key) => {
      const value = nodes[key];
      result[key] = value === undefined ? null : value;
      return result;
    }, {});
    return this._post("/nodes/", nodesWithNull).then(results => {
      this.addNodes(results);
      return results;
    });
  }

  _post(passedUrl, data) {
    const url = `${this.options.baseUrl}${passedUrl}`;
    const info = { method: "POST", apiUrl: url, data };

    return unfetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      // Make the body easier to read in test snapshots. It’s important to still
      // call `JSON.stringify` so we know that `data` actually can be
      // stringified.
      body:
        // istanbul ignore next
        // eslint-disable-next-line no-undef
        typeof process !== "undefined" && process.env.NODE_ENV === "test"
          ? JSON.parse(JSON.stringify(data))
          : JSON.stringify(data),
    })
      .then(
        response => {
          if (response.status >= 200 && response.status < 400) {
            return response
              .json()
              .catch(error => Promise.reject({ response, error }));
          }
          return Promise.reject({
            response,
            error: createStatusCodeError(
              response.status,
              "200 <= status < 400"
            ),
          });
        },
        passedError => {
          // In IE11 the error can be a `ProgressEvent` (I guess it’s due to how
          // the `unfetch` “polyfill” is implemented). Make sure to always
          // return `Error`s so `foo instanceof Error` checks can be used.
          // istanbul ignore next
          const error =
            passedError instanceof Error
              ? passedError
              : new Error("fetch error");
          return Promise.reject({ response: undefined, error });
        }
      )
      .catch(({ response, error }) => {
        error.message = createUpdatedErrorMessage(error, info, response);
        error.status = response == null ? -1 : response.status;

        const textPromise =
          response == null || response.bodyUsed
            ? Promise.resolve("")
            : response.text().then(
                text => text,
                // istanbul ignore next
                () => ""
              );

        return textPromise.then(text => {
          error.responseText = text;
          return Promise.reject(error);
        });
      });
  }
}

// This is a function, not a constant, since it can be mutated by the user.
function makeDefaultOptions() {
  return {
    baseUrl: "/djedi",
    batchInterval: 10, // ms
    defaultRender: state => {
      switch (state.type) {
        case "loading":
          return "Loading…";
        case "error":
          return `Failed to fetch content 😞 (${state.error.status})`;
        case "success":
          return state.content;
        // istanbul ignore next
        default:
          return null;
      }
    },
    uri: {
      defaults: {
        scheme: "i18n",
        namespace: "",
        path: "",
        ext: "txt",
        version: "",
      },
      namespaceByScheme: {
        i18n: "en-us",
        l10n: "local",
        g11n: "global",
      },
      separators: {
        scheme: "://",
        namespace: "@",
        path: "/",
        ext: ".",
        version: "#",
      },
    },
  };
}

// This is a function, not a constant, since it will be mutated.
function makeEmptyBatch() {
  return {
    timeoutId: undefined,
    queue: new Map(),
  };
}

function createStatusCodeError(status, expected) {
  const error = new Error(
    `Unexpected response status code. Got ${status} but expected ${expected}.`
  );
  error.status = status;
  return error;
}

function createUpdatedErrorMessage(error, info, response) {
  return [
    `Djedi API error for request: ${info.method} ${info.apiUrl}`,
    `RequestData sent: ${JSON.stringify(info.data, null, 2)}`,
    `Response: ${
      response ? `${response.status} ${response.statusText}` : response
    }`,
    String(error),
  ].join("\n");
}

function missingUriError(uri) {
  const error = new Error(`Missing result for node: ${uri}`);
  error.status = 1404;
  return error;
}

export default new Djedi();
<commit_msg>Fix code coverage<commit_after>/* eslint-disable prefer-promise-reject-errors */

// This file does not contain anything React-specific. If need JS client for
// another framework this file could be extracted into its own package and be
// re-used.

import unfetch from "isomorphic-unfetch";

import { applyUriDefaults, parseUri, stringifyUri } from "./uri";
import Cache from "./Cache";

const DEFAULT_CACHE_TTL = typeof window === "undefined" ? 20e3 : Infinity; // ms
const DOCUMENT_DOMAIN_REGEX = /\bdocument\.domain\s*=\s*(["'])([^'"\s]+)\1/;

/*
This class fetches and caches nodes, provides global options, and keeps
`window.DJEDI_NODES` up-to-date.
*/
export class Djedi {
  constructor() {
    this.options = makeDefaultOptions();

    // `Cache<uri: string, Node>`. Cache of all fetched nodes.
    this._nodes = new Cache({ ttl: DEFAULT_CACHE_TTL });

    // `Map<uri: string, Node>`. Everything that `reportPrefetchableNode` has
    // reported. The nodes contain default values (if any).
    this._prefetchableNodes = new Map();

    // `{ [uri: string]: string }`. The return value of the last `prefetch` call.
    // Mutated by `get` and `getBatched`. The values come from fetched nodes.
    this._lastPrefetch = {};

    // Queue for `getBatched`.
    this._batch = makeEmptyBatch();

    // `Map<uri: string, Node>`. Everything that `reportRenderedNode` has
    // reported. The nodes contain default values (if any). Used to keep
    // `_DJEDI_NODES` up-to-date.
    this._renderedNodes = new Map();

    // `{ [uri: string]: string }`. The values are default values (if any). //
    // The admin sidebar expects the following mapping of all rendered nodes on
    // the page: `window.DJEDI_NODES = { "<uri>": "<default>" }`.
    this._DJEDI_NODES = {};

    if (typeof window !== "undefined") {
      if (window.DJEDI_NODES == null) {
        window.DJEDI_NODES = {};
      }
      this._DJEDI_NODES = window.DJEDI_NODES;
    }
  }

  resetOptions() {
    this.options = makeDefaultOptions();
  }

  resetState() {
    // istanbul ignore next
    if (this._batch.timeoutId != null) {
      clearTimeout(this._batch.timeoutId);
    }

    this._nodes = new Cache({ ttl: DEFAULT_CACHE_TTL });
    this._prefetchableNodes = new Map();
    this._lastPrefetch = {};
    this._batch = makeEmptyBatch();
    this._renderedNodes = new Map();
    this._DJEDI_NODES = {};

    if (typeof window !== "undefined") {
      window.DJEDI_NODES = this._DJEDI_NODES;
    }
  }

  setCache(value) {
    if (typeof value === "number") {
      if (this._nodes instanceof Cache) {
        this._nodes.ttl = value;
      } else {
        this._nodes = new Cache({ ttl: value });
      }
    } else {
      this._nodes = value;
    }
  }

  get(passedNode, callback) {
    const node = this._normalizeNode(passedNode);
    const { uri } = node;
    const existingNode = this._nodes.get(uri);

    if (existingNode != null) {
      this._callback(callback, existingNode);
      return;
    }

    this._fetchMany({ [node.uri]: node.value }).then(
      () => {
        this._callback(callback, this._nodes.get(uri) || missingUriError(uri));
      },
      error => {
        this._callback(callback, error);
      }
    );
  }

  getBatched(passedNode, callback) {
    if (this.options.batchInterval <= 0) {
      this.get(passedNode, callback);
      return;
    }

    const node = this._normalizeNode(passedNode);
    const existingNode = this._nodes.get(node.uri);

    if (existingNode != null) {
      this._callback(callback, existingNode);
      return;
    }

    const previous = this._batch.queue.get(node.uri) || {
      node,
      callbacks: [],
    };
    previous.callbacks.push(callback);
    this._batch.queue.set(node.uri, previous);

    if (this._batch.timeoutId != null) {
      return;
    }

    this._batch.timeoutId = setTimeout(
      this._flushBatch.bind(this),
      this.options.batchInterval
    );
  }

  reportPrefetchableNode(passedNode) {
    const node = this._normalizeNode(passedNode);
    const previous = this._prefetchableNodes.get(node.uri);
    if (previous != null && previous.value !== node.value) {
      console.warn(
        "djedi-react: Encountered two nodes with the same URI but with different default values. Using the previous value and ignoring the next.",
        {
          uri: node.uri,
          prev: previous.value,
          next: node.value,
        }
      );
      return;
    }
    this._prefetchableNodes.set(node.uri, node);
  }

  prefetch({ filter = undefined, extra = [] } = {}) {
    const nodes = {};
    this._prefetchableNodes.forEach(node => {
      if (
        this._nodes.get(node.uri) == null &&
        (filter == null ||
          filter(this._parseUri(node.uri, { applyDefaults: false })))
      ) {
        nodes[node.uri] = node.value;
      }
    });
    extra.forEach(node => {
      const uri = this._normalizeUri(node.uri);
      if (this._nodes.get(uri) == null) {
        nodes[uri] = node.value;
      }
    });

    const promise =
      Object.keys(nodes).length === 0
        ? Promise.resolve({})
        : this._fetchMany(nodes);

    return promise.then(results => {
      this._lastPrefetch = results;
      return results;
    });
  }

  // Needed to pick up the results from `prefetch` after server-side rendering.
  addNodes(nodes) {
    Object.keys(nodes).forEach(uri => {
      const uriObject = this._parseUri(uri);
      const value = nodes[uri];

      const node = { uri: this._stringifyUri(uriObject), value };
      this._nodes.set(node.uri, node);

      // If the returned node URI has a version, also set the versionless URI to
      // the same node. A request for `home/text.md` can return a URI ending
      // with for example `home/text.md#1` if the user has edited the node.
      if (uriObject.version) {
        const versionlessUri = this._stringifyUri({
          ...uriObject,
          version: "",
        });

        // If the versionless URI has already been set, don’t update it so that
        // if a node re-renders it doesn’t unexpectedly change its text.
        if (this._nodes.get(versionlessUri) == null) {
          this._nodes.set(versionlessUri, node);
        }
      }
    });
  }

  injectAdmin() {
    if (typeof document === "undefined") {
      return Promise.resolve(false);
    }

    const url = `${this.options.baseUrl}/embed/`;

    return unfetch(url, { credentials: "include" }).then(response => {
      if (response.status >= 200 && response.status < 400) {
        return response.text().then(html => {
          // Browsers don’t allow <script> tags inserted as part of an HTML
          // chunk to modify `document.domain`, so cut out the domain and set it
          // manually.
          const [, , domain] = DOCUMENT_DOMAIN_REGEX.exec(html) || [];
          if (domain != null) {
            document.domain = domain;
          }
          document.body.insertAdjacentHTML("beforeend", html);
          return true;
        });
      }
      if (response.status === 403) {
        return false;
      }
      return Promise.reject(
        createStatusCodeError(
          response.status,
          "200 <= status < 400 or status = 403"
        )
      );
    });
  }

  reportRenderedNode(passedNode) {
    const node = this._normalizeNode(passedNode);
    const previous = this._renderedNodes.get(node.uri);

    if (previous != null) {
      if (previous.value !== node.value) {
        console.warn(
          "djedi-react: Rendering a node with with a different default value. That default will be ignored.",
          {
            uri: node.uri,
            prev: previous.value,
            next: node.value,
          }
        );
      }
      previous.numInstances++;
      return;
    }

    this._renderedNodes.set(node.uri, {
      value: node.value,
      numInstances: 1,
    });

    this._DJEDI_NODES[this._djediNodesUri(node.uri)] = node.value;
  }

  reportRemovedNode(passedUri) {
    const uri = this._normalizeUri(passedUri);
    const previous = this._renderedNodes.get(uri);

    if (previous == null) {
      return;
    }

    previous.numInstances--;

    if (previous.numInstances <= 0) {
      this._renderedNodes.delete(uri);
      delete this._DJEDI_NODES[this._djediNodesUri(uri)];
    }
  }

  element(uri) {
    const uriObject = this._parseUri(uri);
    return {
      tag: "span",
      attributes: {
        "data-i18n": this._stringifyUri({
          ...uriObject,
          scheme: "",
          ext: "",
          version: "",
        }),
      },
    };
  }

  // Calls `callback(node)` and also updates the last return value of
  // `djedi.prefetch()`. This is really ugly but needed for server-side
  // rendering.
  _callback(callback, node) {
    if (!(node instanceof Error)) {
      this._lastPrefetch[node.uri] = node.value;
    }
    callback(node);
  }

  _parseUri(uri, { applyDefaults = true } = {}) {
    const { defaults, namespaceByScheme, separators } = this.options.uri;
    const uriObject = parseUri(uri, separators);
    return applyDefaults
      ? applyUriDefaults(uriObject, defaults, namespaceByScheme)
      : uriObject;
  }

  _stringifyUri(uriObject) {
    return stringifyUri(uriObject, this.options.uri.separators);
  }

  _normalizeUri(uri) {
    return this._stringifyUri(this._parseUri(uri));
  }

  _normalizeNode(node) {
    return {
      ...node,
      uri: this._normalizeUri(node.uri),
    };
  }

  _djediNodesUri(uri) {
    const uriObject = this._parseUri(uri);
    return this._stringifyUri({ ...uriObject, version: "" });
  }

  _flushBatch() {
    const { queue } = this._batch;

    const nodes = {};
    queue.forEach((data, uri) => {
      nodes[uri] = data.node.value;
    });

    this._batch = makeEmptyBatch();

    this._fetchMany(nodes).then(
      () => {
        queue.forEach((data, uri) => {
          const node = this._nodes.get(uri) || missingUriError(uri);
          data.callbacks.forEach(callback => {
            this._callback(callback, node);
          });
        });
      },
      error => {
        queue.forEach(data => {
          data.callbacks.forEach(callback => {
            this._callback(callback, error);
          });
        });
      }
    );
  }

  _fetchMany(nodes) {
    // `JSON.stringify` excludes keys whose values are `undefined`. Change them
    // to `null` so that all keys are sent to the backend.
    const nodesWithNull = Object.keys(nodes).reduce((result, key) => {
      const value = nodes[key];
      result[key] = value === undefined ? null : value;
      return result;
    }, {});
    return this._post("/nodes/", nodesWithNull).then(results => {
      this.addNodes(results);
      return results;
    });
  }

  _post(passedUrl, data) {
    const url = `${this.options.baseUrl}${passedUrl}`;
    const info = { method: "POST", apiUrl: url, data };

    return unfetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      // Make the body easier to read in test snapshots. It’s important to still
      // call `JSON.stringify` so we know that `data` actually can be
      // stringified.
      body:
        // istanbul ignore next
        // eslint-disable-next-line no-undef
        typeof process !== "undefined" && process.env.NODE_ENV === "test"
          ? JSON.parse(JSON.stringify(data))
          : JSON.stringify(data),
    })
      .then(
        response => {
          if (response.status >= 200 && response.status < 400) {
            return response
              .json()
              .catch(error => Promise.reject({ response, error }));
          }
          return Promise.reject({
            response,
            error: createStatusCodeError(
              response.status,
              "200 <= status < 400"
            ),
          });
        },
        passedError => {
          // In IE11 the error can be a `ProgressEvent` (I guess it’s due to how
          // the `unfetch` “polyfill” is implemented). Make sure to always
          // return `Error`s so `foo instanceof Error` checks can be used.
          // istanbul ignore next
          const error =
            passedError instanceof Error
              ? passedError
              : new Error("fetch error");
          return Promise.reject({ response: undefined, error });
        }
      )
      .catch(({ response, error }) => {
        error.message = createUpdatedErrorMessage(error, info, response);
        error.status = response == null ? -1 : response.status;

        const textPromise =
          response == null || response.bodyUsed
            ? Promise.resolve("")
            : response.text().then(
                text => text,
                // istanbul ignore next
                () => ""
              );

        return textPromise.then(text => {
          error.responseText = text;
          return Promise.reject(error);
        });
      });
  }
}

// This is a function, not a constant, since it can be mutated by the user.
function makeDefaultOptions() {
  return {
    baseUrl: "/djedi",
    batchInterval: 10, // ms
    defaultRender: state => {
      switch (state.type) {
        case "loading":
          return "Loading…";
        case "error":
          return `Failed to fetch content 😞 (${state.error.status})`;
        case "success":
          return state.content;
        // istanbul ignore next
        default:
          return null;
      }
    },
    uri: {
      defaults: {
        scheme: "i18n",
        namespace: "",
        path: "",
        ext: "txt",
        version: "",
      },
      namespaceByScheme: {
        i18n: "en-us",
        l10n: "local",
        g11n: "global",
      },
      separators: {
        scheme: "://",
        namespace: "@",
        path: "/",
        ext: ".",
        version: "#",
      },
    },
  };
}

// This is a function, not a constant, since it will be mutated.
function makeEmptyBatch() {
  return {
    timeoutId: undefined,
    queue: new Map(),
  };
}

function createStatusCodeError(status, expected) {
  const error = new Error(
    `Unexpected response status code. Got ${status} but expected ${expected}.`
  );
  error.status = status;
  return error;
}

function createUpdatedErrorMessage(error, info, response) {
  return [
    `Djedi API error for request: ${info.method} ${info.apiUrl}`,
    `RequestData sent: ${JSON.stringify(info.data, null, 2)}`,
    `Response: ${
      response ? `${response.status} ${response.statusText}` : response
    }`,
    String(error),
  ].join("\n");
}

function missingUriError(uri) {
  const error = new Error(`Missing result for node: ${uri}`);
  error.status = 1404;
  return error;
}

export default new Djedi();
<|endoftext|>


<commit_before>const showLoadingImage = require('../../../../base/utility').showLoadingImage;
const Content          = require('../../../../common_functions/content').Content;
const ValidateV2       = require('../../../../common_functions/validation_v2').ValidateV2;
const ValidationUI     = require('../../../../validator').ValidationUI;
const validate_object  = require('../../../../validator').validate_object;
const bind_validation  = require('../../../../validator').bind_validation;
const moment           = require('moment');
const dv               = require('../../../../../lib/validation');
const TimePicker       = require('../../../../components/time_picker').TimePicker;
const DatePicker       = require('../../../../components/date_picker').DatePicker;
const dateValueChanged = require('../../../../common_functions/common_functions').dateValueChanged;
const localize         = require('../../../../base/localize').localize;
const Client           = require('../../../../base/client').Client;

const SelfExclusionWS = (function() {
    'use strict';

    let $form,
        $loading,
        fields;

    const timeDateID  = 'timeout_until_duration',
        timeID      = 'timeout_until',
        dateID      = 'exclude_until',
        errorClass  = 'errorfield',
        hiddenClass = 'hidden';

    // ----------------------
    // ----- Get Values -----
    // ----------------------
    const getRequest = function() {
        BinarySocket.send({ get_self_exclusion: 1 });
    };

    const getResponse = function(response) {
        if (response.error) {
            if (response.error.code === 'ClientSelfExclusion') {
                Client.send_logout_request();
            }
            if (response.error.message) {
                showPageError(response.error.message, true);
            }
            return false;
        }
        $loading.addClass(hiddenClass);
        $form.removeClass(hiddenClass);
        $.each(response.get_self_exclusion, function(key, value) {
            fields[key] = value + '';
            $form.find('#' + key).val(value);
        });
        return true;
    };

    const initDatePicker = function() {
        const timePickerInst = new TimePicker('#' + timeID);
        timePickerInst.show();
        // 6 weeks
        const datePickerTime = new DatePicker('#' + timeDateID);
        datePickerTime.show({
            minDate: 'today',
            maxDate: 6 * 7,
        });
        // 5 years
        const datePickerDate = new DatePicker('#' + dateID);
        datePickerDate.show({
            minDate: moment().add(moment.duration(6, 'months')).toDate(),
            maxDate: 5 * 365,
        });
        $('#' + timeDateID + ', #' + dateID).change(function() {
            dateValueChanged(this, 'date');
        });
    };

    // ----------------------
    // ----- Set Values -----
    // ----------------------
    const setRequest = function(data) {
        data.set_self_exclusion = 1;
        BinarySocket.send(data);
    };

    const setResponse = function(response) {
        if (response.error) {
            const errMsg = response.error.message;
            const field  = response.error.field;
            if (field) {
                ValidationUI.draw('input[name=' + field + ']', errMsg);
            } else {
                showFormMessage(localize(errMsg), false);
            }
            return;
        }
        showFormMessage('Your changes have been updated.', true);
        Client.set('session_start', moment().unix()); // used to handle session duration limit
        getRequest();
    };

    const validate = function(data) {
        if (data.exclude_until) {
            delete data.exclude_until;
            data.exclude_until = $('#' + dateID).attr('data-value');
        }
        if (data.timeout_until_duration) {
            delete data.timeout_until_duration;
            data.timeout_until_duration = $('#' + timeDateID).attr('data-value');
        }
        const info = validate_object(data, getSchema());
        info.errors = info.errors.filter(function(e) {
            return e.err !== EMPTY;
        });
        return info;
    };

    const reallyInit = function() {
        $form    = $('#frmSelfExclusion');
        $loading = $('#loading');

        // for error messages to show properly
        $('#' + timeID).attr('style', 'margin-bottom:10px');

        showLoadingImage($loading);

        fields = {};
        $form.find('input').each(function() {
            fields[this.name] = '';
        });

        bind_validation.simple($form[0], {
            validate: validate,
            submit  : submitForm,
        });

        initDatePicker();
        getRequest();
    };

    const onLoad = function() {
        Content.populate();
        BinarySocket.init({
            onmessage: function(msg) {
                const response = JSON.parse(msg.data);
                const msg_type = response.msg_type;
                if (msg_type === 'get_self_exclusion') getResponse(response);
                else if (msg_type === 'set_self_exclusion') setResponse(response);
            },
        });
        reallyInit();
    };

    // To propagate empty values.
    const EMPTY = function() {};
    const allowEmpty = function(value) {
        return value.length > 0 ?
            dv.ok(value) :
            dv.fail(EMPTY);
    };

    const afterToday = function(date) {
        return date.isAfter(moment()) ?
            dv.ok(date) :
            dv.fail('Exclude time must be after today.');
    };

    // big unsigned integer.
    const big_uint = function(x) {
        return x.replace(/^0+/, '');
    };

    big_uint.gt = function(x, y) {
        const maxLength = Math.max(x.length, y.length);
        const lhs = leftPadZeros(x, maxLength);
        const rhs = leftPadZeros(y, maxLength);
        return lhs > rhs; // lexicographical comparison
    };

    // Let empty values go to next validator, because it
    // is ok to put empty values at this stage.
    const numericOrEmpty = function(value) {
        if (!value) return dv.ok(value);
        return /^\d+$/.test(value) ?
            dv.ok(big_uint(value)) :
            dv.fail('Please enter an integer value');
    };

    const leftPadZeros = function(strint, zeroCount) {
        let result = strint;
        for (let i = 0; i < (zeroCount - strint.length); i++) {
            result = '0' + result;
        }
        return result;
    };

    const againstField = function(key) {
        return function(value) {
            const old = fields[key];
            const err = localize('Please enter a number between 0 and [_1]', [old]);
            const hasOld = !!old;
            const isEmpty = value.length === 0;
            if (!hasOld) {
                return isEmpty ? dv.fail(EMPTY) : dv.ok(value);
            }
            return (isEmpty || big_uint.gt(value, old)) ?
                dv.fail(err) :
                dv.ok(value);
        };
    };

    const validSessionDuration = function(value) {
        return +value <= moment.duration(6, 'weeks').as('minutes') ?
            dv.ok(value) :
            dv.fail('Session duration limit cannot be more than 6 weeks.');
    };

    const validExclusionDate = function(date) {
        const six_months = moment().add(moment.duration(6, 'months'));
        const five_years = moment().add(moment.duration(5, 'years'));
        return (
            (date.isBefore(six_months) && dv.fail('Exclude time cannot be less than 6 months.')) ||
            (date.isAfter(five_years)  && dv.fail('Exclude time cannot be for more than 5 years.')) ||
            dv.ok(date)
        );
    };

    const toDateString = function(date) {
        return dv.ok(date.format('YYYY-MM-DD'));
    };

    const allowEmptyUnless = function(key) {
        return function(value, data) {
            if (value.length > 0) return dv.ok(value);
            if (data[key].length > 0) return dv.fail('Please select a valid date');
            return dv.fail(EMPTY);
        };
    };

    let schema;
    const getSchema = function() {
        if (schema) return schema;
        const V2 = ValidateV2;
        const validTime = V2.momentFmt('HH:mm', 'Please select a valid time');
        const validDate = V2.momentFmt('YYYY-MM-DD', 'Please select a valid date');

        schema = {
            max_7day_losses       : [numericOrEmpty, againstField('max_7day_losses')],
            max_7day_turnover     : [numericOrEmpty, againstField('max_7day_turnover')],
            max_30day_losses      : [numericOrEmpty, againstField('max_30day_losses')],
            max_30day_turnover    : [numericOrEmpty, againstField('max_30day_turnover')],
            max_balance           : [numericOrEmpty, againstField('max_balance')],
            max_losses            : [numericOrEmpty, againstField('max_losses')],
            max_open_bets         : [numericOrEmpty, againstField('max_open_bets')],
            max_turnover          : [numericOrEmpty, againstField('max_turnover')],
            session_duration_limit: [numericOrEmpty, againstField('session_duration_limit'), validSessionDuration],
            exclude_until         : [allowEmpty, validDate, afterToday, validExclusionDate, toDateString],
            // these two are combined.
            timeout_until_duration: [allowEmptyUnless('timeout_until'), validDate],
            timeout_until         : [allowEmpty, validTime],
        };
        return schema;
    };

    const detectChange = function(a, b) {
        const k_a = Object.keys(a);
        const k_b = Object.keys(b);
        if (k_a.length !== k_b.length) {
            return true;
        }
        for (let i = 0; i < k_a.length; i++) {
            const key = k_a[i];
            if (a[key] !== b[key]) return true;
        }
        return false;
    };

    const submitForm = function(e, validation) {
        e.preventDefault();
        e.stopPropagation();
        clearError();
        const info = validateForm(validation);
        if (!info.valid) return;
        if (!info.changed) {
            showFormMessage('You did not change anything.', false);
            return;
        }
        if ('timeout_until' in info.data || 'exclude_until' in info.data) {
            if (!hasConfirmed()) return;
        }
        setRequest(info.data);
    };

    const validateForm = function(validation) {
        const values = validation.values;
        let valid = validation.errors.length === 0;

        // Do the date time addition and validation here
        let date = values.timeout_until_duration;
        if (date) {
            // If we've gotten this far then there must *not*
            // be an error with the timeout date.
            date = moment(date);
            const time = values.timeout_until;
            if (time) {
                date = date.add(time.format('HH'), 'hours').add(time.format('mm'), 'minutes');
            }
            const six_weeks = moment().add(moment.duration(6, 'weeks'));
            const res = dv.first(date, [
                afterToday,
                dv.check(function(d) { return !d.isAfter(six_weeks); }, 'Exclude time cannot be more than 6 weeks'),
            ]);
            if (!res.isOk) {
                ValidationUI.draw(
                    'input[name=timeout_until_duration]',
                    res.value[0]);
                valid = false;
            } else {
                delete values.timeout_until_duration;
                values.timeout_until = date.unix();
            }
        }

        return {
            data   : values,
            valid  : valid,
            changed: valid && detectChange(validation.raw, fields),
        };
    };

    const hasConfirmed = function() {
        const message = 'When you click "Ok" you will be excluded from trading on the site until the selected date.';
        return window.confirm(localize(message));
    };

    // -----------------------------
    // ----- Message Functions -----
    // -----------------------------
    const showPageError = function(errMsg, hideForm) {
        $('#errorMsg').html(errMsg).removeClass(hiddenClass);
        if (hideForm) {
            $form.addClass(hiddenClass);
        }
    };

    const clearError = function() {
        $('#errorMsg').html('').addClass(hiddenClass);
        $('#formMessage').html('');
    };

    const showFormMessage = function(msg, isSuccess) {
        $('#formMessage')
            .attr('class', isSuccess ? 'success-msg' : errorClass)
            .html(isSuccess ? '

' + localize(msg) + '

' : localize(msg))
            .css('display', 'block')
            .delay(5000)
            .fadeOut(1000);
    };

    return {
        onLoad: onLoad,
    };
})();

module.exports = SelfExclusionWS;
<commit_msg>minimum 6 months<commit_after>const showLoadingImage = require('../../../../base/utility').showLoadingImage;
const Content          = require('../../../../common_functions/content').Content;
const ValidateV2       = require('../../../../common_functions/validation_v2').ValidateV2;
const ValidationUI     = require('../../../../validator').ValidationUI;
const validate_object  = require('../../../../validator').validate_object;
const bind_validation  = require('../../../../validator').bind_validation;
const moment           = require('moment');
const dv               = require('../../../../../lib/validation');
const TimePicker       = require('../../../../components/time_picker').TimePicker;
const DatePicker       = require('../../../../components/date_picker').DatePicker;
const dateValueChanged = require('../../../../common_functions/common_functions').dateValueChanged;
const localize         = require('../../../../base/localize').localize;
const Client           = require('../../../../base/client').Client;

const SelfExclusionWS = (function() {
    'use strict';

    let $form,
        $loading,
        fields;

    const timeDateID  = 'timeout_until_duration',
        timeID      = 'timeout_until',
        dateID      = 'exclude_until',
        errorClass  = 'errorfield',
        hiddenClass = 'hidden';

    // ----------------------
    // ----- Get Values -----
    // ----------------------
    const getRequest = function() {
        BinarySocket.send({ get_self_exclusion: 1 });
    };

    const getResponse = function(response) {
        if (response.error) {
            if (response.error.code === 'ClientSelfExclusion') {
                Client.send_logout_request();
            }
            if (response.error.message) {
                showPageError(response.error.message, true);
            }
            return false;
        }
        $loading.addClass(hiddenClass);
        $form.removeClass(hiddenClass);
        $.each(response.get_self_exclusion, function(key, value) {
            fields[key] = value + '';
            $form.find('#' + key).val(value);
        });
        return true;
    };

    const initDatePicker = function() {
        const timePickerInst = new TimePicker('#' + timeID);
        timePickerInst.show();
        // 6 weeks
        const datePickerTime = new DatePicker('#' + timeDateID);
        datePickerTime.show({
            minDate: 'today',
            maxDate: 6 * 7,
        });
        // 5 years
        const datePickerDate = new DatePicker('#' + dateID);
        datePickerDate.show({
            minDate: moment().add(6, 'months').add(1, 'day').toDate(),
            maxDate: 5 * 365,
        });
        $('#' + timeDateID + ', #' + dateID).change(function() {
            dateValueChanged(this, 'date');
        });
    };

    // ----------------------
    // ----- Set Values -----
    // ----------------------
    const setRequest = function(data) {
        data.set_self_exclusion = 1;
        BinarySocket.send(data);
    };

    const setResponse = function(response) {
        if (response.error) {
            const errMsg = response.error.message;
            const field  = response.error.field;
            if (field) {
                ValidationUI.draw('input[name=' + field + ']', errMsg);
            } else {
                showFormMessage(localize(errMsg), false);
            }
            return;
        }
        showFormMessage('Your changes have been updated.', true);
        Client.set('session_start', moment().unix()); // used to handle session duration limit
        getRequest();
    };

    const validate = function(data) {
        if (data.exclude_until) {
            delete data.exclude_until;
            data.exclude_until = $('#' + dateID).attr('data-value');
        }
        if (data.timeout_until_duration) {
            delete data.timeout_until_duration;
            data.timeout_until_duration = $('#' + timeDateID).attr('data-value');
        }
        const info = validate_object(data, getSchema());
        info.errors = info.errors.filter(function(e) {
            return e.err !== EMPTY;
        });
        return info;
    };

    const reallyInit = function() {
        $form    = $('#frmSelfExclusion');
        $loading = $('#loading');

        // for error messages to show properly
        $('#' + timeID).attr('style', 'margin-bottom:10px');

        showLoadingImage($loading);

        fields = {};
        $form.find('input').each(function() {
            fields[this.name] = '';
        });

        bind_validation.simple($form[0], {
            validate: validate,
            submit  : submitForm,
        });

        initDatePicker();
        getRequest();
    };

    const onLoad = function() {
        Content.populate();
        BinarySocket.init({
            onmessage: function(msg) {
                const response = JSON.parse(msg.data);
                const msg_type = response.msg_type;
                if (msg_type === 'get_self_exclusion') getResponse(response);
                else if (msg_type === 'set_self_exclusion') setResponse(response);
            },
        });
        reallyInit();
    };

    // To propagate empty values.
    const EMPTY = function() {};
    const allowEmpty = function(value) {
        return value.length > 0 ?
            dv.ok(value) :
            dv.fail(EMPTY);
    };

    const afterToday = function(date) {
        return date.isAfter(moment()) ?
            dv.ok(date) :
            dv.fail('Exclude time must be after today.');
    };

    // big unsigned integer.
    const big_uint = function(x) {
        return x.replace(/^0+/, '');
    };

    big_uint.gt = function(x, y) {
        const maxLength = Math.max(x.length, y.length);
        const lhs = leftPadZeros(x, maxLength);
        const rhs = leftPadZeros(y, maxLength);
        return lhs > rhs; // lexicographical comparison
    };

    // Let empty values go to next validator, because it
    // is ok to put empty values at this stage.
    const numericOrEmpty = function(value) {
        if (!value) return dv.ok(value);
        return /^\d+$/.test(value) ?
            dv.ok(big_uint(value)) :
            dv.fail('Please enter an integer value');
    };

    const leftPadZeros = function(strint, zeroCount) {
        let result = strint;
        for (let i = 0; i < (zeroCount - strint.length); i++) {
            result = '0' + result;
        }
        return result;
    };

    const againstField = function(key) {
        return function(value) {
            const old = fields[key];
            const err = localize('Please enter a number between 0 and [_1]', [old]);
            const hasOld = !!old;
            const isEmpty = value.length === 0;
            if (!hasOld) {
                return isEmpty ? dv.fail(EMPTY) : dv.ok(value);
            }
            return (isEmpty || big_uint.gt(value, old)) ?
                dv.fail(err) :
                dv.ok(value);
        };
    };

    const validSessionDuration = function(value) {
        return +value <= moment.duration(6, 'weeks').as('minutes') ?
            dv.ok(value) :
            dv.fail('Session duration limit cannot be more than 6 weeks.');
    };

    const validExclusionDate = function(date) {
        const six_months = moment().add(moment.duration(6, 'months'));
        const five_years = moment().add(moment.duration(5, 'years'));
        return (
            (date.isBefore(six_months) && dv.fail('Exclude time cannot be less than 6 months.')) ||
            (date.isAfter(five_years)  && dv.fail('Exclude time cannot be for more than 5 years.')) ||
            dv.ok(date)
        );
    };

    const toDateString = function(date) {
        return dv.ok(date.format('YYYY-MM-DD'));
    };

    const allowEmptyUnless = function(key) {
        return function(value, data) {
            if (value.length > 0) return dv.ok(value);
            if (data[key].length > 0) return dv.fail('Please select a valid date');
            return dv.fail(EMPTY);
        };
    };

    let schema;
    const getSchema = function() {
        if (schema) return schema;
        const V2 = ValidateV2;
        const validTime = V2.momentFmt('HH:mm', 'Please select a valid time');
        const validDate = V2.momentFmt('YYYY-MM-DD', 'Please select a valid date');

        schema = {
            max_7day_losses       : [numericOrEmpty, againstField('max_7day_losses')],
            max_7day_turnover     : [numericOrEmpty, againstField('max_7day_turnover')],
            max_30day_losses      : [numericOrEmpty, againstField('max_30day_losses')],
            max_30day_turnover    : [numericOrEmpty, againstField('max_30day_turnover')],
            max_balance           : [numericOrEmpty, againstField('max_balance')],
            max_losses            : [numericOrEmpty, againstField('max_losses')],
            max_open_bets         : [numericOrEmpty, againstField('max_open_bets')],
            max_turnover          : [numericOrEmpty, againstField('max_turnover')],
            session_duration_limit: [numericOrEmpty, againstField('session_duration_limit'), validSessionDuration],
            exclude_until         : [allowEmpty, validDate, afterToday, validExclusionDate, toDateString],
            // these two are combined.
            timeout_until_duration: [allowEmptyUnless('timeout_until'), validDate],
            timeout_until         : [allowEmpty, validTime],
        };
        return schema;
    };

    const detectChange = function(a, b) {
        const k_a = Object.keys(a);
        const k_b = Object.keys(b);
        if (k_a.length !== k_b.length) {
            return true;
        }
        for (let i = 0; i < k_a.length; i++) {
            const key = k_a[i];
            if (a[key] !== b[key]) return true;
        }
        return false;
    };

    const submitForm = function(e, validation) {
        e.preventDefault();
        e.stopPropagation();
        clearError();
        const info = validateForm(validation);
        if (!info.valid) return;
        if (!info.changed) {
            showFormMessage('You did not change anything.', false);
            return;
        }
        if ('timeout_until' in info.data || 'exclude_until' in info.data) {
            if (!hasConfirmed()) return;
        }
        setRequest(info.data);
    };

    const validateForm = function(validation) {
        const values = validation.values;
        let valid = validation.errors.length === 0;

        // Do the date time addition and validation here
        let date = values.timeout_until_duration;
        if (date) {
            // If we've gotten this far then there must *not*
            // be an error with the timeout date.
            date = moment(date);
            const time = values.timeout_until;
            if (time) {
                date = date.add(time.format('HH'), 'hours').add(time.format('mm'), 'minutes');
            }
            const six_weeks = moment().add(moment.duration(6, 'weeks'));
            const res = dv.first(date, [
                afterToday,
                dv.check(function(d) { return !d.isAfter(six_weeks); }, 'Exclude time cannot be more than 6 weeks'),
            ]);
            if (!res.isOk) {
                ValidationUI.draw(
                    'input[name=timeout_until_duration]',
                    res.value[0]);
                valid = false;
            } else {
                delete values.timeout_until_duration;
                values.timeout_until = date.unix();
            }
        }

        return {
            data   : values,
            valid  : valid,
            changed: valid && detectChange(validation.raw, fields),
        };
    };

    const hasConfirmed = function() {
        const message = 'When you click "Ok" you will be excluded from trading on the site until the selected date.';
        return window.confirm(localize(message));
    };

    // -----------------------------
    // ----- Message Functions -----
    // -----------------------------
    const showPageError = function(errMsg, hideForm) {
        $('#errorMsg').html(errMsg).removeClass(hiddenClass);
        if (hideForm) {
            $form.addClass(hiddenClass);
        }
    };

    const clearError = function() {
        $('#errorMsg').html('').addClass(hiddenClass);
        $('#formMessage').html('');
    };

    const showFormMessage = function(msg, isSuccess) {
        $('#formMessage')
            .attr('class', isSuccess ? 'success-msg' : errorClass)
            .html(isSuccess ? '

' + localize(msg) + '

' : localize(msg))
            .css('display', 'block')
            .delay(5000)
            .fadeOut(1000);
    };

    return {
        onLoad: onLoad,
    };
})();

module.exports = SelfExclusionWS;
<|endoftext|>