~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/app/javascript/lazr/choiceedit/choiceedit.js

[r=deryck][bug=803954] Bring lazr-js source into lp tree and package
        yui as a dependency

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
 
2
 
 
3
YUI.add('lazr.choiceedit', function(Y) {
 
4
 
 
5
/**
 
6
 * This class provides the ability to allow a specific field to be
 
7
 *  chosen from an enum, similar to a dropdown.
 
8
 *
 
9
 * This can be thought of as a rather pretty Ajax-enhanced dropdown menu.
 
10
 *
 
11
 * @module lazr.choiceedit
 
12
 */
 
13
 
 
14
var CHOICESOURCE       = 'ichoicesource',
 
15
    CHOICELIST         = 'ichoicelist',
 
16
    NULLCHOICESOURCE   = 'inullchoicesource',
 
17
    C_EDITICON         = 'editicon',
 
18
    C_VALUELOCATION    = 'value',
 
19
    C_NULLTEXTLOCATION = 'nulltext',
 
20
    C_ADDICON          = 'addicon',
 
21
    SAVE               = 'save',
 
22
    LEFT_MOUSE_BUTTON  = 1,
 
23
    RENDERUI           = "renderUI",
 
24
    BINDUI             = "bindUI",
 
25
    SYNCUI             = "syncUI",
 
26
    NOTHING            = new Object();
 
27
 
 
28
/**
 
29
 * This class provides the ability to allow a specific field to be
 
30
 * chosen from an enum, similar to a dropdown.
 
31
 *
 
32
 * @class ChoiceSource
 
33
 * @extends Widget
 
34
 * @constructor
 
35
 */
 
36
 
 
37
var ChoiceSource = function() {
 
38
    ChoiceSource.superclass.constructor.apply(this, arguments);
 
39
    Y.after(this._bindUIChoiceSource, this, BINDUI);
 
40
    Y.after(this._syncUIChoiceSource, this, SYNCUI);
 
41
};
 
42
 
 
43
ChoiceSource.NAME = CHOICESOURCE;
 
44
 
 
45
/**
 
46
 * Dictionary of selectors to define subparts of the widget that we care about.
 
47
 * YUI calls ATTRS.set(foo) for each foo defined here
 
48
 *
 
49
 * @property InlineEditor.HTML_PARSER
 
50
 * @type Object
 
51
 * @static
 
52
 */
 
53
ChoiceSource.HTML_PARSER = {
 
54
    value_location: '.' + C_VALUELOCATION,
 
55
    editicon: '.' + C_EDITICON
 
56
};
 
57
 
 
58
ChoiceSource.ATTRS = {
 
59
    /**
 
60
     * Possible values of the enum that the user chooses from.
 
61
     *
 
62
     * @attribute items
 
63
     * @type Array
 
64
     */
 
65
    items: {
 
66
        value: []
 
67
    },
 
68
 
 
69
    /**
 
70
     * Current value of enum
 
71
     *
 
72
     * @attribute value
 
73
     * @type String
 
74
     * @default null
 
75
     */
 
76
    value: {
 
77
        value: null
 
78
    },
 
79
 
 
80
    /**
 
81
     * List header displayed in the popup
 
82
     *
 
83
     * @attribute title
 
84
     * @type String
 
85
     * @default ""
 
86
     */
 
87
    title: {
 
88
        value: ""
 
89
    },
 
90
 
 
91
    /**
 
92
     * Y.Node displaying the current value of the field. Should be
 
93
     * automatically calculated by HTML_PARSER.
 
94
     * Setter function returns Y.one(parameter) so that you can pass
 
95
     * either a Node (as expected) or a selector.
 
96
     *
 
97
     * @attribute value_location
 
98
     * @type Node
 
99
     */
 
100
    value_location: {
 
101
      value: null,
 
102
      setter: function(v) {
 
103
        return Y.one(v);
 
104
      }
 
105
    },
 
106
 
 
107
    /**
 
108
     * Y.Node (img) displaying the editicon, which is exchanged for a spinner
 
109
     * while saving happens. Should be automatically calculated by HTML_PARSER.
 
110
     * Setter function returns Y.one(parameter) so that you can pass
 
111
     * either a Node (as expected) or a selector.
 
112
     *
 
113
     * @attribute value_location
 
114
     * @type Node
 
115
     */
 
116
    editicon: {
 
117
      value: null,
 
118
      setter: function(v) {
 
119
        return Y.one(v);
 
120
      }
 
121
    },
 
122
 
 
123
    /**
 
124
     * Y.Node display the action icon. The default implementation just returns
 
125
     * the edit icon, but it can be customized to return other elements in
 
126
     * subclasses.
 
127
     * @attribute actionicon
 
128
     * @type Node
 
129
     */
 
130
    actionicon: {
 
131
      getter: function() {
 
132
        return this.get('editicon');
 
133
      }
 
134
    },
 
135
 
 
136
    elementToFlash: {
 
137
      value: null,
 
138
      setter: function(v) {
 
139
        return Y.one(v);
 
140
      }
 
141
    },
 
142
 
 
143
    backgroundColor: {
 
144
      value: null
 
145
    },
 
146
 
 
147
    clickable_content: {
 
148
      value: true
 
149
    }
 
150
};
 
151
 
 
152
Y.extend(ChoiceSource, Y.Widget, {
 
153
    initializer: function(cfg) {
 
154
        /**
 
155
         * Fires when the user selects an item
 
156
         *
 
157
         * @event save
 
158
         * @preventable _saveData
 
159
         */
 
160
        this.publish(SAVE);
 
161
 
 
162
        var editicon = this.get('editicon');
 
163
        editicon.original_src = editicon.get("src");
 
164
    },
 
165
 
 
166
    /**
 
167
     * bind UI events
 
168
     * <p>
 
169
     * This method is invoked after bindUI is invoked for the Widget class
 
170
     * using YUI's aop infrastructure.
 
171
     * </p>
 
172
     *
 
173
     * @method _bindUIChoiceSource
 
174
     * @protected
 
175
     */
 
176
    _bindUIChoiceSource: function() {
 
177
        var that = this;
 
178
        if (this.get('clickable_content')) {
 
179
            var clickable_element = this.get('contentBox');
 
180
        } else {
 
181
            var clickable_element = this.get('editicon');
 
182
        }
 
183
        clickable_element.on("click", this.onClick, this);
 
184
 
 
185
        this.after("valueChange", function(e) {
 
186
            this.syncUI();
 
187
            this._showSucceeded();
 
188
        });
 
189
    },
 
190
 
 
191
    /**
 
192
     * Update in-page HTML with current value of the field
 
193
     * <p>
 
194
     * This method is invoked after syncUI is invoked for the Widget class
 
195
     * using YUI's aop infrastructure.
 
196
     * </p>
 
197
     *
 
198
     * @method _syncUIChoiceSource
 
199
     * @protected
 
200
     */
 
201
    _syncUIChoiceSource: function() {
 
202
        var items = this.get("items");
 
203
        var value = this.get("value");
 
204
        var node = this.get("value_location");
 
205
        for (var i=0; i<items.length; i++) {
 
206
            if (items[i].value == value) {
 
207
                node.set("innerHTML", items[i].source_name || items[i].name);
 
208
            }
 
209
        }
 
210
    },
 
211
 
 
212
    _chosen_value: NOTHING,
 
213
 
 
214
    /**
 
215
     * Get the currently chosen value.
 
216
     *
 
217
     * Compatible with the Launchpad PATCH plugin.
 
218
     *
 
219
     * @method getInput
 
220
     */
 
221
    getInput: function() {
 
222
        if (this._chosen_value !== NOTHING) {
 
223
          return this._chosen_value;
 
224
        } else {
 
225
          return this.get("value");
 
226
        }
 
227
    },
 
228
 
 
229
    /**
 
230
     * Handle click and create the ChoiceList to allow user to
 
231
     * select an item
 
232
     *
 
233
     * @method onClick
 
234
     * @private
 
235
     */
 
236
    onClick: function(e) {
 
237
 
 
238
        // Only continue if the down button is the left one.
 
239
        if (e.button != LEFT_MOUSE_BUTTON) {
 
240
            return;
 
241
        }
 
242
 
 
243
        this._choice_list = new Y.ChoiceList({
 
244
            value:          this.get("value"),
 
245
            title:          this.get("title"),
 
246
            items:          this.get("items"),
 
247
            value_location: this.get("value_location"),
 
248
            progressbar:    false
 
249
        });
 
250
 
 
251
        var that = this;
 
252
        this._choice_list.on("valueChosen", function(e) {
 
253
            that._chosen_value = e.details[0];
 
254
            that._saveData(e.details[0]);
 
255
        });
 
256
 
 
257
        // Stuff the mouse coordinates into the list object,
 
258
        // by the time we'll need them, they won't be available.
 
259
        this._choice_list._mouseX = e.clientX + window.pageXOffset;
 
260
        this._choice_list._mouseY = e.clientY + window.pageYOffset;
 
261
 
 
262
        this._choice_list.render();
 
263
 
 
264
        e.halt();
 
265
    },
 
266
 
 
267
    /**
 
268
     * bind UI events
 
269
     *
 
270
     * @private
 
271
     * @method _saveData
 
272
     */
 
273
    _saveData: function(newvalue) {
 
274
        this.set("value", newvalue);
 
275
        this.fire(SAVE);
 
276
    },
 
277
 
 
278
    /**
 
279
     * Called when save has succeeded to flash the in-page HTML green.
 
280
     *
 
281
     * @private
 
282
     * @method _showSucceeded
 
283
     */
 
284
    _showSucceeded: function() {
 
285
        this._uiAnimateFlash(Y.lazr.anim.green_flash);
 
286
    },
 
287
 
 
288
    /**
 
289
     * Called when save has failed to flash the in-page HTML red.
 
290
     *
 
291
     * @private
 
292
     * @method _showFailed
 
293
     */
 
294
    _showFailed: function() {
 
295
        this._uiAnimateFlash(Y.lazr.anim.red_flash);
 
296
    },
 
297
 
 
298
    /**
 
299
     * Run a flash-in animation on the editable text node.
 
300
     *
 
301
     * @method _uiAnimateFlash
 
302
     * @param flash_fn {Function} A lazr.anim flash-in function.
 
303
     * @protected
 
304
     */
 
305
    _uiAnimateFlash: function(flash_fn) {
 
306
        var node = this.get('elementToFlash');
 
307
        if (node === null) {
 
308
          node = this.get('contentBox');
 
309
        }
 
310
        var cfg = { node: node };
 
311
        if (this.get('backgroundColor') !== null) {
 
312
          cfg.to = {backgroundColor: this.get('backgroundColor')};
 
313
        }
 
314
        var anim = flash_fn(cfg);
 
315
        anim.run();
 
316
    },
 
317
 
 
318
    /**
 
319
     * Set the 'waiting' user-interface state.  Be sure to call
 
320
     * _uiClearWaiting() when you are done.
 
321
     *
 
322
     * @method _uiSetWaiting
 
323
     * @protected
 
324
     */
 
325
    _uiSetWaiting: function() {
 
326
        var actionicon = this.get("actionicon");
 
327
        actionicon.original_src = actionicon.get("src");
 
328
        actionicon.set("src", "https://launchpad.net/@@/spinner");
 
329
    },
 
330
 
 
331
    /**
 
332
     * Clear the 'waiting' user-interface state.
 
333
     *
 
334
     * @method _uiClearWaiting
 
335
     * @protected
 
336
     */
 
337
    _uiClearWaiting: function() {
 
338
        var actionicon = this.get("actionicon");
 
339
        actionicon.set("src", actionicon.original_src);
 
340
    }
 
341
 
 
342
});
 
343
 
 
344
 
 
345
Y.ChoiceSource = ChoiceSource;
 
346
 
 
347
var ChoiceList = function() {
 
348
    ChoiceList.superclass.constructor.apply(this, arguments);
 
349
};
 
350
 
 
351
ChoiceList.NAME = CHOICELIST;
 
352
 
 
353
ChoiceList.ATTRS = {
 
354
    /**
 
355
     * Possible values of the enum that the user chooses from.
 
356
     *
 
357
     * @attribute items
 
358
     * @type Array
 
359
     */
 
360
    items: {
 
361
        value: []
 
362
    },
 
363
 
 
364
    /**
 
365
     * Current value of enum
 
366
     *
 
367
     * @attribute value
 
368
     * @type String
 
369
     * @default null
 
370
     */
 
371
    value: {
 
372
        value: null
 
373
    },
 
374
 
 
375
    /**
 
376
     * List header displayed in the popup
 
377
     *
 
378
     * @attribute title
 
379
     * @type String
 
380
     * @default ""
 
381
     */
 
382
    title: {
 
383
        value: ""
 
384
    },
 
385
 
 
386
    /**
 
387
     * Node currently containing the value, around which we need to
 
388
     * position ourselves
 
389
     *
 
390
     * @attribute value_location
 
391
     * @type Node
 
392
     */
 
393
     value_location: {
 
394
       value: null
 
395
     },
 
396
 
 
397
    /**
 
398
     * List of clickable enum values
 
399
     *
 
400
     * @attribute display_items_list
 
401
     * @type Node
 
402
     */
 
403
     display_items_list: {
 
404
       value: null
 
405
     }
 
406
 
 
407
};
 
408
 
 
409
 
 
410
 
 
411
 
 
412
Y.extend(ChoiceList, Y.lazr.PrettyOverlay, {
 
413
    initializer: function(cfg) {
 
414
        /**
 
415
         * Fires when the user selects an item
 
416
         *
 
417
         * @event valueChosen
 
418
         */
 
419
        this.publish("valueChosen");
 
420
        this.after("renderedChange", this._positionCorrectly);
 
421
        Y.after(this._renderUIChoiceList, this, RENDERUI);
 
422
        Y.after(this._bindUIChoiceList, this, BINDUI);
 
423
    },
 
424
 
 
425
    /**
 
426
     * Render the popup menu
 
427
     * <p>
 
428
     * This method is invoked after renderUI is invoked for the Widget class
 
429
     * using YUI's aop infrastructure.
 
430
     * </p>
 
431
     *
 
432
     * @method _renderUIChoiceList
 
433
     * @protected
 
434
     */
 
435
    _renderUIChoiceList: function() {
 
436
        this.set("align", {
 
437
          node: this.get("value_location"),
 
438
          points:[Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.TL]
 
439
        });
 
440
        this.set("headerContent", "<h2>" + this.get("title") + "</h2>");
 
441
        this.set("display_items_list", Y.Node.create("<ul>"));
 
442
        var display_items_list = this.get("display_items_list");
 
443
        var items = this.get("items");
 
444
        var value = this.get("value");
 
445
        var li;
 
446
        for (var i=0; i<items.length; i++) {
 
447
            if (items[i].disabled) {
 
448
                li = Y.Node.create('<li><span class="disabled">' +
 
449
                    items[i].name + '</span></li>');
 
450
            } else if (items[i].value == value) {
 
451
                li = Y.Node.create('<li><span class="current">' +
 
452
                    items[i].name + '</span></li>');
 
453
            } else {
 
454
                li = Y.Node.create('<li><a href="#' + items[i].value +
 
455
                    '">' + items[i].name + '</a></li>');
 
456
                li.one('a')._value = items[i].value;
 
457
            }
 
458
            if (items[i].css_class !== undefined) {
 
459
                li.addClass(items[i].css_class);
 
460
            } else {
 
461
                li.addClass('unstyled');
 
462
            }
 
463
            display_items_list.appendChild(li);
 
464
        }
 
465
 
 
466
        this.setStdModContent(
 
467
            Y.WidgetStdMod.BODY, display_items_list, Y.WidgetStdMod.REPLACE);
 
468
        this.move(-10000, 0);
 
469
    },
 
470
 
 
471
    /**
 
472
     * Bind UI events
 
473
     * <p>
 
474
     * This method is invoked after bindUI is invoked for the Widget class
 
475
     * using YUI's aop infrastructure.
 
476
     * </p>
 
477
     *
 
478
     * @method _bindUIChoiceList
 
479
     * @protected
 
480
     */
 
481
    _bindUIChoiceList: function() {
 
482
        var display_items_list = this.get("display_items_list");
 
483
        var that = this;
 
484
        Y.delegate("click", function(e) {
 
485
            var target = e.currentTarget;
 
486
            var value = target._value;
 
487
            var items = that.get("items");
 
488
            for (var i=0; i<items.length; i++) {
 
489
                if (items[i].value == value) {
 
490
                    that.fire("valueChosen", items[i].value);
 
491
                    that.destroy();
 
492
                    e.halt();
 
493
                    break;
 
494
                }
 
495
            }
 
496
        }, display_items_list, "li a");
 
497
    },
 
498
 
 
499
    /**
 
500
     * Destroy the widget (remove its HTML from the page)
 
501
     *
 
502
     * @method destructor
 
503
     */
 
504
    destructor: function() {
 
505
        var bb = this.get("boundingBox");
 
506
        var parent = bb.get("parentNode");
 
507
        if (parent) {
 
508
            parent.removeChild(bb);
 
509
        }
 
510
    },
 
511
 
 
512
    /**
 
513
     * Calculate correct position for popup and move it there.
 
514
     *
 
515
     * This is needed so that we have the correct height of the overlay,
 
516
     * with the content, when we position it. This solution is not very
 
517
     * elegant - in the future we'd like to be able to use YUI's positioning,
 
518
     * thought it doesn't seem to work correctly right now.
 
519
     *
 
520
     * @private
 
521
     * @method _positionCorrectly
 
522
     */
 
523
    _positionCorrectly: function(e) {
 
524
        var boundingBox = this.get('boundingBox');
 
525
        var selectedListItem = boundingBox.one('span.current');
 
526
        valueX = this._mouseX - (boundingBox.get('offsetWidth') / 2);
 
527
        var valueY;
 
528
        if (Y.Lang.isValue(selectedListItem)) {
 
529
            valueY = (this._mouseY -
 
530
                      this.get("headerContent").get('offsetHeight') -
 
531
                      selectedListItem.get('offsetTop') -
 
532
                      (selectedListItem.get('offsetHeight') / 2));
 
533
        } else {
 
534
             valueY = this._mouseY - (boundingBox.get('offsetHeight') / 2);
 
535
        }
 
536
        if (valueX < 0) {
 
537
            valueX = 0;
 
538
        }
 
539
        if ((valueX >
 
540
             document.body.clientWidth - boundingBox.get('offsetWidth')) &&
 
541
            (document.body.clientWidth > boundingBox.get('offsetWidth'))) {
 
542
            valueX = document.body.clientWidth - boundingBox.get('offsetWidth');
 
543
        }
 
544
        if (valueY < 0) {
 
545
            valueY = 0;
 
546
        }
 
547
 
 
548
        this.move(valueX, valueY);
 
549
 
 
550
        var bb = this.get('boundingBox');
 
551
        bb.on('focus', function(e) {
 
552
            bb.one('.close-button').focus();
 
553
        });
 
554
        bb.one('.close-button').focus();
 
555
    },
 
556
 
 
557
    /**
 
558
     * Return the absolute position of any node
 
559
     *
 
560
     * @private
 
561
     * @method _findPosition
 
562
     */
 
563
    _findPosition: function(obj) {
 
564
        var curleft = 0,
 
565
        curtop = 0;
 
566
        if (obj.get("offsetParent")) {
 
567
            do {
 
568
                curleft += obj.get("offsetLeft");
 
569
                curtop += obj.get("offsetTop");
 
570
            } while ((obj = obj.get("offsetParent")));
 
571
        }
 
572
        return [curleft,curtop];
 
573
    }
 
574
 
 
575
});
 
576
 
 
577
 
 
578
Y.augment(ChoiceList, Y.Event.Target);
 
579
Y.ChoiceList = ChoiceList;
 
580
 
 
581
 
 
582
/**
 
583
 * This class provides a specialised implementation of ChoiceSource
 
584
 * displaying a custom UI for null items.
 
585
 *
 
586
 * @class NullChoiceSource
 
587
 * @extends ChoiceSource
 
588
 * @constructor
 
589
 */
 
590
var NullChoiceSource = function() {
 
591
    NullChoiceSource.superclass.constructor.apply(this, arguments);
 
592
};
 
593
 
 
594
NullChoiceSource.NAME = NULLCHOICESOURCE;
 
595
 
 
596
NullChoiceSource.HTML_PARSER = {
 
597
    value_location: '.' + C_VALUELOCATION,
 
598
    editicon: '.' + C_EDITICON,
 
599
    null_text_location: '.' + C_NULLTEXTLOCATION,
 
600
    addicon: '.' + C_ADDICON
 
601
};
 
602
 
 
603
NullChoiceSource.ATTRS = {
 
604
    null_text_location: {},
 
605
    addicon: {},
 
606
    /**
 
607
     * Action icon returns either the add icon or the edit icon, depending
 
608
     * on whether the currently selected value is null.
 
609
     *
 
610
     * @attribute actionicon
 
611
     */
 
612
    actionicon: {
 
613
        getter: function() {
 
614
            if (Y.Lang.isValue(this.get('value'))) {
 
615
                return this.get('editicon');
 
616
            } else {
 
617
                return this.get('addicon');
 
618
            }
 
619
          }
 
620
    },
 
621
    /**
 
622
     * The specialised version of the items attirbute is cloned and the name
 
623
     * of the null value is modified to add a remove icon next to it. If the
 
624
     * currently selected value is null, the null item is not displayed.
 
625
     *
 
626
     * @attribute items
 
627
     */
 
628
    items: {
 
629
        value: [],
 
630
        getter: function(v) {
 
631
            if (!Y.Lang.isValue(this.get("value"))) {
 
632
                v = Y.Array(v).filter(function(item) {
 
633
                    return (Y.Lang.isValue(item.value));
 
634
                });
 
635
            }
 
636
            for (var i = 0; i < v.length; i++) {
 
637
                if (!Y.Lang.isValue(v[i].value) &&
 
638
                    v[i].name.indexOf('<img') == -1) {
 
639
                    // Only append the icon if the value for this item is
 
640
                    // null, and the img tag is not already found.
 
641
                    v[i].name = [
 
642
                        '<img src="https://launchpad.net/@@/remove" ',
 
643
                        '     style="margin-right: 0.5em; border: none; ',
 
644
                        '            vertical-align: middle" />',
 
645
                        '<span style="text-decoration: underline; ',
 
646
                        '             display: inline;',
 
647
                        '             color: green;">',
 
648
                        v[i].name,
 
649
                        '</span>'].join('');
 
650
                }
 
651
            }
 
652
            return v;
 
653
        },
 
654
        clone : "deep"
 
655
    }
 
656
};
 
657
 
 
658
Y.extend(NullChoiceSource, ChoiceSource, {
 
659
    initializer: function(cfg) {
 
660
        var addicon = this.get('addicon');
 
661
        addicon.original_src = addicon.get("src");
 
662
        var old_uiClearWaiting = this._uiClearWaiting;
 
663
        this._uiClearWaiting = function() {
 
664
            old_uiClearWaiting.call(this);
 
665
            if (Y.Lang.isValue(this.get('value'))) {
 
666
                this.get('null_text_location').setStyle('display', 'none');
 
667
                this.get('addicon').setStyle('display', 'none');
 
668
                this.get('value_location').setStyle('display', 'inline');
 
669
                this.get('editicon').setStyle('display', 'inline');
 
670
            } else {
 
671
                this.get('null_text_location').setStyle('display', 'inline');
 
672
                this.get('addicon').setStyle('display', 'inline');
 
673
                this.get('value_location').setStyle('display', 'none');
 
674
                this.get('editicon').setStyle('display', 'none');
 
675
            }
 
676
        };
 
677
    }
 
678
});
 
679
 
 
680
Y.NullChoiceSource = NullChoiceSource;
 
681
 
 
682
},"0.2", {"skinnable": true,
 
683
          "requires": ["oop", "event", "event-delegate", "node",
 
684
                       "widget", "widget-position", "widget-stdmod",
 
685
                       "overlay", "lazr.overlay", "lazr.anim", "lazr.base"]});
 
686