-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmodui-base.js
422 lines (325 loc) · 14.4 KB
/
modui-base.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
import _ from 'underscore';
import Backbone from 'backbone';
const Super = Backbone.View;
const ModuiBase = Super.extend( {
className : 'modui-base',
options : [
'extraClassName', // appended to regular class names (used to facilitate styling)
'passMessagesTo' // where to pass spawned messages... defaults to closest ancestor view in DOM
],
onMessages : {},
passMessages : false,
subviewCreators : {},
constructor( options = {} ) {
// Validate prototype property declarations
if( ! _.isArray( this.options ) ) {
this.constructor.throw( 'Option declarations must be an array.' );
}
if( ! ( _.isBoolean( this.passMessages ) || _.isArray( this.passMessages ) ) ) {
this.constructor.throw( 'passMessages declaration should be boolean or an array.' );
}
// Initialize instance properties
const computedOptions = this.constructor.computeInitialOptions( options );
this.set( computedOptions );
this.subviews = {};
const returnValue = Super.prototype.constructor.apply( this, arguments );
return returnValue;
},
// eslint-disable-next-line no-unused-vars
setElement( element ) {
Super.prototype.setElement.apply( this, arguments );
this.$el.data( 'view', this );
if( this.extraClassName ) this.$el.addClass( this.extraClassName );
return this;
},
set( optionsHashOrName = {}, optionValue ) {
const optionsThatWereChanged = {};
const optionsThatWereChangedPreviousValues = {};
let optionsHash = {};
// allow params to be ( propertyName, propertyValue ) instead of a hash of properties
if( _.isString( optionsHashOrName ) && optionsHashOrName ) {
optionsHash[ optionsHashOrName ] = optionValue;
} else if( _.isObject( optionsHashOrName ) ) {
// Shallow clone to prevent mutating arguments
optionsHash = { ...optionsHashOrName };
} else {
this.constructor.throw( 'optionsHashOrName must be non-empty string or object.' );
}
const normalizedOptionDeclarations = _normalizeOptionDeclarations( this.options );
Object.entries( optionsHash ).forEach( thisEntry => {
const [ optionName, optionValue ] = thisEntry;
const oldValue = this[ optionName ];
const valueExisted = ! _.isUndefined( oldValue );
const newValueExists = ! _.isUndefined( optionValue );
const valueChanged = oldValue !== optionValue;
if( ! normalizedOptionDeclarations[ optionName ] ) {
this.constructor.throw( `"${optionName}" is not an option on this view` );
}
const { required } = normalizedOptionDeclarations[ optionName ];
if( required && ! newValueExists ) {
this.constructor.throw( `Required option "${optionName}" can not be set to undefined.` );
}
// Attach the supplied value of this option to the view object
if( newValueExists ) {
// Keep track of value changes on presxisting options
if( valueExisted && valueChanged ) {
optionsThatWereChangedPreviousValues[ optionName ] = oldValue;
optionsThatWereChanged[ optionName ] = optionValue;
}
this[ optionName ] = optionValue;
}
} );
// trigger callbacks if options changed
const optionsWereChanged = Object.keys( optionsThatWereChanged ).length > 0;
if( optionsWereChanged && _.isFunction( this._onOptionsChanged ) ) {
this._onOptionsChanged( optionsThatWereChanged, optionsThatWereChangedPreviousValues );
}
},
get( optionNames ) {
// this.get( <string> ) -> returns just the value of the passed option
// this.get( <array> ) -> returns a hash of options names to values
// this.get() -> returns a hash of all options to their values
const normalizedOptionDeclarations = _normalizeOptionDeclarations( this.options );
const allOptionNames = Object.keys( normalizedOptionDeclarations );
const getOptionValue = optionName => {
if( ! allOptionNames.includes( optionName ) ) {
this.constructor.throw( `"${optionName}" is not an option on this view` );
}
return _cloneOptionValue( this[ optionName ] );
};
// a single option name was provided, so return its value
if( _.isString( optionNames ) ) return getOptionValue( optionNames );
// no args were provided, so we override optionNames to include all options for next step
if( _.isUndefined( optionNames ) ) optionNames = allOptionNames;
// deal with an array of options: return a hash mapping those options to their values
return optionNames.reduce( ( optionValueHashMemo, thisOptionName ) => {
optionValueHashMemo[ thisOptionName ] = getOptionValue( thisOptionName );
return optionValueHashMemo;
}, {} );
},
render() {
// Detach each of our subviews that we have already created during previous
// renders from the DOM, so that they do not loose their DOM events when
// we re-render the contents of this view's DOM element.
Object.values( this.subviews ).forEach( thisSubview => thisSubview.$el.detach() );
// Render view template
if( this.template ) {
const templateData = {
...this.get(),
...this._getTemplateData()
};
this.$el.html( this.template( templateData ) );
}
// Note that we may need to populate subviews even if a template is not defined,
// since the view may be mounted on an existing DOM element with subview placeholders.
const orderedSubviews = this._populateSubviews();
// Now that all subviews have been populated, render them one at a
// time in the order they occur in the DOM.
orderedSubviews.forEach( thisSubview => thisSubview.render() );
// Run any post-rendering logic
this._afterRender();
return this;
},
spawn( messageName, data ) {
this.trigger( messageName, data );
const isRoundTripMessage = messageName.charAt( messageName.length - 1 ) === '!';
// eslint-disable-next-line consistent-this
const originalSourceView = this;
// Traverse view hierarchy to find matching handler functions
let currentSourceView = originalSourceView;
let currentParentView = this._getParentView();
let currentSourceViewHandlerKeys = [];
let messageShouldBePassed;
while( currentParentView ) {
// Find handler keys for this message name.
// Sort them starting with the ones specific to a particular subview, so they get executed first.
// At most, we'll end up with two elements: a view-specific handler and a generic handler.
currentSourceViewHandlerKeys = _.chain( currentParentView.onMessages )
.keys()
.filter( thisOnMessagesKey => thisOnMessagesKey.startsWith( messageName ) )
.filter( thisOnMessagesKey => {
const targetSubviewName = thisOnMessagesKey.split( ' ' )[ 1 ];
return targetSubviewName ? currentParentView.subviews[ targetSubviewName ] === currentSourceView : true;
} )
.sortBy( thisOnMessagesKey => thisOnMessagesKey.split( ' ' ).length )
.reverse()
.value();
// If at least one handler was found, we're done
if( currentSourceViewHandlerKeys.length > 0 ) break;
// If not, find out if we should keep looking up the view hierarchy
if( isRoundTripMessage ) {
messageShouldBePassed = true;
} else if( _.isBoolean( currentParentView.passMessages ) ) {
messageShouldBePassed = currentParentView.passMessages;
} else {
messageShouldBePassed = currentParentView.passMessages.includes( messageName );
}
// If this message should not be passed, then we are done
if( ! messageShouldBePassed ) break;
// Move one level up the view hierarchy and loop again
currentSourceView = currentParentView;
currentParentView = currentParentView._getParentView();
}
// If no handlers were detected up the view hierarchy, we're done.
// Note thay for round trip messages, this means the returned value will be undefined.
if( currentSourceViewHandlerKeys.length === 0 ) return;
// Handlers were detected: map their keys into the corresponding implementations
const values = currentSourceViewHandlerKeys.map( thisHandlerKey => currentParentView.onMessages[ thisHandlerKey ] );
const methods = values.map( thisValue => _.isFunction( thisValue ) ? thisValue : currentParentView[ thisValue ] );
// Validate that all handlers declared as method names exist
methods.forEach( ( thisMethod, index ) => {
if( ! thisMethod ) {
const methodName = values[ index ];
this.constructor.throw( `Method "${methodName}" does not exist.` );
}
} );
// The arguments passed to the message handler are: data, source, originalSource.
// Source and originalSource will be different if the message has been passed.
const callMethod = method => method.call( currentParentView, data, currentSourceView, originalSourceView );
if( isRoundTripMessage ) {
// For round trip messages, just execute the first handler found and return resulting value
return callMethod( methods[ 0 ] );
} else {
// For non roundtrip messages, execute all handlers
methods.forEach( callMethod );
}
},
remove() {
this.removeSubviews();
Super.prototype.remove.call( this );
return this;
},
removeSubviews( whichSubviews ) {
const subviewsToRemove = whichSubviews || Object.keys( this.subviews );
subviewsToRemove.forEach( thisSubviewName => {
this.subviews[ thisSubviewName ].remove();
delete this.subviews[ thisSubviewName ];
} );
},
_afterRender() {
// Extend to add post-rendering logic
},
_getTemplateData() {
// this function may be overridden to add additional properties
// to the object passed to the template's rendering function
return {};
},
_populateSubviews() {
const orderedSubviews = [];
this.$( '[data-subview]' ).each( ( _index, el ) => {
const thisPlaceHolderDiv = $( el );
const subviewName = thisPlaceHolderDiv.attr( 'data-subview' );
let thisSubview;
if( _.isUndefined( this.subviews[ subviewName ] ) ) {
const subviewCreator = this.subviewCreators[ subviewName ];
if( _.isUndefined( subviewCreator ) ) {
this.constructor.throw( `Can not find subview creator for subview named "${subviewName}"` );
}
thisSubview = subviewCreator.apply( this );
if( thisSubview === null ) {
// subview creators can return null to indicate that the subview should not be created
thisPlaceHolderDiv.remove();
return;
}
this.subviews[ subviewName ] = thisSubview;
} else {
// If the subview is already defined, then use the existing subview instead
// of creating a new one. This allows us to re-render a parent view without
// loosing any dynamic state data on the existing subview objects. To force
// re-initialization of subviews, call view.removeSubviews before re-rendering.
thisSubview = this.subviews[ subviewName ];
}
thisPlaceHolderDiv.replaceWith( thisSubview.$el );
orderedSubviews.push( thisSubview );
} );
return orderedSubviews;
},
_onOptionsChanged( changedOptions, previousValues ) {
// override in derived classes to do something when options are changed.
// only called when the option(s) that are changed had previous (non-undefined) value
if( 'extraClassName' in changedOptions ) {
this.$el.removeClass( previousValues.extraClassName );
this.$el.addClass( changedOptions.extraClassName );
}
},
_getParentView() {
// used for passing spawned messages
// if we have an explicit view to pass our messages to, do it
if( this.passMessagesTo ) return this.passMessagesTo;
// otherwise pass to closest parent view
const lastPossibleViewElement = $( 'body' )[ 0 ];
let parent = null;
let curElement = this.$el.parent();
while( curElement.length > 0 && curElement[ 0 ] !== lastPossibleViewElement ) {
const curElementView = curElement.data( 'view' );
if( curElementView && _.isFunction( curElementView.render ) ) {
parent = curElementView;
break;
}
curElement = curElement.parent();
}
return parent;
},
_encapsulateEvent( e ) {
const encapsulatedEvent = _.pick( e, [ 'keyCode', 'metaKey', 'ctrlKey', 'altKey', 'shiftKey' ] );
const methods = [ 'preventDefault', 'stopPropagation', 'stopImmediatePropagation' ];
methods.forEach( thisMethod => {
encapsulatedEvent[ thisMethod ] = () => e[ thisMethod ]();
} );
return encapsulatedEvent;
}
}, {
computeInitialOptions( optionsPassedToConstructor = {} ) {
const normalizedOptionDeclarations = _normalizeOptionDeclarations( this.prototype.options );
const initialOptions = {};
Object.entries( normalizedOptionDeclarations ).forEach( thisEntry => {
const [ optionName, optionProps ] = thisEntry;
const { required, defaultValue } = optionProps;
const value = optionsPassedToConstructor[ optionName ];
// Validate that all required options were provided.
if( required && _.isUndefined( value ) ) {
this.throw( `Required option "${optionName}" was not supplied.` );
}
initialOptions[ optionName ] = _.isUndefined( value ) ? defaultValue : value;
} );
return initialOptions;
},
throw( errorMessage ) {
const classNameWords = this.prototype.className.split( ' ' );
const lastClassName = classNameWords[ classNameWords.length - 1 ];
throw new Error( `${lastClassName}: ${errorMessage}` );
}
} );
function _normalizeOptionDeclarations( optionDeclarations ) {
// convert our short-hand option syntax (with exclamation marks, etc.)
// to a hash of "option declaration" objects of the form { required, defaultValue }, keyed by optionName
const normalizedOptionDeclarations = {};
optionDeclarations.forEach( thisOptionDeclaration => {
let thisOptionName;
let thisOptionRequired = false;
let thisOptionDefaultValue;
if( _.isString( thisOptionDeclaration ) ) {
thisOptionName = thisOptionDeclaration;
} else if( _.isObject( thisOptionDeclaration ) ) {
thisOptionName = Object.keys( thisOptionDeclaration )[ 0 ];
thisOptionDefaultValue = _cloneOptionValue( thisOptionDeclaration[ thisOptionName ] );
}
if( thisOptionName.charAt( thisOptionName.length - 1 ) === '!' ) {
thisOptionRequired = true;
thisOptionName = thisOptionName.slice( 0, thisOptionName.length - 1 );
}
normalizedOptionDeclarations[ thisOptionName ] = normalizedOptionDeclarations[ thisOptionName ] || {};
normalizedOptionDeclarations[ thisOptionName ].required = thisOptionRequired;
if( ! _.isUndefined( thisOptionDefaultValue ) ) normalizedOptionDeclarations[ thisOptionName ].defaultValue = thisOptionDefaultValue;
} );
return normalizedOptionDeclarations;
}
function _cloneOptionValue( optionValue ) {
if( ! _.isObject( optionValue ) || _.isFunction( optionValue ) || _.isRegExp( optionValue ) ) {
// can't clone functions or regexs, and no need to clone primitives
return optionValue;
} else if( _.isObject( optionValue ) ) {
return JSON.parse( JSON.stringify( optionValue ) );
}
}
export default ModuiBase;