Merge lp:~jcsackett/juju-gui/charm-slider into lp:juju-gui/experimental
- charm-slider
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 420 |
Proposed branch: | lp:~jcsackett/juju-gui/charm-slider |
Merge into: | lp:juju-gui/experimental |
Diff against target: |
512 lines (+447/-2) 8 files modified
app/modules-debug.js (+4/-0) app/templates/charm-small-widget.handlebars (+1/-1) app/widgets/charm-slider.js (+329/-0) app/widgets/charm-small.js (+1/-1) lib/views/browser/charm-slider.less (+29/-0) lib/views/stylesheet.less (+1/-0) test/index.html (+1/-0) test/test_charm_slider.js (+81/-0) |
To merge this branch: | bzr merge lp:~jcsackett/juju-gui/charm-slider |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email: mp+152418@code.launchpad.net |
Commit message
Description of the change
Adds the charm slider widget
This adds a slider widget derived from Jeff Pihach's flickr carousel. It
doesn't wire it into anything yet.
j.c.sackett (jcsackett) wrote : | # |
Richard Harding (rharding) wrote : | # |
Some light weight changes. Many have a lot of occurances so didn't mark
each one for the first pass, but {Object} vs { Object } and String vs
string.
Some other suggestions and additional tests requested.
https:/
File app/widgets/
https:/
app/widgets/
namespace juju; modules widgets; submodule browser.notifier I think is
how it works.
https:/
app/widgets/
comma here and combine with next var
https:/
app/widgets/
In the doc string can you put a sample of min. html needed to use the
widget? Want to include enough to reuse without too much hunting.
https:/
app/widgets/
Caps String for a class name. No spaces inside the {}.
https:/
app/widgets/
extra space here
https:/
app/widgets/
extra space here (my snippets fault I know...)
https:/
app/widgets/
please move private methods to the top of the class functions.
https:/
app/widgets/
that.get('width'), index: index}));
you get width twice. Please just get the var and cache it to avoid
lookups that aren't required.
https:/
app/widgets/
or a designated index
end comment with .
https:/
app/widgets/
why does index need to be parsed? How is it coming in as a string?
https:/
app/widgets/
can we rename this to pause(d)OnHover. It matches up better since it's a
completed state. Maybe even just move to paused. Who cares if we're
paused for hover or some manual control.
https:/
app/widgets/
Y.juju.
to prevent the nested lookup use ns. please.
https:/
app/widgets/
since this is a little slider let's default to something closer to what
we would use like 150 or 200px.
- 431. By j.c.sackett
-
Addressed comments from review.
j.c.sackett (jcsackett) wrote : | # |
Agree with most comments, address the remainder. Code is coming up
shortly.
https:/
File app/widgets/
https:/
app/widgets/
On 2013/03/08 15:12:02, rharding wrote:
> In the doc string can you put a sample of min. html needed to use the
widget?
> Want to include enough to reuse without too much hunting.
Per discussion, this doesn't actual require any setup; just pass in
parentNode to render, as with default Y.Widget.
https:/
app/widgets/
On 2013/03/08 15:12:02, rharding wrote:
> why does index need to be parsed? How is it coming in as a string?
We get index out of a data-* attribute on the element, but I have moved
the parseInt to where we fetch it, so it's an int when it hits this
method.
j.c.sackett (jcsackett) wrote : | # |
Please take a look.
Jeff Pihach (hatch) wrote : | # |
Thanks for the code! See my comments (as we discussed) below.
https:/
File app/widgets/
https:/
app/widgets/
We decided to no longer use isValue() as all it's really doing is a
glorified falsy check
https:/
app/widgets/
index: index}));
There is no need to do the that=this trick here because (like most
methods of this type in yui) it has a final param which is the context.
http://
https:/
app/widgets/
setContent() is depricated use setHTML()
https:/
app/widgets/
'info', this.name);
I think we have standardized on using console.log()
https:/
app/widgets/
function(item, index) {
Like I mentioned above, YUI's iterators have a context param as the
final param so the that=this can be removed in favour of that.
https:/
app/widgets/
this.getAttribu
event callbacks are passed an event object which hold reference to the
target.
so this can be changed to e.currentTarget
https:/
app/widgets/
delegate() also has a context property
https:/
File lib/views/
https:/
lib/views/
.yui3-browser-
Because we are using less you can actually nest most of the rules in
this commit - let me know if you need help doing this.
- 432. By j.c.sackett
-
Changes from hatch's review
j.c.sackett (jcsackett) wrote : | # |
On 2013/03/08 19:23:49, jeff.pihach wrote:
> Thanks for the code! See my comments (as we discussed) below.
https:/
> File app/widgets/
https:/
> app/widgets/
> We decided to no longer use isValue() as all it's really doing is a
glorified
> falsy check
https:/
> app/widgets/
index:
> index}));
> There is no need to do the that=this trick here because (like most
methods of
> this type in yui) it has a final param which is the context.
> http://
https:/
> app/widgets/
> setContent() is depricated use setHTML()
https:/
> app/widgets/
'info',
> this.name);
> I think we have standardized on using console.log()
https:/
> app/widgets/
function(item,
> index) {
> Like I mentioned above, YUI's iterators have a context param as the
final param
> so the that=this can be removed in favour of that.
https:/
> app/widgets/
this.getAttribu
> event callbacks are passed an event object which hold reference to the
target.
> so this can be changed to e.currentTarget
https:/
> app/widgets/
> delegate() also has a context property
https:/
> File lib/views/
https:/
> lib/views/
.yui3-browser-
> Because we are using less you can actually nest most of the rules in
this commit
> - let me know if you need help doing this.
I've address your points, code coming shortly.
j.c.sackett (jcsackett) wrote : | # |
Please take a look.
Jeff Pihach (hatch) wrote : | # |
Richard Harding (rharding) wrote : | # |
On 2013/03/08 20:17:48, jeff.pihach wrote:
> LGTM
lgtm as well
j.c.sackett (jcsackett) wrote : | # |
*** Submitted:
Adds the charm slider widget
This adds a slider widget derived from Jeff Pihach's flickr carousel. It
doesn't wire it into anything yet.
R=rharding, jeff.pihach
CC=
https:/
Preview Diff
1 | === modified file 'app/modules-debug.js' |
2 | --- app/modules-debug.js 2013-03-06 16:17:11 +0000 |
3 | +++ app/modules-debug.js 2013-03-08 19:44:21 +0000 |
4 | @@ -52,6 +52,10 @@ |
5 | fullpath: '/juju-ui/widgets/charm-small.js' |
6 | }, |
7 | |
8 | + 'browser-charm-slider': { |
9 | + fullpath: '/juju-ui/widgets/charm-slider.js' |
10 | + }, |
11 | + |
12 | 'reconnecting-websocket': { |
13 | fullpath: '/juju-ui/assets/javascripts/reconnecting-websocket.js' |
14 | }, |
15 | |
16 | === modified file 'app/templates/charm-small-widget.handlebars' |
17 | --- app/templates/charm-small-widget.handlebars 2013-03-01 20:58:58 +0000 |
18 | +++ app/templates/charm-small-widget.handlebars 2013-03-08 19:44:21 +0000 |
19 | @@ -1,4 +1,4 @@ |
20 | -<div > |
21 | +<div> |
22 | <img url="{{ iconfile }}" /> |
23 | <h3 class="title">{{ title }}</h3> |
24 | <p class="description">{{ description }}</p> |
25 | |
26 | === added file 'app/widgets/charm-slider.js' |
27 | --- app/widgets/charm-slider.js 1970-01-01 00:00:00 +0000 |
28 | +++ app/widgets/charm-slider.js 2013-03-08 19:44:21 +0000 |
29 | @@ -0,0 +1,329 @@ |
30 | +'use strict'; |
31 | + |
32 | + |
33 | +/** |
34 | + * Provides the Charm Slider widget. |
35 | + * |
36 | + * @namespace juju |
37 | + * @module widgets |
38 | + * @submodule browser.CharmSlider |
39 | + * |
40 | + */ |
41 | +YUI.add('browser-charm-slider', function(Y) { |
42 | + var sub = Y.Lang.sub, |
43 | + ns = Y.namespace('juju.widgets.browser'); |
44 | + |
45 | + /** |
46 | + * The CharmSlider provides a rotating display of one member of a generic set |
47 | + * of items, with controls to go directly to a given item. |
48 | + * |
49 | + * @class CharmSlider |
50 | + * @extends {Y.ScrollView} |
51 | + * |
52 | + */ |
53 | + ns.CharmSlider = new Y.Base.create('browser-charm-slider', Y.ScrollView, [], { |
54 | + |
55 | + /** |
56 | + * Template for the CharmSlider |
57 | + * |
58 | + * @property charmSliderTemplate |
59 | + * @type {String} |
60 | + * |
61 | + */ |
62 | + charmSliderTemplate: '<ul width="{width}px" />', |
63 | + |
64 | + /** |
65 | + * Template for a given item in the slider |
66 | + * |
67 | + * @property itemTemplate |
68 | + * @type {String} |
69 | + * |
70 | + */ |
71 | + itemTemplate: '<li width="{width}px" data-index="{index}" />', |
72 | + |
73 | + /** |
74 | + * Template used for the navigation controls. |
75 | + * |
76 | + * @property prevNavTemplate |
77 | + * @type {String} |
78 | + */ |
79 | + navTemplate: '<ul class="navigation"></div>', |
80 | + |
81 | + /** |
82 | + * Template used for items in the navigation. |
83 | + * |
84 | + * @property navItemTemplate |
85 | + * @type {String} |
86 | + */ |
87 | + navItemTemplate: '<li data-index="{index}">O</li>', |
88 | + |
89 | + /** |
90 | + * Advances the slider to the next item, or a designated index. |
91 | + * |
92 | + * @method _advanceSlide |
93 | + * @param {string} Index to move to; if not supplied, advances to next |
94 | + * slide. |
95 | + * @private |
96 | + */ |
97 | + _advanceSlide: function(index) { |
98 | + var pages = this.pages; |
99 | + if (index) { |
100 | + this._stopTimer(); |
101 | + pages.scrollToIndex(index); |
102 | + this._startTimer(); |
103 | + } else { |
104 | + index = pages.get('index'); |
105 | + if (index < pages.get('total') - 1) { |
106 | + pages.next(); |
107 | + } else { |
108 | + pages.scrollToIndex(0); |
109 | + } |
110 | + } |
111 | + }, |
112 | + |
113 | + /** |
114 | + * Creates the structure and DOM nodes for the slider. |
115 | + * |
116 | + * @method _generateDOM |
117 | + * @private |
118 | + * @return {Node} The slider's DOM nodes. |
119 | + * |
120 | + */ |
121 | + _generateDOM: function() { |
122 | + var width = this.get('width'), |
123 | + slider = Y.Node.create( |
124 | + sub(this.charmSliderTemplate, {width: width})); |
125 | + |
126 | + Y.Array.map(this.get('items'), function(item, index) { |
127 | + var tmpNode = Y.Node.create( |
128 | + sub(this.itemTemplate, {width: width, index: index})); |
129 | + tmpNode.setHTML(item); |
130 | + slider.append(tmpNode); |
131 | + }, this); |
132 | + return slider; |
133 | + }, |
134 | + |
135 | + /** |
136 | + * Generates and appends the navigation controls for the slider |
137 | + * |
138 | + * @method _generateSliderControls |
139 | + * @private |
140 | + */ |
141 | + _generateSliderControls: function() { |
142 | + var nav = Y.Node.create(this.navTemplate); |
143 | + Y.Array.each(this.get('items'), function(item, index) { |
144 | + nav.append(Y.Node.create(sub( |
145 | + this.navItemTemplate, {index: index}))); |
146 | + }, this); |
147 | + this.get('boundingBox').append(nav); |
148 | + }, |
149 | + |
150 | + /** |
151 | + * Mouseenter/mouseleave event handler |
152 | + * |
153 | + * @method _pauseAutoAdvance |
154 | + * @private |
155 | + * @param {object} mouseout or mouseover event object. |
156 | + */ |
157 | + _pauseAutoAdvance: function(e) { |
158 | + if (e.type === 'mouseenter') { |
159 | + this.set('paused', true); |
160 | + } else { |
161 | + this.set('paused', false); |
162 | + } |
163 | + }, |
164 | + |
165 | + /** |
166 | + * Checks to see if autoadvance is set then sets up the timeouts |
167 | + * |
168 | + * @method _startTimer |
169 | + * @private |
170 | + */ |
171 | + _startTimer: function() { |
172 | + |
173 | + if (this.get('autoAdvance') === true) { |
174 | + var timer = Y.later(this.get('advanceDelay'), this, function() { |
175 | + if (this.get('paused') !== true) { |
176 | + this._advanceSlide(); |
177 | + } |
178 | + }, null, true); |
179 | + this.set('timer', timer); |
180 | + } |
181 | + }, |
182 | + |
183 | + /** |
184 | + * Stops the timer for autoadvance |
185 | + * |
186 | + * @method _stopTimer |
187 | + * @private |
188 | + */ |
189 | + _stopTimer: function() { |
190 | + var timer = this.get('timer'); |
191 | + if (timer) { |
192 | + timer.cancel(); |
193 | + } |
194 | + }, |
195 | + |
196 | + /** |
197 | + * Binds the navigate event listeners |
198 | + * |
199 | + * @method bindUI |
200 | + * @private |
201 | + */ |
202 | + bindUI: function() { |
203 | + |
204 | + //Call the parent bindUI method |
205 | + ns.CharmSlider.superclass.bindUI.apply(this); |
206 | + |
207 | + var events = this.get('_events'), |
208 | + boundingBox = this.get('boundingBox'), |
209 | + nav = boundingBox.one('.navigation'); |
210 | + events.push(this.after('render', this._startTimer, this)); |
211 | + events.push(boundingBox.on('mouseenter', this._pauseAutoAdvance, this)); |
212 | + events.push(boundingBox.on('mouseleave', this._pauseAutoAdvance, this)); |
213 | + events.push(nav.delegate('click', function(e) { |
214 | + var index = e.currentTarget.getAttribute('data-index'); |
215 | + index = parseInt(index, 10); |
216 | + this._advanceSlide(index); |
217 | + }, 'li', this)); |
218 | + }, |
219 | + |
220 | + /** |
221 | + * Detaches events attached during instantiation |
222 | + * |
223 | + * @method destructor |
224 | + * @private |
225 | + */ |
226 | + destructor: function() { |
227 | + this.get('_events').each(function(event) { |
228 | + event.detach(); |
229 | + }); |
230 | + }, |
231 | + |
232 | + /** |
233 | + * Initializer |
234 | + * |
235 | + * @method initializer |
236 | + * @param {Object} The config object. |
237 | + * |
238 | + */ |
239 | + initializer: function(cfg) { |
240 | + this.plug(Y.Plugin.ScrollViewPaginator, { |
241 | + selector: 'li' |
242 | + }); |
243 | + |
244 | + }, |
245 | + |
246 | + /** |
247 | + * Render the nodes and HTML for the slider. |
248 | + * |
249 | + * @method renderUI |
250 | + * @private |
251 | + */ |
252 | + renderUI: function() { |
253 | + this.get('contentBox').setHTML(this._generateDOM()); |
254 | + this._generateSliderControls(); |
255 | + } |
256 | + }, { |
257 | + ATTRS: { |
258 | + |
259 | + /** |
260 | + * @attribute width |
261 | + * @default 200 |
262 | + * @type {Int} |
263 | + * |
264 | + */ |
265 | + width: { |
266 | + value: 500 |
267 | + }, |
268 | + |
269 | + /** |
270 | + * @attribute autoAdvance |
271 | + * @default true |
272 | + * @type {Boolean} |
273 | + * |
274 | + */ |
275 | + autoAdvance: { |
276 | + value: true |
277 | + }, |
278 | + |
279 | + /** |
280 | + * @attribute advanceDelay |
281 | + * @default 3000 |
282 | + * @type {Int} |
283 | + * |
284 | + */ |
285 | + advanceDelay: { |
286 | + value: 3000 |
287 | + }, |
288 | + |
289 | + /** |
290 | + * @attribute paused |
291 | + * @default false |
292 | + * @type {Boolean} |
293 | + * |
294 | + */ |
295 | + paused: { |
296 | + value: false |
297 | + }, |
298 | + |
299 | + /** |
300 | + * @attribute items |
301 | + * @default [] |
302 | + * @type {Array} |
303 | + * |
304 | + */ |
305 | + items: { |
306 | + value: [], |
307 | + /** |
308 | + * Verify items aren't larger than max value. |
309 | + * |
310 | + * @method validator |
311 | + * @param {Array} The items being validated. |
312 | + */ |
313 | + validator: function(val) { |
314 | + return (val.length <= this.get('max')); |
315 | + } |
316 | + }, |
317 | + |
318 | + /** |
319 | + * @attribute _events |
320 | + * @default [] |
321 | + * @type {Array} |
322 | + * |
323 | + */ |
324 | + _events: { |
325 | + value: [] |
326 | + }, |
327 | + |
328 | + /** |
329 | + * @attribute max |
330 | + * @default 5 |
331 | + * @type {Int} |
332 | + * |
333 | + */ |
334 | + max: { |
335 | + value: 5 |
336 | + }, |
337 | + |
338 | + /** |
339 | + * @attribute timer |
340 | + * @default null |
341 | + * @type {Object} |
342 | + * |
343 | + */ |
344 | + timer: { |
345 | + value: null |
346 | + } |
347 | + } |
348 | + }); |
349 | + |
350 | +}, '0.1.0', { |
351 | + requires: [ |
352 | + 'array-extras', |
353 | + 'base', |
354 | + 'event-mouseenter', |
355 | + 'scrollview', |
356 | + 'scrollview-paginator' |
357 | + ] |
358 | +}); |
359 | |
360 | === modified file 'app/widgets/charm-small.js' |
361 | --- app/widgets/charm-small.js 2013-03-04 21:26:27 +0000 |
362 | +++ app/widgets/charm-small.js 2013-03-08 19:44:21 +0000 |
363 | @@ -57,7 +57,7 @@ |
364 | }, |
365 | |
366 | /** |
367 | - * Desctructor |
368 | + * Destructor |
369 | * |
370 | * @method destructor |
371 | * @return {undefined} Mutates only. |
372 | |
373 | === added file 'lib/views/browser/charm-slider.less' |
374 | --- lib/views/browser/charm-slider.less 1970-01-01 00:00:00 +0000 |
375 | +++ lib/views/browser/charm-slider.less 2013-03-08 19:44:21 +0000 |
376 | @@ -0,0 +1,29 @@ |
377 | +.yui3-browser-charm-slider { |
378 | + |
379 | + .yui3-browser-charm-slider-content { |
380 | + white-space: nowrap; |
381 | + |
382 | + ul { |
383 | + margin: 0; |
384 | + -moz-padding-start: 0; |
385 | + padding-start: 0; |
386 | + -webkit-padding-start: 0; |
387 | + } |
388 | + |
389 | + li { |
390 | + display: inline-block; |
391 | + text-align: center; |
392 | + vertical-align: middle; |
393 | + } |
394 | + } |
395 | + |
396 | + .navigate { |
397 | + cursor: pointer; |
398 | + list-style-type: none; |
399 | + |
400 | + li { |
401 | + display: block-inline; |
402 | + width: auto; |
403 | + } |
404 | + } |
405 | +} |
406 | |
407 | === modified file 'lib/views/stylesheet.less' |
408 | --- lib/views/stylesheet.less 2013-03-06 15:07:47 +0000 |
409 | +++ lib/views/stylesheet.less 2013-03-08 19:44:21 +0000 |
410 | @@ -1635,3 +1635,4 @@ |
411 | } |
412 | |
413 | @import "browser/charm-small.less"; |
414 | +@import "browser/charm-slider.less"; |
415 | |
416 | === modified file 'test/index.html' |
417 | --- test/index.html 2013-03-06 15:59:28 +0000 |
418 | +++ test/index.html 2013-03-08 19:44:21 +0000 |
419 | @@ -39,6 +39,7 @@ |
420 | <script src="test_charm_configuration.js"></script> |
421 | <script src="test_charm_panel.js"></script> |
422 | <script src="test_charm_small_widget.js"></script> |
423 | + <script src="test_charm_slider.js"></script> |
424 | <script src="test_charm_store.js"></script> |
425 | <script src="test_charm_view.js"></script> |
426 | <script src="test_console.js"></script> |
427 | |
428 | === added file 'test/test_charm_slider.js' |
429 | --- test/test_charm_slider.js 1970-01-01 00:00:00 +0000 |
430 | +++ test/test_charm_slider.js 2013-03-08 19:44:21 +0000 |
431 | @@ -0,0 +1,81 @@ |
432 | +'use strict'; |
433 | + |
434 | +describe('charm slider', function() { |
435 | + var container, Y; |
436 | + |
437 | + before(function(done) { |
438 | + Y = YUI(GlobalConfig).use( |
439 | + ['browser-charm-slider', 'browser-charm-small', 'event-simulate', |
440 | + 'node-event-simulate', 'node'], function(Y) { |
441 | + done(); |
442 | + }); |
443 | + }); |
444 | + |
445 | + beforeEach(function() { |
446 | + container = Y.Node.create('<div id="container"></div>'); |
447 | + Y.one('body').prepend(container); |
448 | + }); |
449 | + |
450 | + afterEach(function() { |
451 | + container.remove(true); |
452 | + }); |
453 | + |
454 | + it('initializes', function() { |
455 | + var cs = new Y.juju.widgets.browser.CharmSlider(); |
456 | + assert.isObject(cs); |
457 | + }); |
458 | + |
459 | + it('creates the right DOM', function() { |
460 | + var cs = new Y.juju.widgets.browser.CharmSlider(), |
461 | + items = ['foo', 'bar', 'baz']; |
462 | + cs.set('items', items); |
463 | + var sliderDOM = cs._generateDOM(); |
464 | + assert.equal(3, sliderDOM.all('li').size()); |
465 | + var html = sliderDOM.get('outerHTML'); |
466 | + Y.Array.each(items, function(item) { |
467 | + assert.notEqual(-1, html.indexOf(item)); |
468 | + }); |
469 | + }); |
470 | + |
471 | + it('renders', function() { |
472 | + var cs = new Y.juju.widgets.browser.CharmSlider({ |
473 | + items: ['<div id="foo"/>'] |
474 | + }); |
475 | + cs.render(container); |
476 | + assert.isObject(Y.one('#foo')); |
477 | + }); |
478 | + |
479 | + it('it generates buttons for each', function() { |
480 | + var cs = new Y.juju.widgets.browser.CharmSlider(), |
481 | + items = ['<div />', '<div />']; |
482 | + cs.set('items', items); |
483 | + cs.render(container); |
484 | + var nav = Y.one('.navigation'); |
485 | + assert.equal(items.length, nav.all('li').size()); |
486 | + }); |
487 | + |
488 | + it('pauses on hover', function() { |
489 | + var cs = new Y.juju.widgets.browser.CharmSlider({items: ['<div/>']}); |
490 | + cs.render(container); |
491 | + Y.one('.yui3-browser-charm-slider').simulate('mouseover'); |
492 | + assert.isTrue(cs.get('paused'), 'Slider is not paused.'); |
493 | + Y.one('.yui3-browser-charm-slider').simulate('mouseout'); |
494 | + assert.isFalse(cs.get('paused'), 'Slider is not paused.'); |
495 | + }); |
496 | + |
497 | + it('goes to the right slide on nav click', function() { |
498 | + var cs = new Y.juju.widgets.browser.CharmSlider({ |
499 | + items: ['<div/>', '<div/>'], |
500 | + autoAdvance: false |
501 | + }); |
502 | + cs.render(container); |
503 | + assert.equal( |
504 | + 0, cs.pages.get('index'), |
505 | + 'Slider did not start on first slide.'); |
506 | + var li = Y.one('.navigation').all('li').pop(); |
507 | + li.simulate('click'); |
508 | + assert.equal( |
509 | + 1, cs.pages.get('index'), |
510 | + 'Slider did not advance to second slide.'); |
511 | + }); |
512 | +}); |
Reviewers: mp+152418_ code.launchpad. net,
Message:
Please take a look.
Description:
Adds the charm slider widget
This adds a slider widget derived from Jeff Pihach's flickr carousel. It
doesn't wire it into anything yet.
https:/ /code.launchpad .net/~jcsackett /juju-gui/ charm-slider/ +merge/ 152418
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/7641043/
Affected files: debug.js charm-small- widget. handlebars charm-slider. js charm-small. js browser/ charm-slider. less stylesheet. less charm_slider. js
A [revision details]
M app/modules-
M app/templates/
A app/widgets/
M app/widgets/
A lib/views/
M lib/views/
M test/index.html
A test/test_