Show:

File: app/components/exp-frame-base/component.js

import Ember from 'ember';
import Ajv from 'ajv';

//import config from 'ember-get-config';
import FullScreen from '../../mixins/full-screen';
import SessionRecord from '../../mixins/session-record';

/**
 * @module exp-player
 * @submodule frames
 */

/** An abstract component to extend when defining new Lookit frames
 *
 * This provides common base behavior required for any experiment frame. All experiment frames must extend this one.
 *
 * This frame has no configuration options because all of its logic is internal, and should not be directly used
 * in an experiment.
 *
 * As a user you will almost never need to insert a component into a template directly- the platform should handle that
 *  by automatically inserting an <a href="../classes/Exp-player.html" class="crosslink">exp-player</a> component when your experiment starts.
 * However, a sample template usage is provided below for completeness.
 *
 * ```handlebars
 *  {{
      component currentFrameTemplate
        frameIndex=frameIndex
        frameConfig=currentFrameConfig
        frameContext=currentFrameContext

        session=session
        experiment=experiment

        next=(action 'next')
        exit=(action 'exit')
        previous=(action 'previous')
        saveHandler=(action 'saveFrame')
        skipone=(action 'skipone')
        extra=extra
    }}
 * ```
 * @class Exp-frame-base
 *
 * @uses Full-screen
 * @uses Session-record
 */
export default Ember.Component.extend(FullScreen, SessionRecord, {
    toast: Ember.inject.service(),
    // {String} the unique identifier for the _instance_
    id: null,
    kind: null,

    classNames: ['lookit-frame'],

    extra: {},

    mergedProperties: ['frameSchemaProperties'],
    concatenatedProperties: ['frameSchemaRequired'],

    frameSchemaRequired: [],
    frameSchemaProperties: { // Configuration parameters, which can be auto-populated from the experiment structure JSON
    },

    meta: { // Configuration for all fields available on the component/template
        data: { // Controls what and how parameters are serialized and sent to the server. Ideally there should be a validation mechanism.
            type: 'object',
            properties: {}
        }
    },
    // {Number} the current exp-player frameIndex
    frameIndex: null,
    frameConfig: null,
    frameContext: null,
    frameType: 'DEFAULT',
    eventTimings: null,
    _oldFrameIndex: null,

    /**
     * Function to generate additional properties for this frame (like {"kind": "exp-lookit-text"})
     * at the time the frame is initialized. Allows behavior of study to depend on what has
     * happened so far (e.g., answers on a form or to previous test trials).
     * Must be a valid Javascript function, returning an object, provided as
     * a string.
     *
     *
     * Arguments that will be provided are: `expData`, `sequence`, `child`, `pastSessions`, `conditions`.
     *
     *
     * `expData`, `sequence`, and `conditions` are the same data as would be found in the session data shown
     * on the Lookit experimenter interface under 'Individual Responses', except that
     * they will only contain information up to this point in the study.
     *
     *
     * `expData` is an object consisting of `frameId`: `frameData` pairs; the data associated
     * with a particular frame depends on the frame kind.
     *
     *
     * `sequence` is an ordered list of frameIds, corresponding to the keys in `expData`.
     *
     *
     * `conditions` is an object representing the data stored by any randomizer frames;
     * keys are `frameId`s for randomizer frames and data stored depends on the randomizer
     * used.
     *
     *
     * `child` is an object that has the following properties - use child.get(propertyName)
     * to access:
     * - `additionalInformation`: String; additional information field from child form
     * - `ageAtBirth`: String; child's gestational age at birth in weeks. Possible values are
     *   "24" through "39", "na" (not sure or prefer not to answer),
     * "<24" (under 24 weeks), and "40>" (40 or more weeks).
     * - `birthday`: Date object
     * - `gender`: "f" (female), "m" (male), "o" (other), or "na" (prefer not to answer)
     * - `givenName`: String, child's given name/nickname
     * - `id`: String, child UUID
     * - `languageList`: String, space-separated list of languages child is exposed to
     * (2-letter codes)
     * - `conditionList`: String, space-separated list of conditions/characteristics
     * - of child from registration form, as used in criteria expression, e.g.
     * "autism_spectrum_disorder deaf multiple_birth"
     *
     *
     * `pastSessions` is a list of previous response objects for this child and this study,
     * ordered starting from most recent (at index 0 is this session!). Each has properties
     * (access as pastSessions[i].get(propertyName)):
     * - `completed`: Boolean, whether they submitted an exit survey
     * - `completedConsentFrame`: Boolean, whether they got through at least a consent frame
     * - `conditions`: Object representing any conditions assigned by randomizer frames
     * - `createdOn`: Date object
     * - `expData`: Object consisting of frameId: frameData pairs
     * - `globalEventTimings`: list of any events stored outside of individual frames - currently
     *   just used for attempts to leave the study early
     * - `sequence`: ordered list of frameIds, corresponding to keys in expData
     * - `isPreview`: Boolean, whether this is from a preview session (possible in the event
     *   this is an experimenter's account)
     *
     *
     * Example:
     * ```
     * function(expData, sequence, child, pastSessions, conditions) {
     *     return {
     *        'blocks':
     *             [
     *                 {
     *                     'text': 'Name: ' + child.get('givenName')
     *                 },
     *                 {
     *                     'text': 'Frame number: ' + sequence.length
     *                 },
     *                 {
     *                     'text': 'N past sessions: ' + pastSessions.length
     *                 }
     *             ]
     *       };
     *   }
     * ```
     *
     *
     *  (This example is split across lines for readability; when added to JSON it would need
     *  to be on one line.)
     *
     * @property {String} generateProperties
     * @default null
     */
    generateProperties: null,
    generatedProperties: null,
    _generatePropertiesFn: null,

    /**
     * Function to select which frame index to go to when using the 'next' action on this
     * frame. Allows flexible looping / short-circuiting based on what has happened so far
     * in the study (e.g., once the child answers N questions correctly, move on to next
     * segment). Must be a valid Javascript function, returning a number from 0 through
     * frames.length - 1, provided as a string.
     *
     *
     * Arguments that will be provided are:
     * `frames`, `frameIndex`, `expData`, `sequence`, `child`, `pastSessions`
     *
     *
     * `frames` is an ordered list of frame configurations for this study; each element
     * is an object corresponding directly to a frame you defined in the
     * JSON document for this study (but with any randomizer frames resolved into the
     * particular frames that will be used this time).
     *
     *
     * `frameIndex` is the index in `frames` of the current frame
     *
     *
     * `expData` is an object consisting of `frameId`: `frameData` pairs; the data associated
     * with a particular frame depends on the frame kind.
     *
     *
     * `sequence` is an ordered list of frameIds, corresponding to the keys in `expData`.
     *
     *
     * `child` is an object that has the following properties - use child.get(propertyName)
     * to access:
     * - `additionalInformation`: String; additional information field from child form
     * - `ageAtBirth`: String; child's gestational age at birth in weeks. Possible values are
     *   "24" through "39", "na" (not sure or prefer not to answer),
     * "<24" (under 24 weeks), and "40>" (40 or more weeks).
     * - `birthday`: timestamp in format "Mon Apr 10 2017 20:00:00 GMT-0400 (Eastern Daylight Time)"
     * - `gender`: "f" (female), "m" (male), "o" (other), or "na" (prefer not to answer)
     * - `givenName`: String, child's given name/nickname
     * - `id`: String, child UUID
     *
     *
     * `pastSessions` is a list of previous response objects for this child and this study,
     * ordered starting from most recent (at index 0 is this session!). Each has properties
     * (access as pastSessions[i].get(propertyName)):
     * - `completed`: Boolean, whether they submitted an exit survey
     * - `completedConsentFrame`: Boolean, whether they got through at least a consent frame
     * - `conditions`: Object representing any conditions assigned by randomizer frames
     * - `createdOn`: timestamp in format "Thu Apr 18 2019 12:33:26 GMT-0400 (Eastern Daylight Time)"
     * - `expData`: Object consisting of frameId: frameData pairs
     * - `globalEventTimings`: list of any events stored outside of individual frames - currently
     *   just used for attempts to leave the study early
     * - `sequence`: ordered list of frameIds, corresponding to keys in expData
     *
     *
     * Example that just sends us to the last frame of the study no matter what:
     * ``"function(frames, frameIndex, frameData, expData, sequence, child, pastSessions) {return frames.length - 1;}"```
     *
     *
     * @property {String} selectNextFrame
     * @default null
     */
    selectNextFrame: null,
    _selectNextFrameFn: null,

    /**
     * An object containing values for any parameters (variables) to use in this frame.
     * Any property VALUES in this frame that match any of the property NAMES in `parameters`
     * will be replaced by the corresponding parameter value. For example, suppose your frame
     * is:
     *
```
{
    'kind': 'FRAME_KIND',
    'parameters': {
        'FRAME_KIND': 'exp-lookit-text'
    }
}
```
     *
     * Then the frame `kind` will be `exp-lookit-text`. This may be useful if you need
     * to repeat values for different frame properties, especially if your frame is actually
     * a randomizer or group. You may use parameters nested within objects (at any depth) or
     * within lists.
     *
     * You can also use selectors to randomly sample from or permute
     * a list defined in `parameters`. Suppose `STIMLIST` is defined in
     * `parameters`, e.g. a list of potential stimuli. Rather than just using `STIMLIST`
     * as a value in your frames, you can also:
     *
     * * Select the Nth element (0-indexed) of the value of `STIMLIST`: (Will cause error if `N >= THELIST.length`)
```
    'parameterName': 'STIMLIST#N'
```
     * * Select (uniformly) a random element of the value of `STIMLIST`:
```
    'parameterName': 'STIMLIST#RAND'
```
    * * Set `parameterName` to a random permutation of the value of `STIMLIST`:
```
    'parameterName': 'STIMLIST#PERM'
```
    * * Select the next element in a random permutation of the value of `STIMLIST`, which is used across all
    * substitutions in this randomizer. This allows you, for instance, to provide a list
    * of possible images in your `parameterSet`, and use a different one each frame with the
    * subset/order randomized per participant. If more `STIMLIST#UNIQ` parameters than
    * elements of `STIMLIST` are used, we loop back around to the start of the permutation
    * generated for this randomizer.
```
    'parameterName': 'STIMLIST#UNIQ'
```
     *
     * @property {Object[]} parameters
     * @default {}
     */
    parameters: {},

    session: null,

    frameStartTimestamp: null, // keep track of when frame started to store duration

    // see https://github.com/emberjs/ember.js/issues/3908. Moved
    // to init because we were losing the first event per instance of a frame
    // when it was in didReceiveAttrs.
    setTimings: Ember.on('init', function () {
        this.set('eventTimings', []);
    }),

    loadData: function (frameData) { // eslint-disable-line no-unused-vars
        return null;
    },

    didReceiveAttrs: function () {
        this._super(...arguments);

        if (!this.get('frameConfig')) {
            return;
        }

        let currentFrameIndex = this.get('frameIndex', null);

        let clean = currentFrameIndex !== this.get('_oldFrameIndex');

        var defaultParams = this.setupParams(clean);
        if (clean) {
            Object.keys(defaultParams).forEach((key) => {
                this.set(key, defaultParams[key]);
            });
        }

        if (!this.get('id')) {
            var frameIndex = this.get('frameIndex');
            var kind = this.get('kind');
            this.set('id', `${kind}-${frameIndex}`);
        }

        if (clean) {
            var session = this.get('session');
            var expData = session ? session.get('expData') : null;

            // Load any existing data for this particular frame - e.g. for a survey that
            // the participant is returning to via a previous button.
            if (session && session.get('expData')) {
                var key = this.get('frameIndex') + '-' + this.get('id');
                if (expData[key]) {
                    this.loadData(expData[key]);
                }
            }

            // Use the provided generateProperties fn, if any, to generate properties for this
            // frame on-the-fly based on expData, sequence, child, & pastSessions.
            if (this.get('generateProperties')) { // Only if generateProperties is non-empty
                try {
                    this.set('_generatePropertiesFn', Function('return ' + this.get('generateProperties'))());
                } catch (error) {
                    console.error(error);
                    throw new Error('generateProperties provided for this frame, but cannot be evaluated.');
                }
                if (typeof (this.get('_generatePropertiesFn')) === 'function') {
                    var sequence = session ? session.get('sequence', null) : null;
                    var child = session ? session.get('child', null) : null;
                    var conditions = session ? session.get('conditions', {}) : {};
                    var frameContext = this.get('frameContext');
                    var pastSessions = frameContext ? frameContext.pastSessions : null;
                    var generatedParams = this._generatePropertiesFn(expData, sequence, child, pastSessions, conditions);
                    if (typeof (generatedParams) === 'object') {
                        this.set('generatedProperties', generatedParams);
                        Object.keys(generatedParams).forEach((key) => {
                            this.set(key, generatedParams[key]);
                        });
                    } else {
                        throw new Error('generateProperties function provided for this frame, but did not return an object');
                    }
                } else {
                    throw new Error('generateProperties provided for this frame, but does not evaluate to a function');
                }
            }

            // Use the provided selectNextFrame fn, if any, to determine which frame should come
            // next.
            if (this.get('selectNextFrame')) { // Only if selectNextFrame is non-empty
                try {
                    this.set('_selectNextFrameFn', Function('return ' + this.get('selectNextFrame'))());
                } catch (error) {
                    console.error(error);
                    throw new Error('selectNextFrame provided for this frame, but cannot be evaluated.');
                }
                if (!(typeof (this.get('_selectNextFrameFn')) === 'function')) {
                    throw new Error('selectNextFrame provided for this frame, but does not evaluate to a function');
                }
            }

            // After adding any generated properties, check that all required fields are set
            if (this.get('frameSchemaProperties').hasOwnProperty('required')) {
                var requiredFields = this.get('frameSchemaProperties.required', []);
                requiredFields.forEach((key) => {
                    if (!this.hasOwnProperty(key) || this.get(key) === undefined) {
                        // Don't actually throw an error here because the frame may actually still function and that's probably good
                        console.error(`Missing required parameter '${key}' for frame of kind '${this.get('kind')}'.`);
                    }
                });
            }

            // Use JSON schema validator to check that all values are within specified constraints
            var ajv = new Ajv({
                allErrors: true,
                verbose: true
            });
            var frameSchema = {type: 'object', properties: this.get('frameSchemaProperties')};
            try {
                var validate = ajv.compile(frameSchema);
                var valid = validate(this);
                if (!valid) {
                    console.warn('Invalid: ' + ajv.errorsText(validate.errors));
                }
            }
            catch (error) {
                console.error(`Failed to compile frameSchemaProperties to use for validating researcher usage of frame type '${this.get('kind')}.`);
            }

        }

        this.set('_oldFrameIndex', currentFrameIndex);
    },

    // Internal save logic
    _save() {
        var frameId = `${this.get('id')}`;  // don't prepend frameindex, done by parser
        // When exiting frame, save the data to the base player using the provided saveHandler
        const payload = this.serializeContent();
        return this.attrs.saveHandler(frameId, payload);
    },

    // Display error messages related to save failures
    displayError(error) { // eslint-disable-line no-unused-vars
        // If the save failure was a server error, warn the user. This error should never disappear.
        // Note: errors are not visible in FS mode, which is generally the desired behavior so as not to silently
        // bias infant looking time towards right.
        const msg = 'Check your internet connection. If another error like this still shows up as you continue, please contact lookit-tech@mit.edu to let us know!';
        this.get('toast').error(msg, 'Error: Could not save data', {timeOut: 0, extendedTimeOut: 0});
    },

    setupParams(clean) {
        // Add config properties and data to be serialized as instance parameters (overriding with values explicitly passed in)
        var params = this.get('frameConfig');

        var defaultParams = {};
        Object.keys(this.get('frameSchemaProperties') || {}).forEach((key) => {
            defaultParams[key] = this.get(`frameSchemaProperties.${key}.default`);
        });

        Object.keys(this.get('meta.data').properties || {}).forEach((key) => {
            if (this[key] && this[key].isDescriptor) {
                return;
            }
            var value = !clean ? this.get(key) : undefined;
            if (typeof value === 'undefined') {
                // Make deep copy of the default value (to avoid subtle reference errors from reusing mutable containers)
                defaultParams[key] = Ember.copy(this.get(`frameSchemaProperties.${key}.default`), true);
            } else {
                defaultParams[key] = value;
            }
        });

        // Need to explicitly set defaults for non-meta properties here, otherwise defaults
        // do not overwrite previous properties when no value is provided on the next
        // frame.
        defaultParams.generateProperties = null;
        defaultParams.generatedProperties = null;
        defaultParams.selectNextFrame = null;
        Ember.assign(defaultParams, params);

        return defaultParams;
    },

    /**
     * Any properties generated via a custom generateProperties function provided to this
     * frame (e.g., a score you computed to decide on feedback). In general will be null.
     * @attribute generatedProperties
     */

    /**
     * Duration between frame being inserted and call to `next`
     * @attribute frameDuration
     */

    /**
     * Type of frame: EXIT (exit survey), CONSENT (consent or assent frame), or DEFAULT
     * (anything else)
     * @attribute frameType
     */

    /**
     * Ordered list of events captured during this frame (oldest to newest). Each event is
     * represented as an object with at least the properties
     * `{'eventType': EVENTNAME, 'timestamp': TIMESTAMP}`.
     * See Events tab for details of events that might be captured.
     * @attribute eventTimings
     */

    /**
     * Each frame that extends ExpFrameBase will send at least an array `eventTimings`,
     * a frame type, and any generateProperties back to the server upon completion.
     * Individual frames may define additional properties that are sent.
     *
     * @param {Array} eventTimings
     * @method serializeContent
     * @return {Object}
     */
    serializeContent() {
        // Serialize selected parameters for this frame, plus eventTiming data
        var serialized = this.getProperties(Object.keys(this.get('meta.data.properties') || {}));
        serialized.generatedProperties = this.get('generatedProperties');
        serialized.eventTimings = this.get('eventTimings');
        serialized.frameType = this.get('frameType');
        try {
            serialized.frameDuration = (new Date().getTime() - this.get('frameStartTimestamp'))/1000;
        } catch(e){
            serialized.frameDuration = null;
        }
        return serialized;
    },

    /**
     * Create the time event payload for a particular frame / event. This can be overridden to add fields to every
     *  event sent by a particular frame
     * @method makeTimeEvent
     * @param {String} eventName
     * @param {Object} [extra] An object with additional properties to be sent to the server
     * @return {Object} Event type, time, and any additional metadata provided
     */
    makeTimeEvent(eventName, extra) {
        const curTime = new Date();
        const eventData = {
            eventType: `${this.get('kind', 'unknown-frame')}:${eventName}`,
            timestamp: curTime.toISOString()
        };
        Ember.assign(eventData, extra);
        // Add some extra info if there's session recording ongoing
        if (this.get('sessionRecorder') && this.get('sessionRecordingInProgress')) {
            Ember.assign(eventData, {
                sessionStreamTime: this.get('sessionRecorder').getTime()
            });
        }
        return eventData;

    },

    setSessionCompleted() {
        this.get('session').set('completed', true);
    },

    actions: {
        setTimeEvent(eventName, extra) {
            let eventData = this.makeTimeEvent(eventName, extra);
            console.log(`Timing event captured for ${eventName}`, eventData);
            // Copy timing event into a single dict for this component instance
            let timings = this.get('eventTimings');
            timings.push(eventData);
            this.set('eventTimings', timings);
        },

        save() {
            // Show an error if saving fails
            this._save().catch(err => this.displayError(err));
        },

        next() {
            /**
             * Move to next frame
             *
             * @event nextFrame
             */
            this.send('setTimeEvent', 'nextFrame');

            // Determine which frame to go to next
            var iNextFrame = -1;
            if (this._selectNextFrameFn) {
                var session = this.get('session');
                var expData = session ? session.get('expData') : null;
                var sequence = session ? session.get('sequence', null) : null;
                var child = session ? session.get('child', null) : null;
                var frameContext = this.get('frameContext');
                var pastSessions = frameContext ? frameContext.pastSessions : null;
                var frames = this.get('parentView').get('frames');
                var frameIndex = this.get('frameIndex');
                var frameData = this.serializeContent(); // note - may not have saved to expData yet at time of call

                iNextFrame = this._selectNextFrameFn(frames, frameIndex, frameData, expData, sequence, child, pastSessions);
                if (!(typeof (iNextFrame) === 'number')) {
                    throw new Error('selectNextFrame function provided for this frame, but did not return a number');
                }
            }

            // Note: this will allow participant to proceed even if saving fails. The
            // reason not to execute 'next' within this._save().then() is that an action
            // executed as a promise doesn't count as a 'user interaction' event, so
            // we wouldn't be able to enter FS mode upon starting the next frame. Given
            // that the user is likely to have limited ability to FIX a save error, and the
            // only thing they'll really be able to do is try again anyway, preventing
            // them from continuing is unnecessarily disruptive.
            this.send('save');

            if (this.get('endSessionRecording') && this.get('sessionRecorder')) {
                var _this = this;
                if (!(this.get('session').get('recordingInProgress'))) {
                    _this.sendAction('next', iNextFrame);
                    window.scrollTo(0, 0);
                } else {
                    this.get('session').set('recordingInProgress', false);
                    this.stopSessionRecorder().finally(() => {
                        _this.sendAction('next', iNextFrame);
                        window.scrollTo(0, 0);
                    });
                }

            } else {
                this.sendAction('next', iNextFrame);
                window.scrollTo(0, 0);
            }

        },

        exit() {
            this.sendAction('exit');
        },

        previous() {
            /**
             * Move to previous frame
             *
             * @event previousFrame
             */
            this.send('setTimeEvent', 'previousFrame');
            var frameId = `${this.get('id')}`; // don't prepend frameindex, done by parser
            console.log(`Previous: Leaving frame ID ${frameId}`);
            this.sendAction('previous');
            window.scrollTo(0, 0);
        }
    },

    didInsertElement() {
        // Add different classes depending on whether fullscreen mode is
        // being triggered as part of standard frame operation or as an override to a frame
        // that is not typically fullscreen. In latter case, keep formatting as close to
        // before as possible, to enable forms etc. to work ok in fullscreen mode.

        Ember.$('*').removeClass('player-fullscreen');
        Ember.$('*').removeClass('player-fullscreen-override');
        Ember.$('#application-parse-error-text').hide();
        this.set('frameStartTimestamp', new Date().getTime());
        var $element = Ember.$(`#${this.get('fullScreenElementId')}`);
        if (this.get('displayFullscreenOverride') && !this.get('displayFullscreen')) {
            $element.addClass('player-fullscreen-override');
        } else {
            $element.addClass('player-fullscreen');
        }
        // Set to non-fullscreen (or FS if overriding) immediately, except for frames displayed fullscreen.
        // Note: if this is defined the same way in full-screen.js, it gets called twice
        // for reasons I don't yet understand.
        if (this.get('displayFullscreenOverride') || this.get('displayFullscreen')) {
            this.send('showFullscreen');
        } else {
            this.send('exitFullscreen');
        }
        this._super(...arguments);
    }
});