Grafika zraje, napíšu si komponentu

Sliboval jsem, sliboval. Co jsem to sliboval? To že poodhalím grafickou podobu mojí skvělé aplikace. Dokonce jsem i první grafiku nakreslil, prozatím s ní ale nejsem úplně spokojen, je tomu prostě potřeba dát ještě trochu času. A proto přicházím s kompenzací.

Když jsem si připravoval inspiraci a materiály, rozhodl jsem se, že budu vycházet ze skvělého CSS frameworku od Twitteru, ano každý ho zná, je to Bootsrap. Ten v sobě obsahuje spoustu hezkých čudlátek, hejbátek a vůbec všech možných potřebností.

Dokumentace hezky ukazuje, jak se pomocí klasických HTML prvků a tříd dá například z obyčejného button elementu udělat přenádherné tlačítko, určit mu barvičku, přidat mu ikonku a třeba i nastavit velikost. Je toho prostě spousta, co se takovým tlačítkem dá udělat. Jak toho všeho ale využít uvnitř Ember aplikace?

Jako první bude fajn si Boostrap nainstalovat do mého projektu. A to rovnou, novotou vonící, verzi s pořadovým číslem 3. Do Gemfile jsem přidal:

1
2
3
gem 'anjlab-bootstrap-rails', :require => 'bootstrap-rails',
    :github => 'anjlab/bootstrap-rails',
    :branch => '3.0.0'

a spustil bundler:

1
~/vetserv$ bundle install

Teď ještě Bootstrap přidám do do hlavního stylu tak, že ho přejmenuji z application.css na application.css.scss a vložím @import "twitter/bootstrap";

První čudlátko

Říkám si: “Chci velké červené tlačítko, na které když se klikne, něco se vypíše do konzole.” Každý, kdo kdy použil jQuery, jistě tuší jak na to. Stop! Používám Ember.js, budu to dělat jinak! Jak? Takhle:

vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    <button class="btn btn-danger btn-lg" {{action click}}>Stiskni mě!</button>
</div>

Po kliknutí to ale zatím nic neudělá, protože nemám napsanou akci v kontroléru, mohla by vypadat asi takto:

vetreserv/app/assets/javascripts/controllers/application_controller.js.coffee
1
2
3
4
Vetreserv.ApplicationController = Ember.Controller.extend
  actions:
    click: ->
      console.log 'něco'

a voilà hop

Požaduji další funkčnost

Tohle bylo dost jednoduché, trochu si to ztížím: “Navíc chci, aby po stisknutí tlačíka přešlo do tak zvaného loadingState a po 2 vteřinách se vrátilo zpět do stavu normálního.” Nejdřív se sluší odkázat na to, co to loadingState je. A teď hurá na to. Potřebuji:

  1. proměnnou, která bude značit, že se něco načítá (isLoading),
  2. podle této proměnné zpodmínkovat text v tlačítku,
  3. stejně tak podle ní tlačítko z(ne)aktivňovat,
  4. nu a nakonec nastavit časovač, který vrátí proměnnou po 2 vteřinách zpět.
vetreserv/app/assets/javascripts/controllers/application_controller.js.coffee
1
2
3
4
5
6
7
8
9
Vetreserv.ApplicationController = Ember.Controller.extend
  isLoading: false
  actions:
    click: ->
      console.log 'něco'
      @set 'isLoading', true
      setTimeout =>
        @set 'isLoading', false
      , 2000
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
6
7
8
9
10
11
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    <button class="btn btn-danger btn-lg" {{action click}} {{bind-attr disabled=isLoading}}>
        {{#if isLoading}}
        Načítám ...
        {{else}}
        Stiskni mě!
        {{/if}}
    </button>
</div>

Znovupoužitelnost? Rozšiřitelnost?

Omnomnom, krásně mi to všechno funguje. Koukám do dokumentace Bootstrapu, co vše by ještě to moje tlačítko mohlo umět a představuji si, jak bych kód šablony doplňoval a nafukoval. Pokud bych chtěl mít i jinde podobně mocná tlačítka, kód bych kopíroval tam a sem a taky kousek tuhle a támhle.

Mezi tím vším kopírovaním, při mém štěstí, by si určitě kluci z Twittru rozmysleli, že třída btn je málo výmluvná a přejmenovali by ji v další verzi na button. Já, natěšen na nové krásné nevímco, bych samozřejmě mezi prvními upgradoval a … ježkovi zraky! To to jako mám všude přepsat?

Tu přichází ta kompezace z úvodu. Moje programátorské svědomí celkem jasně velí toto multifunkční tlačítko hezky zabalit do komponenty a světu vystavit na odiv jen jeho rozhraní. Ona kompenzace spočívá v tom, že se zde o tuto komponentu podělím v podobě step-by-step postupu jejího zrození.

Rozhraní tlačítka

Tlačítko bude View s následujícími vlastnostmi (všechny jsou volitelné, to zanemená že je lze zcela vynechat).

1
2
3
4
5
6
7
8
9
10
11
12
{{ view Vetreserv.ButtonView
	
	text		= "Text tlačítka"
	type		= "default|primary|success|info|warning|danger|link"
	size		= "lg|sm|xs"
	loadingText	= "Text tlačítka v loading state"
	isLoading	= true|false
	icon		= "nazev_ikonky"
	disabled	= false|true
	block		= true|false
	action		= "nazev_akce"
}}

Základní view

Začnu tím jednoduchým, bude se snažit vykreslit jednoduché tlačítko.

vetreserv/app/assets/javascripts/views/button_view.js.coffee
1
2
3
4
5
6
7
8
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend
  tagName: 'button'

  classNameBindings: [":#{bootstrapClass}"]

  render: (buffer) ->
    buffer.push @get('text')
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    {{view Vetreserv.ButtonView text="Stiskni mě!"}}
</div>

Myslím, že je zbytečné vysvětlovat jednotlivé řádky a vlastnosti v mé nové třídě a to zejména proto, že v dokumentaci Emberu.js je vše detailně popsáno. Tady jsou jednotlivé odkazy do dokumentace:

  1. vlastnost tagName
  2. vlastnost classNameBindings
  3. metoda render

Zajímavé je, proč používám metodu render, když mohu jednoduše použít šablonu. Někde jsem četl, že je to výkonější a v tomto případě, kdy renderujeme jen text uvnitř tlačítka, je to stále jednoduché a čitelné. Na druhou stranu, je nutné si uvědomit, že ztracím kouzlo vazeb a tím i kouzlo automatického překreslení v závislosti na změnách proměnných. Na to ostatně narazím později (hlavně na to nezapomenout) a budu se s tím muset nějka poprat.

Velikost, typ a zorabzení na celou šířku

vetreserv/app/assets/javascripts/views/button_view.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend
  tagName: 'button'
  type: 'default'

  classNameBindings: [":#{bootstrapClass}", "typeClass", "sizeClass", "blockClass"]

  typeClass: (->
    "#{bootstrapClass}-#{@get('type')}"
  ).property('type')

  sizeClass: (->
    if @get('size') then "#{bootstrapClass}-#{@get('size')}" else null
  ).property('size')

  blockClass: (->
    if @get('block') then "#{bootstrapClass}-block" else null
  ).property('block')

  render: (buffer) ->
    buffer.push @get('text')
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
6
7
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    {{view Vetreserv.ButtonView text="Stiskni mě!" type="danger" size="lg"}}
    <br /><br />
    {{view Vetreserv.ButtonView block=true text="Stiskni mě!"}}
</div>

Jak tohle vlastně funguje? Použil jsem, co jsem již jednou ukázal, konkrétně vlastnost classNameBindings dle instrukcí v dokumentaci. Tím jsem vytvořil vazby mezi třídou elementu a vlastností view. Protože nemohu použít klasické statické vlastnosti, ale musím je poskládat dle Boostrap dokumentace, využil jsem další zajímavé funkčnosti Ember.js a tou jsou počítané vlastnosti. Jsou to vlastně fuknce, které se tváří jako vlastnosti. Navíc se šetrně ukládají do mezipaměti a invalidují se v okamžiku, kdy se změní jedna z vlastností, na které jsou závislé. Paráda.

Ikonka

Tohle je už trochu těžší kalibr. Hlavně proto, že ikonku může přeci mít více prvků, nejen tlačítko, musím tedy aplikovat DRY a napsat to tak, aby bylo možno ikonku použít i u jiných prvků. Alzák by zvolal “Tamtadadáááá” a obratem by mi prodal Ember.Mixin za super baťovskou cenu, protože přesně ten nyní potřebuji.

Mixin bude velmi jednoduchý, přidá jednu metodu pro renderování ikonky renderIcon, za jejíž volání bude zodpovědná třída (View), která daný Mixin bude používat.

vetreserv/app/assets/javascripts/mixins/icon_aware_mixin.js.coffee
1
2
3
Vetreserv.IconAwareMixin = Ember.Mixin.create
  renderIcon: (buffer) ->
    buffer.push "<i class=\"glyphicon glyphicon-#{@get('icon')}\"></i>&nbsp;" if @get('icon')
vetreserv/app/assets/javascripts/views/button_view.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend Vetreserv.IconAwareMixin,
  tagName: 'button'
  type: 'default'

  classNameBindings: [":#{bootstrapClass}", "typeClass", "sizeClass", "blockClass"]

  typeClass: (->
    "#{bootstrapClass}-#{@get('type')}"
  ).property('type')

  sizeClass: (->
    if @get('size') then "#{bootstrapClass}-#{@get('size')}" else null
  ).property('size')

  blockClass: (->
    if @get('block') then "#{bootstrapClass}-block" else null
  ).property('block')

  render: (buffer) ->
    @renderIcon(buffer)
    buffer.push @get('text')
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    {{view Vetreserv.ButtonView text="Stiskni mě!" type="danger" size="lg" icon="star"}}
</div>

A co volání akce?

Mám sice přepěkné tlačítko, ale jaksi se nedá moc použít, protože neumí zavolat akci v kontroléru. Tady je třeba trochu pátrat, aby toho jeden byl schopen dosáhnout – ale kdo hledá, najde Ember.ViewTargetActionSupport a pak už je to hračka. Navíc jsem využil faktu, že pokud view implementuje metodu, která se jmenuje stejně jako akce z jQuery (takové to click, doubleClick a spol.), je tato akce na daný view kontejner připojena.

vetreserv/app/assets/javascripts/views/button_view.js.coffee
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
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend Vetreserv.IconAwareMixin, Ember.ViewTargetActionSupport,
  tagName: 'button'
  type: 'default'

  classNameBindings: [":#{bootstrapClass}", "typeClass", "sizeClass", "blockClass"]

  typeClass: (->
    "#{bootstrapClass}-#{@get('type')}"
  ).property('type')

  sizeClass: (->
    if @get('size') then "#{bootstrapClass}-#{@get('size')}" else null
  ).property('size')

  blockClass: (->
    if @get('block') then "#{bootstrapClass}-block" else null
  ).property('block')

  render: (buffer) ->
    @renderIcon(buffer)
    buffer.push @get('text')

  click: ->
    @triggerAction()
    false
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    {{view Vetreserv.ButtonView text="Stiskni mě!" type="danger" size="lg" icon="star" action="handleClick"}}
</div>
vetreserv/app/assets/javascripts/controllers/application_controller.js.coffee
1
2
3
4
Vetreserv.ApplicationController = Ember.Controller.extend
  actions:
    handleClick: ->
      console.log 'něco'

To nejlepší nakonec – LoadingState

Když jsem se nad tím zamyslel, vyšlo mi, co všechno vlastně potřebuji jako vstup:

  1. text, který se má zobrazit při načítání,
  2. proměnnou, která mi bude načítací stav indikovat.

Z algoritmického pohledu pak bude komponenta fungovat tak, že text do tlačítka se bude vypisovat podle toho, jak je nastavena proměnná indikující načítací stav a stejně tak se bude přidávat nebo odebírat disabled attribut . Udělal jsem tedy úpravu komponenty:

vetreserv/app/assets/javascripts/views/button_view.js.coffee
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
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend Vetreserv.IconAwareMixin, Ember.ViewTargetActionSupport,
  tagName: 'button'
  type: 'default'
  isLoading: false

  classNameBindings: [":#{bootstrapClass}", "typeClass", "sizeClass", "blockClass"]
  attributeBindings: ['disabledAttr:disabled']

  typeClass: (->
    "#{bootstrapClass}-#{@get('type')}"
  ).property('type')

  sizeClass: (->
    if @get('size') then "#{bootstrapClass}-#{@get('size')}" else null
  ).property('size')

  blockClass: (->
    if @get('block') then "#{bootstrapClass}-block" else null
  ).property('block')

  disabledAttr: (->
    @get('isLoading') || @get('disabled')
  ).property('disabled', 'isLoading')

  render: (buffer) ->
    @renderIcon(buffer)
    if !@get('isLoading')
      text = @get('text')
    else if !@get('loadingText')
      text = "#{@get('text')} ..."
    else
      text = "#{@get('loadingText')}"
    buffer.push text

  click: ->
    @triggerAction()
    false
vetreserv/app/assets/javascripts/templates/application.hbs
1
2
3
4
5
6
7
<div class="container">
    <h1>Ember.js</h1>
    <hr/>
    {{view Vetreserv.ButtonView text="Stiskni mě!"
        type="danger" size="lg" icon="star" action="handleClick"
        isLoadingBinding=isLoading}}
</div>
vetreserv/app/assets/javascripts/controllers/application_controller.js.coffee
1
2
3
4
5
6
Vetreserv.ApplicationController = Ember.Controller.extend
  isLoading: false
  actions:
    handleClick: ->
      console.log 'něco'
      @set 'isLoading', true

Super, to je mi ale … počkat! Tlačítko sice je disablované, ale nezměnil se mi text tlačítka! A teď to přichází, několik desítek minut střídavě ladím, refreshuji, vypisuji do konzole a nadávám. Poté si vzpomenu, jak jsem se výše zmiňoval o nedostatku použití metody render pro vykreslování a okamžitě si dávám takovou facku, že mi na okamžik slézají uši na jednu stranu hlavy. No nic, příště si udělám uzel na kabelu od mé Apple Wireless MagicMouse.

Existují dvě možnosti, jak to vyřešit. První je, přestat používat metodu render a místo toho použít šablonu, která se bude sama překreslovat. To bych ale musel upravit i mixin pro ikonku (což stejně budu muset později asi udělat) a navíc mám rád seriál How I Met Your Mother – “Challenge accepted!”. Druhou možností, která mi navíc trochu rozšíří obzory, je použití observerů:

vetreserv/app/assets/javascripts/views/button_view.js.coffee
1
2
3
4
5
6
7
bootstrapClass = 'btn'
Vetreserv.ButtonView = Ember.View.extend Vetreserv.IconAwareMixin, Ember.ViewTargetActionSupport,
  # ...

  isLoadingChanged: (->
    @rerender()
  ).observes('isLoading')

A je to!

Jsem na sebe hrdý! Tlačítko jak z alabastru a padla u toho jen jedna facka.

Odkazy

Komentáře