/** * Custom UI for banner sequence campaign mixin administration. * * Code layout and separation of concerns * -------------------------------------- * * The code for this part of the UI roughly follows an MVC pattern. Here are the * components and rules for separation of concerns: * * - Controller (BannerSequenceUiController): * - Controls most interactions among components and with other parts of the UI (managed * by ext.centralNotice.adminUi.campaignManager). The main exceptions to this rule * are: widgets notify the controller of updates, may request certain information from * the controller, and manage their contained widgets. * - The controller instantiates the main container widget. * - It provides methods to handle changes to the model. * - To act on smaller widgets contained within the main container widget, the * controller either goes through the main container, or receives a reference to the * contained widget. * * - Model (BannerSequenceUiModel): * - The model is passive, and acts only on itself. * - It is responsible for validation, providing default values, and updating the data. * * - View widgets (BannerSequenceWidget, BucketSeqContainerWidget, BucketSeqWidget, * StepWidget): * - Widgets receive a model on instantiation (either the full model, in the case of * the main container widget, or submodels that correspond to the data they set). * - Widgets provide updateFromModel() methods, in which they update their values, add * or remove contained widgets as needed, and tell contained widgets to update their * values. * - Widgets can read from their models, but cannot alter on the models directly; to * update the data, widgets go through the controller. * - Widgets receive user interactions directly, manage their own state, display * validation error messages, keep track of contained widgets, and handle events from * contained widgets. * * Data structure for sequences * ---------------------------- * * The same sequences data structure is used internally by BannerSequenceUiModel and * externally for the mixin parameters sent to the server and received by the * mixin's subscribing module, ext.centralNotice.bannerSequence. Here is the structure: * * // Outer array; each element is a sequence for a bucket. The element's index * // corresponds to the bucket number. * [ * * // Inner arrays are sequences; each element corresponds to a step in the sequence. * [ * * // The elements of the inner arrays are objects, whose properties control * // the functioning of the step they represent. * { * * // {string} The name of the banner to display, or null to display no * // banner during this step. * 'banner': 'TheNameOfABanner', * * // {number} The number of page views to display this step. * 'numPageViews': 4, * * // {string} An identifier to use for a flag in the browser. If a flag with the * // identifier is present, the step will be skipped. If not, the step will be * // shown, and when it completes, a flag with this identifier will be set. If * // this property is null, the step will always show. * 'skipWithIdentifier': null * } * ] * ] */ // TODO: Maybe on submit, we should verify that the data shown in the widgets is the same // as what we're submitting? Given the complexity of this code, it's not inconceivable // that a bug could cause different data to display than what be submitted. ( function () { var BannerSequenceUiController, BannerSequenceUiModel, BannerSequenceWidget, BucketSeqContainerWidget, BucketSeqWidget, StepWidget, campaignManager = require( 'ext.centralNotice.adminUi.campaignManager' ); /* BannerSequenceUiController */ /** * Singleton controller for the banner sequence administration UI. * * @class BannerSequenceUiController * @constructor */ BannerSequenceUiController = function () { BannerSequenceUiController.super.call( this ); }; OO.inheritClass( BannerSequenceUiController, campaignManager.MixinCustomUiController ); // This allows JS for the rest of the UI to find this class following registration // with campaignManager.mixinCustomUiControllerFactory BannerSequenceUiController.static.name = 'bannerSequence'; /** * Setup to be called immediately after instantiation. * * @param {Object} [data] Current banner sequence settings received from the server. */ BannerSequenceUiController.prototype.init = function ( data ) { // Don't assume that data has been provided data = data || {}; // Facility for widgets to get a unique (within this page view) key, used for // tracking error states this.errorStateKeyAutoIncrement = 0; /** * The data model * * @property {BannerSequenceUiModel} */ this.model = new BannerSequenceUiModel( data, campaignManager.getNumBuckets() ); // On instantiation, the widget will create subwidgets in accordance with the model /** * The enclosing view widget * * @property {BannerSequenceWidget} */ this.widget = new BannerSequenceWidget( this, this.model ); /** * Access point for the view widget's element * * @property {jQuery} */ this.$widgetElement = this.widget.$element; // Banners might no longer be valid if the mixin was enabled, disabled and // re-enabled, and banner assignments were modified while it was disabled (since // re-enabled mixins remember their previous settings). this.verifyAndFixBanners(); // Set input fields for form submission (uses data from the model) this.setSequencesInputParam(); this.setDaysInputParam(); // Subscribe to external events campaignManager.eventBus.connect( this, { 'bucket-change': this.onBucketChange } ); campaignManager.eventBus.connect( this, { 'assigned-banners-change': this.onAssignedBannersChange } ); }; /** * Get the list of banners currently available for a bucket. * * @param {number} bucket * @return {string[]} */ BannerSequenceUiController.prototype.getBannersForBucket = function ( bucket ) { return campaignManager.getAssignedBanners( bucket ); }; /** * Get the human-readable alphabetic label for a bucket. * * @param {number} bucket * @return {string} */ BannerSequenceUiController.prototype.getBucketLabel = function ( bucket ) { return campaignManager.getBucketLabel( bucket ); }; BannerSequenceUiController.prototype.onBucketChange = function ( numBuckets ) { this.model.updateNumBuckets( numBuckets ); this.widget.updateFromModel(); // The banners available for different buckets may have changed this.verifyAndFixBanners(); this.setSequencesInputParam(); }; BannerSequenceUiController.prototype.onAssignedBannersChange = function () { this.verifyAndFixBanners(); this.setSequencesInputParam(); }; /** * Check that all banners currently set in sequence steps are available for the * sequence's bucket. If any are not available, update the model and widgets and set * errors accordingly. */ BannerSequenceUiController.prototype.verifyAndFixBanners = function () { var bucket, bannersForBucket, stepsWithMissingBanners; // Iterate over active buckets for ( bucket = 0; bucket < campaignManager.getNumBuckets(); bucket++ ) { bannersForBucket = this.getBannersForBucket( bucket ); // Ask the model to check itself, and get an array of steps with issues stepsWithMissingBanners = this.model.verifyAndFixBannersForBucket( bucket, bannersForBucket ); // If there were any problem steps in this bucket, tell the widget to update // and set error messages as needed if ( stepsWithMissingBanners.length > 0 ) { this.widget.updateFromModelForBucket( bucket ); this.widget.setMissingBannerErrorsForBucket( bucket, stepsWithMissingBanners ); } // Tell the widget to update the options in the banner drop-downs this.widget.updateBannersForDropdownsForBucket( bucket, bannersForBucket ); } }; /** * Get a unique (within this page view) key, for tracking error states * * @return {string} */ BannerSequenceUiController.prototype.getErrorStateKey = function () { return String( this.errorStateKeyAutoIncrement++ ); }; /** * Add a new step with default values at the end of the sequence for this bucket, * and update the widget. * * @param {number} bucket */ BannerSequenceUiController.prototype.addStep = function ( bucket ) { this.model.addStep( bucket ); this.widget.updateFromModelForBucket( bucket ); this.setSequencesInputParam(); }; /** * Remove the indicated step in the sequence for the indicated bucket, and update the * widget. * * @param {number} bucket * @param {number} stepNum */ BannerSequenceUiController.prototype.removeStep = function ( bucket, stepNum ) { this.model.removeStep( bucket, stepNum ); this.setSequencesInputParam(); this.widget.removeStepForBucket( bucket, stepNum ); // Updating from model ensures widget state is all good (for example, add step // button state) this.widget.updateFromModelForBucket( bucket ); }; /** * Set the global error state of the banner sequence controls. This is called when * a widget error state changes. The ID provided is used in the ID for the event * emitted (which will be used by a global error state tracker). * * @param {string} errorStateKey Unique key for this error state * @param {boolean} state true sets an error for this key, and false clear it */ BannerSequenceUiController.prototype.setErrorState = function ( errorStateKey, state ) { // Broadcast an event to the rest of the UI campaignManager.eventBus.emit( 'error-state', 'banner-sequence-' + errorStateKey, state ); }; /** * Move a step to a new location, within the sequence of the bucket indicated. * Note: Does not update widgets from model, as it's not necessary. * * @param {number} bucket Bucket number * @param {number} newStepNum Index at which to place the step, according to * how steps would be indexed before the step is removed from its current * location. * @param {number} oldStepNum Current step index. */ BannerSequenceUiController.prototype.moveStepNoWidgetUpdate = function ( bucket, newStepNum, oldStepNum ) { this.model.moveStep( bucket, newStepNum, oldStepNum ); this.setSequencesInputParam(); }; BannerSequenceUiController.prototype.setBanner = function ( bucket, stepNum, banner, stepWidget ) { this.model.setBanner( bucket, stepNum, banner ); this.setSequencesInputParam(); stepWidget.model = this.model.getBktSequences()[ bucket ][ stepNum ]; stepWidget.updateFromModel(); }; BannerSequenceUiController.prototype.setNumPageViews = function ( bucket, stepNum, numPageViews, stepWidget ) { this.model.setNumPageViews( bucket, stepNum, numPageViews ); this.setSequencesInputParam(); stepWidget.model = this.model.getBktSequences()[ bucket ][ stepNum ]; stepWidget.updateFromModel(); this.widget.updateTotalPageViewsForBucket( bucket ); }; BannerSequenceUiController.prototype.setSkipWithIdentifier = function ( bucket, stepNum, skipWithIdentifier, stepWidget ) { this.model.setSkipWithIdentifier( bucket, stepNum, skipWithIdentifier ); this.setSequencesInputParam(); stepWidget.model = this.model.getBktSequences()[ bucket ][ stepNum ]; stepWidget.updateFromModel(); }; BannerSequenceUiController.prototype.setDays = function ( days ) { this.model.setDays( days ); this.setDaysInputParam(); }; BannerSequenceUiController.prototype.validateSkipWithIdentifier = function ( identifier ) { return this.model.validateSkipWithIdentifier( identifier ); }; BannerSequenceUiController.prototype.canAddAStep = function ( bucket ) { return this.model.canAddAStep( bucket ); }; BannerSequenceUiController.prototype.canRemoveAStep = function ( bucket ) { return this.model.canRemoveAStep( bucket ); }; BannerSequenceUiController.prototype.canMoveSteps = function ( bucket ) { return this.model.canMoveSteps( bucket ); }; /** * Set the value of the hidden form input for the sequences mixin parameter * * @private */ BannerSequenceUiController.prototype.setSequencesInputParam = function () { this.setParam( 'sequences', this.model.sequencesAsJSON() ); }; /** * Set the value of the hidden form input for the days mixin parameter * * @private */ BannerSequenceUiController.prototype.setDaysInputParam = function () { this.setParam( 'days', this.model.getDays() ); }; /* BannerSequenceUiModel */ /** * Singleton model for the banner sequence administration UI. * * @class BannerSequenceUiModel * @constructor * @param {Array} data Array of banner sequences, by bucket * @param {number} numBuckets The number of buckets currently active (may be different * from the number of sequences in data). */ BannerSequenceUiModel = function ( data, numBuckets ) { // Initialize bucket sequences as a deep copy of data, or empty if not provided if ( data && data.sequences ) { // Even though validateAndFix() also checks for an array, we need to do so // now, too, before extending if ( Array.isArray( data.sequences ) ) { /** * Sequences by bucket (index corresponds to bucket number) * * @property {Array} */ this.bucketSequences = $.extend( true, [], data.sequences ); } else { mw.log.warn( 'Received banner sequence data is not an array.' ); this.bucketSequences = []; } } else { this.bucketSequences = []; } // Number of days that identifiers to skip steps should last, or default if ( data && data.days ) { this.days = data.days; } else { this.days = this.constructor.static.DEFAULT_DAYS_PARAM; } // Validate and, if necessary, repair the data received this.validateAndFix(); // Set the number of bucket and adjust data, if necessary. // (If no initial data was provided, this will create the correct number of // default sequences.) this.updateNumBuckets( numBuckets ); }; OO.initClass( BannerSequenceUiModel ); // TODO Check this is the right number /** * Maximum number of steps allowed in a sequence * * @private * @static */ BannerSequenceUiModel.static.MAX_SEQ_STEPS = 20; /** * Default duration of identifiers to skip steps (in days) * * @private * @static */ BannerSequenceUiModel.static.DEFAULT_DAYS_PARAM = 250; /** * Export the contents of the model as a JSON string. * * @return {string} */ BannerSequenceUiModel.prototype.sequencesAsJSON = function () { // Only export valid data this.validateAndFix(); return JSON.stringify( this.getBktSequences() ); }; BannerSequenceUiModel.prototype.getDays = function () { return this.days; }; BannerSequenceUiModel.prototype.setDays = function ( days ) { this.days = days; }; BannerSequenceUiModel.prototype.updateNumBuckets = function ( numBuckets ) { var i; /** * Number of buckets in the model * * @property {number} */ this.numBuckets = numBuckets; // It's OK if there are more sequences in bucketSequences than there are active // buckets, since getBktSequences() outputs only sequences for active buckets. // Create default sequences as necessary for ( i = 0; i < this.numBuckets; i++ ) { if ( !this.bucketSequences[ i ] ) { this.bucketSequences[ i ] = this.defaultBktSeq(); } } }; BannerSequenceUiModel.prototype.addStep = function ( bucket ) { var bucketSequence = this.bucketSequences[ bucket ]; bucketSequence.push( this.defaultStep() ); }; BannerSequenceUiModel.prototype.setBanner = function ( bucket, stepNum, banner ) { this.bucketSequences[ bucket ][ stepNum ].banner = banner; }; BannerSequenceUiModel.prototype.setNumPageViews = function ( bucket, stepNum, numPageViews ) { this.bucketSequences[ bucket ][ stepNum ].numPageViews = numPageViews; }; BannerSequenceUiModel.prototype.setSkipWithIdentifier = function ( bucket, stepNum, skipWithIdentifier ) { this.bucketSequences[ bucket ][ stepNum ].skipWithIdentifier = skipWithIdentifier; }; BannerSequenceUiModel.prototype.removeStep = function ( bucket, stepNum ) { this.bucketSequences[ bucket ].splice( stepNum, 1 ); }; /** * Move a step to a new location, within the sequence of the bucket indicated. * * @param {number} bucket Bucket number * @param {number} newStepNum Index at which to place the step, according to * how steps would be indexed before the step is removed from its current * location. * @param {number} oldStepNum Current step index. */ BannerSequenceUiModel.prototype.moveStep = function ( bucket, newStepNum, oldStepNum ) { var step = this.bucketSequences[ bucket ][ oldStepNum ]; newStepNum = ( newStepNum > oldStepNum ) ? newStepNum - 1 : newStepNum; this.bucketSequences[ bucket ].splice( oldStepNum, 1 ); this.bucketSequences[ bucket ].splice( newStepNum, 0, step ); }; /** * Get an array of sequences for all currently active buckets. * * @return {Object[]} */ BannerSequenceUiModel.prototype.getBktSequences = function () { // Sliced in case it has data on buckets that have been de-activated return this.bucketSequences.slice( 0, this.numBuckets ); }; BannerSequenceUiModel.prototype.canAddAStep = function ( bucket ) { return this.bucketSequences[ bucket ].length < this.constructor.static.MAX_SEQ_STEPS; }; BannerSequenceUiModel.prototype.canRemoveAStep = function ( bucket ) { return ( this.bucketSequences[ bucket ].length > 1 ); }; BannerSequenceUiModel.prototype.canMoveSteps = function ( bucket ) { return ( this.bucketSequences[ bucket ].length > 1 ); }; /** * @private */ BannerSequenceUiModel.prototype.defaultBktSeq = function () { return [ this.defaultStep() ]; }; /** * @private */ BannerSequenceUiModel.prototype.defaultStep = function () { return { banner: this.defaultBanner(), numPageViews: this.defaultNumPageViews(), skipWithIdentifier: this.defaultSkipWithIdentifier() }; }; /** * @private */ BannerSequenceUiModel.prototype.defaultBanner = function () { return null; }; /** * @private */ BannerSequenceUiModel.prototype.defaultNumPageViews = function () { return 1; }; /** * @private */ BannerSequenceUiModel.prototype.defaultSkipWithIdentifier = function () { return null; }; /** * Validate this.bucketSequences and replace any data that's invalid. * * @private */ BannerSequenceUiModel.prototype.validateAndFix = function () { var i; // First, check for an array if ( !Array.isArray( this.bucketSequences ) ) { mw.log.warn( 'Bucket sequences should be an array.' ); this.bucketSequences = []; return; } // Check the sequences in the array for ( i = 0; i < this.bucketSequences.length; i++ ) { if ( !this.validateBktSeq( this.bucketSequences[ i ] ) ) { mw.log.warn( 'Invalid data in sequence for bucket ' + i ); this.bucketSequences[ i ] = this.defaultBktSeq(); } } // Check the days parameter if ( !this.validateDays( this.days ) ) { this.days = this.constructor.static.DEFAULT_DAYS_PARAM; } }; /** * @param seq * @private * @return {boolean} */ BannerSequenceUiModel.prototype.validateBktSeq = function ( seq ) { var i; // Check size limits if ( !Array.isArray( seq ) || ( seq.length > this.constructor.static.MAX_SEQ_STEPS ) || ( seq.length < 1 ) ) { return false; } // Check the steps in the sequence for ( i = 0; i < seq.length; i++ ) { if ( !this.validateStep( seq[ i ] ) ) { return false; } } return true; }; /** * @param step * @private * @return {boolean} */ BannerSequenceUiModel.prototype.validateStep = function ( step ) { var hasOwn = Object.prototype.hasOwnProperty; // Check the step object if ( ( step === null ) || ( typeof step !== 'object' ) ) { return false; } // Check that the properties exist and validate their values if ( !hasOwn.call( step, 'banner' ) || !this.validateBanner( step.banner ) ) { return false; } if ( !hasOwn.call( step, 'numPageViews' ) || !this.validateNumPageViews( step.numPageViews ) ) { return false; } if ( !hasOwn.call( step, 'skipWithIdentifier' ) || !this.validateSkipWithIdentifier( step.skipWithIdentifier ) ) { return false; } return true; }; // Validation methods for individual fields are not marked private, since they might // be called by the controller for the benefit of widgets (though, in practice, this // only happens with validateSkipWithIdentifier()). BannerSequenceUiModel.prototype.validateBanner = function ( banner ) { // Note: regex should coordinate with Banner::isValidBannerName() in Banner.php return ( typeof banner === 'string' && /^[A-Za-z0-9_]+$/.test( banner ) ) || banner === null; }; BannerSequenceUiModel.prototype.validateNumPageViews = function ( numPageViews ) { return this.validateIntOneOrGreater( numPageViews ); }; BannerSequenceUiModel.prototype.validateSkipWithIdentifier = function ( id ) { return ( typeof id === 'string' && id.indexOf( '|' ) === -1 ) || id === null; }; BannerSequenceUiModel.prototype.validateDays = function ( days ) { return this.validateIntOneOrGreater( days ); }; /** * @param n * @private */ BannerSequenceUiModel.prototype.validateIntOneOrGreater = function ( n ) { return typeof n === 'number' && isFinite( n ) && Math.floor( n ) === n && n > 0; }; /** * For all the steps in a sequence, check that any selected banners are included in * the provided list of assigned banners. If a step's selected banner is not in the * list, reset it to default and include the step's index in the returned array. * * @param {number} bucket The bucket whose sequence to check * @param {string[]} assignedBanners An array of the names of banners assigned to * this bucket * @return {number[]} An array with the indexes of steps whose selected banners were * not found in assignedBanners */ BannerSequenceUiModel.prototype.verifyAndFixBannersForBucket = function ( bucket, assignedBanners ) { var i, sequence = this.bucketSequences[ bucket ], stepsWithMissingBanners = [], banner; for ( i = 0; i < sequence.length; i++ ) { banner = sequence[ i ].banner; if ( banner !== null && assignedBanners.indexOf( banner ) === -1 ) { stepsWithMissingBanners.push( i ); sequence[ i ].banner = this.defaultBanner(); } } return stepsWithMissingBanners; }; /** * Global container widget for the banner sequence administration UI. * * @param controller * @param model * @class BannerSequenceWidget * @constructor */ BannerSequenceWidget = function ( controller, model ) { /** * @property {BannerSequenceUiController} */ this.controller = controller; /** * @property {BannerSequenceUiModel} */ this.model = model; // Call parent constructor BannerSequenceWidget.super.call( this, controller ); // Set up days widget and field layout this.daysInput = new OO.ui.NumberInputWidget( { min: 1, isInteger: true, classes: [ 'centralNoticeBannerSeqDays' ] } ); this.daysLayout = new OO.ui.FieldLayout( this.daysInput, { label: mw.message( 'centralnotice-banner-sequence-days' ).text(), align: 'left', classes: [ 'centralNoticeBannerSeqDaysLayout' ] } ); // Prepend help text and days input so they come before $group, in reverse order this.$element.prepend( $( '