CS Dept. Univ. Helsinki

Lukijalle

Tämä materiaali on tarkoitettu Helsingin yliopiston tietojenkäsittelytieteen laitoksen syksyllä 2015 järjestettävälle kurssille web-selainohjelmointi. Materiaali pohjautuu syksyn 2012 kurssiin, ja sen kirjoittajat ovat Kalle Ilves ja Arto Vihavainen. Vuoden 2012 materiaalin syntyyn ovat vaikuttaneet useat tahot, joista tärkein lienee Mikael Nousiainen. Iso kiitos kuuluu myös Kasper Hirvikoskelle.

Lue materiaalia siten, että teet samalla itse kaikki lukemasi esimerkit. Esimerkkeihin kannattaa tehdä pieniä muutoksia ja tarkkailla, miten muutokset vaikuttavat ohjelman toimintaan. Äkkiseltään voisi luulla, että esimerkkien tekeminen ja muokkaaminen hidastaa opiskelua. Tämä ei kuitenkaan pidä ollenkaan paikkansa. Oppiminen perustuu oleellisesti aktiiviseen tekemiseen ja rutiinin kasvattamiseen. Esimerkkien ja erityisesti omien kokeilujen tekeminen on parhaita tapoja sisäistää luettua.

Tekstiä ei ole tarkoitettu vain kertaalleen luettavaksi. Joudut varmasti myöhemmin palaamaan aiemmin lukemiisi kohtiin tai aiemmin tekemiisi tehtäviin. Tämä materiaali ei sisällä kaikkea oleellista web-selainohjelmointiin liittyvää. Tällä hetkellä ei ole oikeastaan mitään kirjaa josta löytyisi kaikki oleellinen. Joudut kurssin aikana ja urallasi etsimään tietoa myös omatoimisesti. Harjoitukset ja materiaali sisältävät jo jonkin verran ohjeita, mistä suunnista tietoa kannattaa lähteä hakemaan.

Jos (ja kun) materiaalista löytyy esimerkiksi kirjoitusvirheitä, korjaa tilanne, ja tee pull-request osoitteessa https://github.com/web-selainohjelmointi/web-selainohjelmointi.github.io. Kiitos bugien ja ongelmien korjauksesta vuosien aikana kuuluu monelle, joista tässä mainitaan vain muutama: pro_, gleant, BiQ, Absor, Rase, jombo, BearGrylls, Marko, _jumi_, jykke, Semilia, Walther, Loezi, happosade, doge ja mluukkai.

Pisteytys

Kurssi sisältää seitsemän tehtäväsarjaa. Ensimmäinen tehtäväsarja julkaistaan 25. lokakuuta 2015. Uusia tehtäväsarjoja julkaistaan viikoittain. Tehtäväsarjojen viimeinen palautuspäivämäärä on aina keskiviikkoisin kello 22:00. Ensimmäisen tehtäväsarjan viimeinen palautuspäivä on keskiviikkona 4.11. klo 22:00.

Kurssin arvostelu perustuu viikoittaisiin harjoitustehtäviin, sekä kahteen kokeeseen, joista toinen tehdään kynällä ja paperilla, ja toinen tietokoneella. Kurssin pisteytys on seuraava:

Kurssista voi saada yhteensä 1200 pistettä. Sekä paperilla ja kynällä että tietokoneella tehtävistä kokeista on kummastakin saatava vähintään puolet mahdollisista pisteistä. Kurssin alustavat arvosanarajat ovat seuraavat:

Sisältö

Tehtävät

Viikko 1

Tools of the trade

Käydään läpi pikaisesti oleellisia työkaluja.

Web-selainten tarjoamat kehittäjien työkalut

Ohjelmien debuggaaminen on oleellinen taito. Selainohjelmistot pyörivät selaimessa, joten luonnollinen paikka niiden debuggaamiseen on selaimessa. Esimerkiksi google chrome ja mozilla firefox tarjoavat debuggausympäristöt, joilla voi tutkia sivuja. Debuggausympäristöt aukeavat yleensä nappia f12-painamalla. Oleellisin osio lienee konsoli, mistä näkee esimerkiksi JavaScript-suorituksessa tapahtuvat virheviestit.

Tutustu Chrome Developer Toolseihin osoitteessa https://developers.google.com/chrome-developer-tools/

NetBeans

Kurssilla käytetään oletuksena NetBeans-ohjelmointiympäristön uusinta versiota (tätä kirjoitettaessa 8.0.2). Kurssin tehtävät palautetaan TMC:n kautta. Kaikille avointen verkkokurssien mooc.fi -sivusto tarjoaa hyvät asennusohjeet NetBeansille ja TMC:lle. Jos et halua käyttää NetBeansia, tehtävät voi palauttaa myös TMC:n web-sivujen kautta.

Huom! Toisin kuin kurssilla web-palvelinohjelmointi, TMC ei tarkasta tehtävien oikeellisuutta. Palauttaessasi tehtävän lupaat sen olevan oikein.

Seuraa asennusohjeita ja asenna tarvitsemasi työvälineet. Valitse TMC NetBeansin asetuksista palvelimeksi https://tmc.mooc.fi/hy ja kurssiksi hy-s2015-weso. Kun teet käyttäjätunnusta TMC:hen, käytä opiskelijanumeroasi käyttäjätunnuksena.

JSFiddle

JSFiddle on kurssin materiaalissa paljon käytetty interaktiivinen alusta kokeilujen tekemiseen. Materiaaliin upotetut palat, missä voi esimerkiksi kirjoittaa HTML:ää ja JavaScriptia, sekä tarkastella tulosta, on toteutettu JSFiddlen avulla.

Internet

Web on täynnä selainohjelmointiin liittyviä artikkeleita. Oikeasti! Googlehaku lauseella "html5 introduction" palauttaa hieman yli 30000 sivua (helmikuu 2015). Jos avainsanat ovat erikseen, tuloksia on lähes neljä miljoonaa. Kun teet kurssin tehtäviä, käytä googlea avuksi. Tätä materiaalia ei yritetäkään rakentaa kaiken kattavaksi, vaan joudut etsimään tietoa myös internetistä.

Jos mietit että miten vaikkapa article-elementille asetetaan reunat, voit googlettaa esimerkiksi avainsanoilla "html5 article css border". Ensimmäisen kymmenen artikkelin joukossa on (lähes) varmasti sinua auttava artikkeli. Itseasiassa, informaation hakeminen netistä on taito siinä missä ohjelmointikin -- sitä kannattaa ja pitää harjoitella.

HTML

HTML on kieli web-sivustojen luomiseen. HTML ei ole ohjelmointikieli, vaan kuvauskieli, jonka avulla kuvataan sekä web-sivun rakenne että sivun sisältämä teksti. HTML-sivujen rakenne määritellään HTML-kielessä määritellyillä elementeillä, ja yksittäinen HTML-dokumentti koostuu sisäkkäin ja peräkkäin olevista elementeistä.

Sivujen rakenteen määrittelevät elementit erotellaan pienempi kuin (<) ja suurempi kuin (>) -merkeillä. Elementti avataan elementin nimen sisältävällä pienempi kuin -merkillä alkavalla ja suurempi kuin -merkkiin loppuvalla merkkijonolla, esim. <html>, ja suljetaan merkkijonolla jossa elementin pienempi kuin -merkin jälkeen on vinoviiva, esim </html>. Yksittäisen elementin sisälle voi laittaa muita elementtejä.

Suurin osa elementeistä tulee sulkea lopuksi. Osa HTML5:n elementeistä – esimerkiksi <br> – on kuitenkin ns. tyhjiä ("void"), eikä niille kirjoiteta erillistä lopetusta. Halutessaan tyhjät elementit voi lopettaa X(HT)ML-tyyliseen /-merkkiin, esimerkiksi seuraavasti: <br />.

HTML-dokumentin runko

Tyypillisen HTML-dokumentin runko näyttää seuraavalta. Kun klikkaat allaolevassa iframe-elementissä Result-tekstiä, näet HTML-sivun, ja kun painat HTML-tekstiä, näet HTML-koodin. Klikkaamalla elementin oikeassa ylälaidassa olevasta Edit in JSFiddle-linkistä, pääset muokkaamaan elementtiä suoraan JSFiddlessä.

Yllä olevassa HTML-dokumentissa on dokumentin tyypin kertova erikoiselementti <!DOCTYPE html>, joka kertoo dokumentin olevan HTML-sivu. Tätä seuraa elementti <html>, joka aloittaa HTML-dokumentin. Elementti <html> sisältää yleensä kaksi elementtiä, elementit <head> ja <body>. Elementti <head> sisältää sivun otsaketiedot, eli esimerkiksi sivun käyttämän merkistön <meta charset="utf-8" /> ja otsikon <title>. Elementti <body> sisältää selaimessa näytettävän sivun rungon. Ylläolevalla sivulla on ensimmäisen tason otsake-elementti h1 (header 1) ja tekstielementti p (paragraph).

Elementit voivat sisältää attribuutteja, joilla voi olla yksi tai useampi arvo. Yllä olevassa HTML-dokumentissa elementille meta on määritelty erillinen attribuutti charset, joka kertoo dokumentissa käytettävän merkistön: "utf-8". Attribuuttien lisäksi elementit voivat sisältää tekstisolmun. Esimerkiksi yllä olevat elementit title, h1 ja p kukin sisältävät tekstisolmun eli tekstiä. Tekstisolmulle ei ole erillistä elementtiä tai määrettä, vaan se näkyy tekstinä.

Puhe tekstisolmuista antaa viitettä jonkinlaisesta puurakenteesta. HTML-dokumentit, aivan kuten XML-dokumentit, ovat rakenteellisia dokumentteja, joiden rakenne on usein helppo ymmärtää puumaisena kaaviona. Ylläolevan web-sivun voi esittää esimerkiksi seuraavanlaisena puuna (attribuutit ja dokumentin tyyppi on jätetty merkitsemättä).

                   html
               /          \
             /              \
          head              body
        /       \         /      \
     meta       title     h1      p
                 :        :       :
              tekstiä  tekstiä tekstiä

Koska HTML-dokumentti on rakenteellinen dokumentti, on elementtien sulkemisjärjestyksellä väliä. Elementit tulee sulkea samassa järjestyksessä kuin ne on avattu. Esimerkiksi, järjestys <body><p>whoa, minttutee!</body></p> on väärä, kun taas järjestys <body><p>whoa, minttutee!</p></body> on oikea.

Kaikki elementit eivät kuitenkaan sisällä tekstisolmua, eikä niitä suljeta erikseen. Yksi näistä poikkeuksista on link-elementti.

Kun selaimet lataavat HTML-dokumenttia, ne käyvät sen läpi ylhäältä alas, vasemmalta oikealle. Kun selain kohtaa elementin, se luo sille uuden solmun. Seuraavista elementeistä luodut solmut menevät aiemmin luodun solmun alle kunnes aiemmin kohdattu elementti suljetaan. Aina kun elementti suljetaan, puussa palataan ylöspäin edelliselle tasolle.

Ascii Artist (1p)

Huom! Jos NetBeans ei suostu avaamaan tehtäviä ja valittaa esimerkiksi "failed to download exercises", varmista että käytössäsi on HTML5-tuki. Tämän tuen saa ladattua NetBeansin Tools->Plugins -valikosta. Ladattu liitännäinen aktivoituu viimeistään kun luot uuden HTML5-projektin (File -> New Project -> HTML5 -> ...).

Tehtäväpohjassa olevassa kansiossa src (tai Site Root) on dokumentti index.html. Muokkaa dokumenttia siten, että sen katsominen selaimessa näyttää seuraavannäköisen ASCII-taideteoksen (käytettävän fontin ei tarvitse olla sama).

käytä pre-elementtiä tekstimuotoisen taideteoksen luomiseen

Huom! Yllä näkyvän kuvakaappauksen ympärille asetettuja reunoja ei tarvitse piirtää omaan sivuun.

Koska taideteos on ASCII-taidetta, et luonnollisestikaan saa käyttää sivussa kuvaa. Vinkki taideteoksen tekemiseen on yllä olevassa kuvassa. Kun taideteoksesi toimii Chromessa, palauta tehtävä TMC:lle.

Listaelementit

Sivuille voi lisätä listoja mm. ol (ordered list) ja ul (unordered list) -elementtien avulla. Elementeillä ol tai ul aloitetaan lista, ja listan sisälle asetettavat yksittäisiin listaelementteihin käytetään li (list item)-elementtiä. Yksittäiset listaelementit voivat taas sisältää esimerkiksi tekstisolmun tai lisää html-elementtejä.

Kuvien lisääminen

Jokaisen web-sivuja rakentavan ihmisen tulee ainakin kerran elämässään lisätä kuva web-sivuilleen. Sivuille saa lisättyä kuvia elementillä img, jolla on attribuutti src, jonka arvona on kuvan sijainti. Kuvan sijainti riippuu kuvan näyttävän html-tiedoston sijainnista. Jos kuva on samassa kansiossa html-dokumentin kanssa, tarvitsee img-elementin src-attribuutin arvoksi asettaa vain kuvan nimi.

Esimerkiksi, jos tämän html-tiedoston sisältämässä kansiossa on kansio nimeltä "img", ja siellä kuvatiedosto nimeltä "lamppu.png", saa kuvatiedoston sivuille näkyville elementillä <img src="img/lamppu.png" />. Koska kuvaelementti img ei sisällä muita elementtejä tai tekstiä, voi sen sulkea suoraan.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Selaimen palkissa ja suosikeissa näkyvä otsikko</title>
    </head>
    <body>

        <h1>Sivulla näkyvä otsikko</h1>

        <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä,
        listaelementtiä voi käyttää esimerkiksi ostoslistan tekemiseen.</p>

        <ol>
            <li>kauraa</li>
            <li>puuroa</li>
            <li>omenaa</li>
        </ol>

        <p>Kuvan saa taas näytettyä img-elementillä. Välähtikö?</p>

        <img src="img/lamppu.png" />

    </body>
</html>

Kuva ilman muita sivujen elementtejä näyttää seuraavalta.

Kuvien oikeuksista

Netissä olevat kuvat ja tiedostot eivät ole vapaasti kaikkien käytettävissä. Jos teet sivuja itsellesi, tutuille tai kavereille, ja käytät niissä netistä löytynyttä materiaalia, muista varmistaa että käyttämäsi kuvat ovat laillisesti käytettäviä. Kuvien käyttöoikeuksien varmistaminen ei ole aina helppoa tai edes mahdollista -- kannattaakin käyttää vain sivustoja, joiden oikeuksista on varmuus.

Esimerkiksi flickr-sivustolla on erillinen creative commons-osio, joka listaa kuvia, joiden käyttö on sallittua tietyin ehdoin. Löydät eri ehdot ja kuvia osoitteesta http://www.flickr.com/creativecommons/. On myös sivuja, jotka tarkoituksella keräävät materiaalia tiettyihin aiheisiin liittyen. Esimerkiksi sivusto OpenGameArt tarjoaa vapaasti peleissä käytettäviä materiaaleja.

Kuvat ja käytettävyys

Riippuen käytössä olevasta laitteesta, sen asetuksista, ja lukijasta, kuvat eivät näy aina toivotulla tavalla. Sivuston käytettävyyttä voi helpottaa huomattavasti lisäämällä kuvaelementteihin alt-attribuutti, millä kerrotaan tekstuaalisesti mitä kuvassa on.

Tällöin, jos kuvat eivät näy käyttäjälle, voi hän kuitenkin lukea kuvaan liittyvän kuvauksen, mikä mahdollisesti selkiyttää sivun ymmärrettävyyttä.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Selaimen palkissa ja suosikeissa näkyvä otsikko</title>
    </head>
    <body>

        <p>...</p>

        <img src="img/lamppu.png" alt="Kuva lampusta" />

    </body>
</html>

Linkit toisille sivuille

Elementin a (anchor) avulla voi luoda linkin sivulta toiselle. Sivu, jolle käyttäjä siirtyy, merkitään elementin a attribuutin href arvolla. Jos sovelluksessasi on kaksi sivua, index.html ja oma.html, voi sivulta oma.html luoda linkin sivulle index.html komennolla <a href="index.html">index.html</a>.

Sivulta voi lisätä myös linkin täysin toiselle sivulle ja linkki-elementeille voi lisätä myös attribuutin target, jolla voi ilmaista tietyn ikkunan, johon sivu avataan. Jos attribuutille target antaa arvon "_blank", avataan linkki aina uuteen ikkunaan.

Alla olevassa esimerkissä on kaksi linkkiä YouTube-sivustolle. Ensimmäinen linkki avaa linkin tässä ikkunassa, toinen linkki avaa linkin erillisessä selainikkunassa.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Selaimen palkissa ja suosikeissa näkyvä otsikko</title>
    </head>
    <body>

        <p>Linkkejä saa lisättyä a-elementillä: <a href="https://www.youtube.com/watch?v=oT3mCybbhf0">klikkaamalla
        liityt miljoonien joukkoon.</a></p>


        <p>Linkkejä saa lisättyä a-elementillä: <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ" target="_blank">klikkaamalla
        liityt kymmenien miljoonien joukkoon.</a></p>

    </body>
</html>

Yllä olevan sivun viimeinen tekstielementti näyttää seuraavalta:

Linkkejä saa lisättyä a-elementillä: klikkaamalla liityt miljoonien joukkoon.

Linkkejä saa lisättyä a-elementillä: klikkaamalla liityt kymmenien miljoonien joukkoon.

HTML5 ja apuvälineet sivun rakenteen määrittelyyn

HTML5, tuttavallisemmin HTML, toi mukanaan sivun rakenteen suunnittelua helpottavia elementtejä. Sivun rakenteen määrittelyä helpottavat elementit header, jonka sisälle kirjoitetaan sivun yleinen alkuosa kuten h1-elementti ja valikko, nav, joka sisältää sivun valikon, section-elementti, joka esimerkiksi nivoo yhteen toisiinsa liittyviä osia, article, joka sisältää yksittäisen sivulla olevan dokumentin, ja footer, joka kertoo sivun loppuosan. Näiden avulla sivun saa jaettua loogisiin osakokonaisuuksiin.

Rakennetta helpottavien elementtien käyttö ja toiminta liittyy elementtiin, jonka sisällä ne ovat. Jos elementtiä header käytetään elementin article sisällä, on header luonnollisesti artikkelin otsaketiedot. Jos taas header-elementti on body-elementin sisällä, liittyy header-elementin sisältö itse sivuun.

Sivut koostuvat yleensä header-elementillä merkittävästä yläosasta, jossa on otsikko ja mahdollisesti nav-elementillä merkitty valikko. Näitä seuraa yksi tai useampi tekstiosa, joka merkitään article-elementillä. Sivun lopussa on elementti footer, joka sisältää esimerkiksi yhteystiedot.

Seuraavassa on esimerkki, jossa h1-otsikko on asetettu header-elementin sisään. Sivulla on kaksi erillistä kirjoitusosaa, jotka on eroteltu article-elementeillä. Näitä seuraa lopulta footer-elementillä merkitty alaosa.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>

        <header>
            <h1>Sivulla näkyvä otsikko</h1>
        </header>

        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
        </article>

        <article>
            <ol>
                <li>kauraa</li>
                <li>puuroa</li>
                <li>omenaa</li>
            </ol>
        </article>

        <footer>
            <p>alatunniste, esimerkiksi tekijöiden nimet.</p>
        </footer>
    </body>
</html>

Sivulla näkyvä otsikko

Sivuilla näytettävä normaali teksti on p-elementin sisällä.

  1. kauraa
  2. puuroa
  3. omenaa

alatunniste, esimerkiksi tekijöiden nimet.

Rakenteellinen lähestymistapa sivujen sisällön määrittelyyn

HTML-kuvauskieltä käytetään sivujen rakenteen määrittelyyn. Ennen article, section, ym. elementtejä, tapana oli erotella sivun alueita toisistaan div (divider)-elementeillä. Div-elementille määriteltiin tyyppi (class), joka kuvasi sivun osaa, jonka div-elementti sisälsi. Yllä olevan sivun rakenne voidaan luoda myös div-elementeillä.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>

        <div class="header">
            <h1>Sivulla näkyvä otsikko</h1>
        </div>

        <div class="article">
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
        </div>

        <div class="article">
            <ol>
                <li>kauraa</li>
                <li>puuroa</li>
                <li>omenaa</li>
            </ol>
        </div>

        <div class="footer">
            <p>alatunniste, esimerkiksi tekijöiden nimet.</p>
        </div>
    </body>
</html>

Huomannet että ero on käytännössä hyvin pieni. Oleellisinta on loogisten osakokonaisuuksien erottelu toisistaan.

Kampuskuoro (1p)

Luo tehtäväpohjassa olevaan kansioon src (tai Site Root) uusi sivu index.html. Muokkaa sivua siten, että se näyttää seuraavalta selaimessa:

Otsikon tulee olla header-elementin sisällä, kuvaus ja laululista omien article-elementtien sisällä. Ei haittaa jos tekstin leveys on eri kuin yllä olevassa kuvassa! Kun olet valmis, ja sivusi näyttää oikealta Chromessa, palauta tehtävä TMC:lle.

Lomakkeet

Lomakkeita käytetään tiedon syöttämiseen web-sivuille. Tietoa voi lähettää joko erilliselle palvelimelle, tai käsitellä osana sivustoa JavaScript-kielen avulla. Lomakkeet aloitetaan HTML-elementillä <form>, jonka sisälle voi asettaa useita erilaisia kenttiä. Palvelimelle dataa lähetettäessä jokaisella kentällä tulee olla attribuutti name, jonka perusteella palvelinohjelmisto osaa erotella lähetettävän datan toisistaan.

Erilaisia lomakekenttiä on useita:

Lomakkeen lähettäminen

Kun lomake lähetetään selain ohjaa käyttäjän form-elementissä olevan action-attribuutin määrittelemään osoitteeseen. Pyynnössä lähetetään lomakkeeseen kirjoitetut tiedot. Jos lomakkeen lähetystapa on GET, liitetään lomakkeen tiedot osaksi osoitetta. Lähetystavassa POST arvot tulevat osana pyynnön runkoa.

Lisätietoa..

Alla oleva lomake lähettää lomakkeen tiedot tälle sivulle GET-pyyntönä, eli pyynnön tiedot lisätään osaksi haettavaa osoitetta.

<form method="GET" action="index.html">
    <label>Käyttäjätunnus: <input type="text" name="tunnus" /></label>
    <label>Salasana: <input type="password" name="salasana" /></label>
    <input type="submit" />
</form>

CSS

CSS (cascading style sheets)-tyylitiedostot ovat tiedostoja, joissa määritellään miten web-sivun elementit tulee näyttää käyttäjälle. HTML-kuvauskielellä määritellään web-sivun rakenne ja sisältö, tyylitiedostoilla sen ulkoasu.

Tyylitiedostoilla voi tehdä ison eron siihen, miltä sivu näyttää. Voit kokeilla tätä itse avaamalla minkä tahansa mielestäsi hienolta näyttävän sivun, avata selaimen Developer Tools-työvälineet (painamalla F12), valitsemalla elements-välilehti, ja poistamalla kaikki css-tiedostoviittaukset. Tiedostoviittaukset voi poistaa elements-välilehdeltä klikkailemalla niitä oikealla hiirennapilla ja valitsemalla delete.

Lähdetään kuitenkin pienestä liikkeelle. Tyylitiedosto on HTML-dokumentista erillinen tiedosto, joka sisältää erilaisia tyylimäärittelyjä. Tyylitiedostoja voi olla useita. Jotta HTML-dokumentti saa tyylitiedoston käyttöönsä, tulee tyylitiedoston sijainti määritellä head-elementin sisälle tyylitiedoston lataavaan elementtiin link.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <link rel="stylesheet" type="text/css" href="stylesheets/style.css">
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>

        <!-- sivun sisältö: näin sivuille saa kommentin -->

    </body>
</html>

Elementille link kerrotaan viitattavan tiedoston tyyli (rel="stylesheet"), tyyppi (type="text/css") ja sijainti (href="sijainti.css"). Sijainnin tulee kertoa tyylitiedoston nimi. Tyylitiedostojen päätteenä käytetään merkkijonoa .css. Esimerkiksi jos tyylitiedosto tämän tiedoston sisältämän kansion sisällä olevassa kansiossa "stylesheets" ja tyylitiedoston nimi on style.css, asetetaan elementin link attribuutin href arvoksi "stylesheets/style.css".

Tyylitiedosto

Tyylitiedosto on tyypillisesti erillinen tiedosto. Luodaan tiedosto style.css, jonka sisältö on seuraavanlainen:

body {
    background-color: rgb(200, 200, 200);
    margin: 0;
    padding: 0;
}

Yllä olevassa tyylitiedostossa sanotaan, että elementin body (eli HTML-dokumentin rungon) taustaväri on rgb-arvolla kerrottuna 200, 200, 200, eli vaaleahko. Väriarvo rgb tulee sanoista red, green, ja blue, ja jokaisella arvolla kerrotaan värin määrän. Kunkin värin määrä ilmaistaan numerolla nollan ja 255 välillä. Jos jokaisen värin arvo on 0, on väri musta, ja jos jokaisen värin arvo on 255, on väri valkoinen.

Kukin tyylimäärittely alkaa tyyliteltävän elementin kertovalla valitsimella ja avaavalla aaltosululla {, joita seuraa listaus käytettävistä tyyleistä. Kun käytettävät tyylit on määritelty, tyylimäärittely lopetetaan sulkevalla aaltosululla }. Kullakin tyylillä on nimi ja arvo, jotka erotetaan toisistaan kaksoispisteellä :. Yksittäisen tyylin (nimi ja arvo) jälkeen lisätään puolipiste ;. Yhteen tyylimäärittelyyn voi sisällyttää useita tyylejä, ja yksittäinen tyyli voi riippuen tyylistä saada useita arvoja.

valitsin {
    tyylin-nimi: tyylin-arvo;
    toisen-tyylin-nimi: arvo toinen-arvo;
}

Tyylimäärittely voi myös sisältää useita tyyliteltäviä elementtejä, tällöin valitsimet erotellaan toisistaan pilkulla.

valitsin, valitsin2 {
    tyylin-nimi: tyylin-arvo;
    toisen-tyylin-nimi: arvo toinen-arvo;
}

Valitsimia voi käyttää myös suorien lasten valintaan, esimerkiksi seuraavassa valitaan ensimmäisen valitsimen joukosta toisen valitsimen tyyppiset suorat lapset, mutta ei niiden lapsia.

valitsin > valitsin2 {
    tyylin-nimi: tyylin-arvo;
    toisen-tyylin-nimi: arvo toinen-arvo;
}

Vastaavasti myös kaikki elementin alla olevat toiset elementit voi myös valita. Seuraavassa valitaan ensimmäisen valitsimen joukosta kaikki toisen valitsimen tyyppiset lapset ja lapsenlapset.

valitsin valitsin2 {
    tyylin-nimi: tyylin-arvo;
    toisen-tyylin-nimi: arvo toinen-arvo;
}

Valitsimet

Jokaiselle sivun elementille voidaan määritellä oma tyyli. Jos halutaan että sama tyyli esiintyy kaikissa elementeissä, voidaan valitsimena käyttää elementin nimeä. Esimerkiksi seuraava tyylitiedosto muuttaa kaikkien p-elementtien tekstin värin punaiseksi.

p {
    color: rgb(255, 0, 0);
}

Luokka-attribuutti

Silloin tällöin tyyli halutaan asettaa vain tietylle elementille tai elementtijoukolle. Elementeille voidaan määritellä luokka-attribuutti class, jonka arvoksi asetetaan joku tietty arvo, esimerkiksi "blue". Luokka-attribuuttien tyylit voi asettaa erillisellä class-attribuutteja valitsevalla valitsimella, pisteellä. Esimerkiksi kaikki elementit, joilla on luokka-attribuutin arvo "blue" voi valita seuraavasti (kaikille asetetaan alla tekstin väriksi sininen):

.blue {
    color: rgb(0, 0, 255);
}

Itse sivulla olevat elementit näyttävät seuraavalta luokka-attribuutin kanssa. Osalla elementeistä on luokka-attribuutti "blue", ja osalla ei.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <link rel="stylesheet" type="text/css" href="stylesheets/style.css">
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>

        <header>
            <h1>Sivulla näkyvä otsikko</h1>
        </header>


        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
            <p class="blue">Kuten edellinen elementti, mutta tällä on luokka-attribuutti, jonka arvo on "blue".
            Tyylimäärittely .blue asettaa tekstin värin siniseksi.</p>
        </article>

    </body>
</html>

Sivulla näkyvä otsikko

Sivuilla näytettävä normaali teksti on p-elementin sisällä.

Kuten edellinen elementti, mutta tällä on luokka-attribuutti, jonka arvo on "blue". Tyylimäärittely .blue asettaa tekstin värin siniseksi.

Tunnus-attribuutti

Luokka-attribuuttia käytetään joukolle tyylejä. Yksittäisiä elementtejä tyyliteltäessä tapana on käyttää erillistä tunnus-attribuuttia, joka määritellään nimellä id. Kuten luokka-attribuutille, myös tunnus-attribuutille asetetaan arvo. Tunnus-attribuuttiin pääsee käsiksi tyylitiedostossa risuaita (#) -etuliitteellä. Luodaan sivu, jossa on useampia artikkeleita, joista yhtä halutaan korostaa.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <link rel="stylesheet" type="text/css" href="stylesheets/style.css" >
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>
        <header>
            <h1>Sivulla näkyvä otsikko</h1>
        </header>


        <article id="highlighted-article">
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
        </article>

        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
        </article>

        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä.</p>
        </article>

    </body>
</html>

Luodaan sivu siten, että artikkeleilla on pyöreät kulmat ja vaaleahko taustaväri. Korostettavalla artikkelilla on oma tyylinsä, jossa sille asetetaan hieman vaaleampi taustaväri.

article {
    background-color: rgb(200, 200, 200);
    margin: 1em;
    padding: 1em;
    width: 30%;

    border-radius: 10px;
}

#highlighted-article {
    width: 55%;
    background-color: rgb(240, 240, 240);
    transform: rotate(-2deg);
}

Sama JSFiddlessä karsittuna siten, että HTML:ssä vain dokumentin runko.

Tyylien vaikutus elementteihin

Edellä olevassa esimerkissä tyyli #highlighted-article ei sisältänyt reunojen pyöristystä, mutta silti korostetun artikkelin reunat pyöristettiin silti. Miksi?

Elementti käyttää kaikkia sille määriteltyjä tyylejä. Esimerkiksi tyyli #highlighted-article on osana article-elementtiä, jolla taas on siihen liittyvä tyyli. Mielenkiintoinen kohta liittyy artikkelin leveyteen (tyylimäärittely width): tyylissä #highlighted-article oleva määrittely width korvaa tyylissä article määritellyn leveyden. Jos leveyttä ei olisi korvattu #highlighted-article-tyylissä, olisi leveys peritty article-elementille määritellystä tyylistä.

Koska HTML-dokumentti on puu, voi tyylien periytymistä ajatella myös puumaisena periytymisenä. Jos elementille body määritellään tietynlainen tyyli, on kaikilla sen lapsisolmuilla body-elementissä määritelty tyyli, ellei lapsisolmu korvaa tyyliä.

Kaikki tyylit eivät kuitenkaan periydy. Tutustu tarkemmin tyylimäärittelyihin ja standardiin osoitteessa http://www.w3.org/TR/CSS/#properties.

CSS Askeleet (2p)

Luo tehtäväpohjan kansioon src tyylitiedosto style.css, ja viittaa siihen tiedostosta index.html. Muokkaa tyylitiedostoa style.css siten, että sivu index.html näyttää seuraavanlaiselta.

Tässä muutama hyödyllinen väri:

  • rgb(242, 242, 242)
  • rgb(155, 155, 155)
  • rgb(10, 10, 10)
  • rgb(252,179,21)

Sivu käyttää seuraavaa määrittelyä fontin valintaan..

    font-family: 'Trebuchet MS', Trebuchet, Arial, sans-serif;

Kun olet valmis, ja sivusi näyttää oikealta Chromessa, palauta tehtävä TMC:lle.

Lohko- ja sisäelementit

HTML-sivuilla näkyvät elementit voidaan jakaa karkeasti kahteen tyyppiin: lohkoelementteihin (block-level element) ja sisäelementteihin (inline element). Lohkoelementtien ja sisäelementtien ero on se, että lohkoelementit luovat HTML-sivulle suorakulmaisen alueen, jota edeltää ja seuraa rivinvaihto. Esimerkiksi <p>-elementti on lohkoelementti.

Sisäelementit taas eivät luo rivinvaihtoa niitä ennen ja niiden jälkeen. Esimerkiksi <span>-elementti on sisäelementti.

Se, että onko elementti lohko- vai sisäelementti, määräytyy oletustyylin perusteella. Lohkoelementeillä on display-tyylin arvona block kun taas sisäelementeillä display-tyylin arvona on inline. Alla esimerkki em. tyylien käytöstä.

Case: Listojen tyylit

Tyylitiedostoilla saa muokattua oikeastaan kaikkea sivulla olevaa. Listalle voi asettaa taustavärin, ja siitä voi poistaa numeroinnin tai pallot. Luodaan tyyliluokka menu, jossa listan taustaväri on vaalean harmaa, ja listan palloja ei näytetä.

.menu {
    /* listan tausta on harmaa */
    background-color: rgb(230, 230, 230);

    /* ei näytetä palloja */
    list-style-type: none;

    /* lisätään reunoille hieman tilaa (1em = 1 standardimerkin leveys) */
    padding: 1em;
}
  <ul class="menu">
    <li>Eka pallukka</li>
    <li>Toka pallukka</li>
    <li>Kolmas pallukka</li>
  </ul>

Nyt lista näyttää seuraavalta:

Muutetaan listan elementtejä siten, että ne asetellaan vierekkäin. Luodaan tyyliluokka menuelement, joka asettaa listaelementit vierekkäin, ja lisää niille hieman tilaa sivuille.

.menuelement {
    /* 1 standardimerkin leveyden verran tilaa jokaiselle puolelle */
    padding: 1em;
    /* näytetään menuelementit vierekkäin */
    display: inline;
}

Lisätään listan elementeille tyyliluokka listaelementti.

  <ul class="menu">
    <li class="menuelement">Eka pallukka</li>
    <li class="menuelement">Toka pallukka</li>
    <li class="menuelement">Kolmas pallukka</li>
  </ul>

Nyt lista näyttää seuraavalta:

Valikkoihin halutaan usein jonkinlaista dynaamista toiminnallisuutta. Lisätään toiminnallisuus, jossa vaihtoehdon taustaväri muuttuu kun sen päälle viedään hiiri. Valitsimen lisämääreellä :hover voidaan määritellä tyyli, joka näkyy vain kun hiiri on tyylitellyn alueen päällä. Lisätään toinen menuelement-tyyliluokka, ja sille lisämääre :hover.

.menuelement {
    /* 1 standardimerkin leveyden verran tilaa jokaiselle puolelle */
    padding: 1em;
    /* näytetään menuelementit vierekkäin */
    display: inline;
}

.menuelement:hover {
    /* vaaleampi taustaväri kun hiiri on tyylin päällä */
    background-color: rgb(245, 245, 245);
}

Tässä meidän ei tarvitse muokata HTML-dokumenttia, sillä tyyliluokka menuelement on jo määritelty HTML-dokumenttiin.

Noin 10 vuotta sitten pyöreät kulmat tehtiin erillisillä kuvilla. Ei enää! Pyöreät kulmat eivät ole vielä ihan helpon komennon takana, vaan niihin tarvitaan kolme erillistä komentoa. Erillisiä komentoja tarvitaan selainyhteensopivuuden varmistamiseksi: pyöreät kulmat määrittelevä standardi ei ole vielä lopullinen...). Pyöreät kulmat saa lisättyä tyyliluokkaan menu seuraavasti:

.menu {
    /* tämä on muuten kommentti, eli kone ei tee sillä mitään */
    /* listan tausta on harmaa */
    background-color: rgb(230, 230, 230);

    /* ei näytetä palloja */
    list-style-type: none;

    /* lisätään reunoille hieman tilaa (1em = 1 standardimerkin leveys) */
    padding: 1em;

    /* pyöreät kulmat */
    border-radius: 10px;
}

Lista näyttää nyt seuraavalta:

Case: Listojen tyylit, osa 2

Yllä oleva lähestymistapa, vaikkakin hieno, on hieman kömpelö. Jouduimme lisäämään jokaiselle tyyliteltävälle elementille luokkamäärittelyn. Tavoitteenamme on tyylitellä alla olevan sivun header-osio uudestaan. Huomaa jo nyt, että sivun header-osioon ei ole määritelty luokkia tai tunnuksia!

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <link rel="stylesheet" type="text/css" href="stylesheets/style.css" >
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>
        <header>
            <h1>Sivulla näkyvä otsikko</h1>

            <nav>
                <ul>
                    <li><a href="#">linkki</a></li>
                    <li><a href="#">linkki</a></li>
                    <li><a href="#">linkki</a></li>
                    <li><a href="#">linkki</a></li>
                </ul>
            </nav>

        </header>

        <!-- muu sisältö -->

    </body>
</html>

Sivulla näkyvä otsikko

Elementtien valinta dokumentin rakenteen perusteella

Toinen tapa valita tyyliteltäviä elementtejä liittyy niiden järjestykseen dokumentissa. Voimme tyylitellä header-elementin sisällä olevan h1-elementin vaaleammaksi. Huomaa että tämä tyylittely ei muuta kaikkia h1-elementtejä, vain vaan ne, jotka ovat header-elementin sisällä.

header h1 {
    color: rgb(120, 120, 120);
}

Sivulla näkyvä otsikko

Jatketaan esimerkkiä muokkaamalla header-elementissä olevan nav-elementin sisältämää listaa. Listan taustaväriksi asetetaan lähes musta, ja sillä on pyöristetyt kulmat.

header h1 {
    color: rgb(120, 120, 120);
}

header nav ul {
    /* fontin koon ym määrittelyä.. */
    font-size: 1.2em;
    height: 40px;
    line-height: 30px;
    margin: 0 auto 2em auto;

    /* taustaväri */
    background-color: rgb(40, 40, 40);

    /* ei näytetä palloja */
    list-style-type: none;

    /* pyöreät kulmat */
    border-radius: 10px;
}

Sivulla näkyvä otsikko

Ylläolevassa esimerkissä elementille ul on määritelty korkeudeksi 40 pikseliä. Listan elementit ovat kuitenkin lohkoelementtejä, joten jokaista edeltää ja seuraa rivinvaihto, ja listan elementit päätyvät niiden juurielementin ulkopuolelle. Se, että näkyykö tämä efekti, riippuu tosin myös selaimesta -- selaimilla on vapaus päättää, miten tyyli näytetään.

Lisätään listaelementeille määrittely display: inline, jossa ne asetetaan vierekkäin.

header h1 {
    color: rgb(120, 120, 120);
}

header nav ul {
    /* fontin koon ym määrittelyä.. */
    font-size: 1.2em;
    height: 40px;
    line-height: 30px;
    margin: 0 auto 2em auto;

    /* taustaväriksi h1-elementin taustaväri */
    background-color: rgb(120, 120, 120);

    /* ei näytetä palloja */
    list-style-type: none;


    /* pyöreät kulmat */
    border-radius: 10px;
}

header nav ul li {
    display: inline;
}

Sivulla näkyvä otsikko

Linkkielementtien tyylittely

Ei vieläkään komea, mutta selviämme tästä kyllä. Muutetaan linkkielementtien väri valkoiseksi, suurennetaan niiden fonttia, ja asetetaan niille hieman tilaa ympärille.

header h1 {
    color: rgb(40, 40, 40);
}

header nav ul {
    /* fontin koon ym määrittelyä.. */
    font-size: 1.2em;
    height: 40px;
    line-height: 30px;
    margin: 0 auto 2em auto;

    /* taustaväriksi h1-elementin taustaväri */
    background-color: rgb(40, 40, 40);

    /* ei näytetä palloja */
    list-style-type: none;

    /* pyöreät kulmat */
    border-radius: 10px;
}

header nav ul li {
    display: inline;
}

header nav ul li a {
    color: rgb(255, 255, 255);
    /* rivielementti, mutta suorakulmio */
    display: inline-block;
    padding: 5px 1.5em;
    text-decoration: none;
}

Nyt sivumme yläosa näyttää seuraavalta.

Sivulla näkyvä otsikko

Tyyli text-decoration: none; kertoo linkille, että sitä ei tule tyylitellä -- normaalisti linkeillä on sinertävä väri, ja ne on alleviivattu.

Hiiren liikkeeseen reagointi

Lisätään vielä linkkielementeille toiminnallisuus, jossa niiden taustaväri muuttuu kun hiiri viedään elementin päälle. Asetetaan taustaväri tällöin valkoiseksi, ja linkin fontti aiemmin käytetyksi tummaksi väriksi.

Tämä tapahtuu määrittelemällä siihen elementtiin, jonka halutaan reagoivan hiiren saapumiseen, tyyli, jossa on lisäksi määre :hover.

header h1 {
    color: rgb(40, 40, 40);
}

header nav ul {
    /* fontin koon ym määrittelyä.. */
    font-size: 1.2em;
    height: 40px;
    line-height: 30px;
    margin: 0 auto 2em auto;

    /* taustaväriksi h1-elementin taustaväri */
    background-color: rgb(40, 40, 40);

    /* ei näytetä palloja */
    list-style-type: none;

    /* pyöreät kulmat */
    border-radius: 10px;
}

header nav ul li {
    display: inline;
}

header nav ul li a {
    color: rgb(255, 255, 255);
    display: inline-block;
    padding: 5px 1.5em;
    text-decoration: none;
}

/* kun a-elementin päälle viedään hiiri, muutetaan sekä
   taustaväriä että tekstin väriä */
header nav ul li a:hover {
    background-color: rgb(255, 255, 255);
    color: rgb(40, 40, 40);
}

Sivulla näkyvä otsikko

Done! Tyylimaailman sopivuudesta jokainen saa toki päättää itse :). Alla sama vielä JSFiddlessä.

Suosikit (1p)

Tehtäväpohjassa on lista suosikkeja. Tehtävänäsi on lisätä sivulle tyyli, joka tekee sivusta seuraavannäköisen. Huomaa, että sivun artikkeli-elementtien tulee kääntyä hieman kun hiiri viedään niiden päälle.

Edellisessä tehtävässä määrittelemistäsi tyyleistä on hyötyä tässä tehtävässä. Kun olet valmis, ja sivusi näyttää oikealta Chromessa, palauta tehtävä TMC:lle.

JavaScript

Siinä missä HTML on kuvauskieli web-sivujen rakenteen ja sisällön luomiseen ja CSS on kieli web-sivustojen tyylin määrittelyyn, JavaScript on kieli dynaamisen toiminnan lisäämiselle. Käytännössä JavaScript on ohjelmakoodia, jota suoritetaan tarvittaessa komento kerrallaan -- ylhäältä alas, vasemmalta oikealle.

Ensimmäinen komento jonka opimme on alert("tulostettava merkkijono"). Funktio alert("jotain") on JavaScriptin valmis funktio, ja se avaa uuden pop-up -ikkunan jossa näkyy funktion alert parametrina saama arvo. Funktiolle alert voi antaa parametrina oikeastaan minkälaisia arvoja tahansa. Voit testata alert-funktion toimintaa alla olevassa laatikossa. Painamalla nappia "Suorita koodi!", selaimesi suorittaa laatikossa olevan koodin.

Ohjelmakoodia suoritettaessa selain käy läpi laatikossa olevat komennot yksi kerrallaan ylhäältä alas, vasemmalta oikealle, ja toimii niiden mukaan. JavaScript-koodi suoritetaan siis omalla koneellasi omassa selaimessasi. Kukin komento loppuu puolipisteeseen (;), ja komentoja voi olla useampia. Mitä käy jos muutat alert-komennossa olevaa tekstiä, tai lisäät useamman alert-komennon?

Komennon alert yhteydessä on hyvä kertoa heti komennosta console.log, minkä avulla voidaan kirjoittaa selaimen JavaScript-konsoliin viestejä. Käynnistä selaimen debug-työvälineet (F12) ja valitse käyttöön Console. Kirjoita tämän jälkeen yllä olevaan laatikkoon komento console.log("hello world"); ja paina "Suorita koodi!"-nappia. Komennolle console.log annettu teksti ilmestyy konsoliin tarkasteltavaksi -- tätä voi käyttää mm. oman koodin debuggauksessa.

Funktiot

Jos ylläoleva koodi -- alert("heippa!"); asetetaan javascript-lähdekooditiedostoon, se suoritetaan heti kun selain lataa tiedoston. Haluamme kuitenkin usein siirtää koodin suoritusta tulevaisuuteen, ja kiinnittää se esimerkiksi johonkin sivulla tapahtuvaan tapahtumaan.

Funktioilla määritellään ohjelmakoodia, joka suoritetaan myöhemmin.

Funktio määritellään merkkijonolla function funktionNimi() { suoritettava koodi}. Ensin avainsana function, joka kertoo että seuraavaksi on tulossa funktion määrittely. Tätä seuraa funktion nimi ja sulut, joita seuraa aukeava aaltosulku {. Aaltosulun jälkeen tulee funktiota kutsuttaessa suoritettava ohjelmakoodi, jonka jälkeen tulee sulkeva aaltosulku }.

Alla on määritelty funktio, joka kysyy käyttäjältä nimeä, tallentaa nimen muuttujaan nimi, ja lopulta käyttää funktiota console.log nimen tulostamiseen selaimen JavaScript-konsoliin.

Kun painat nappia "Suorita koodi!", huomaat ettei mitään tapahdu :(.

Tämä johtuu siitä, että funktiota ei kutsuta mistään, eli funktion suorittamista ei pyydetä. Lisätään funktiokutsu. Funktion kutsuminen onnistuu sanomalla funktion nimi ja sulut, sekä puolipiste. Esimerkiksi funktionNimi();. Alla olevassa koodissa on sekä funktion kysyNimiJaTervehdi määrittely, että funktion kysyNimiJaTervehdi kutsu.

JavaScriptin lisääminen omille sivuille

Aivan kuten CSS-tyylimäärittelyt, JavaScript-lähdekoodit kannattaa erottaa HTML-dokumentista.

JavaScript-tiedoston pääte on yleensä .js ja siihen viitataan elementillä script. Elementillä script on attribuutti src, jolla kerrotaan lähdekooditiedoston sijainti. Jos lähdekoodi on kansiossa javascript olevassa tiedostossa code.js, käytetään script-elementtiä seuraavasti: <script src="javascript/code.js"></script>. Huomaa että script-elementti suljetaan poikkeuksellisesti erikseen vaikka se ei sisälläkään tekstiä.

Yleinen käytänne JavaScript-lähdekoodien sivulle lisäämiseen on lisätä ne sivun loppuun. Tämä johtuu mm. siitä, että selain lähtee hakemaan JavaScript-tiedostoa kun se kohtaa sen määrittelyn HTML-dokumentissa, jolloin kaikki muut toiminnot odottavat latausta odottamaan. Jos lähdekooditiedosto ladataan vasta sivun lopussa, käyttäjälle näytetään sivun sisältöä jo ennen Javascript-lähdekoodin latautumista, sillä selaimet usein näyttävät sivua käyttäjälle sitä mukaa kun se latautuu. Tällä luodaan tunne nopeammin reagoivista ja latautuvista sivuista.

Luodaan kansioon javascript lähdekooditiedosto code.js. Tiedostossa code.js on funktio sayHello, joka tulostaa konsoliin viestin "BAD = browser application development".

function sayHello() {
    console.log("BAD = browser application development");
}

HTML-dokumentti, jossa lähdekooditiedosto ladataan, näyttää seuraavalta:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <link rel="stylesheet" type="text/css" href="stylesheets/style.css">
        <title>Sivun otsikko (näkyy selaimen palkissa)</title>
    </head>
    <body>
        <header>
            <h1>Sivulla näkyvä otsikko</h1>
        </header>

        <article>
            <p>Sivuilla näytettävä normaali teksti on p-elementin sisällä. Alla on nappi,
            jota painamalla kutsutaan funktiota "sayHello".</p>
            <input type="button" value="Tervehdi" onclick="sayHello();" />
        </article>

        <!-- ladataan JavaScript-koodit tiedoston lopussa! -->
        <script src="javascript/code.js"></script>

    </body>
</html>

Itse sivu näyttää seuraavalta:

Sivulla näkyvä otsikko

Sivuilla näytettävä normaali teksti on p-elementin sisällä. Alla on nappi, jota painamalla kutsutaan funktiota "sayHello".

Sama JSFiddlessä siten, että javascript-tiedosto on osana sivua:

Mitä eläin sanoo? (1p)

Tehtäväpohjassa tulevalla sivulla on kolme erillistä eläintä. Luo tehtäväpohjaan erillinen javascript-tiedosto, ja lataa se sivun käyttöön. Lisää toiminnallisuus, jonka avulla lehmän nappia painettaessa käyttäjälle tulostetaan viesti "muu muu", porsaan nappia painettaessa "nöf nöf", ja kanan nappia painettaessa "kot kot".

Varmista sivusi toiminnallisuus. Kun olet valmis, palauta tehtävä TMC:lle.

JavaScriptin alkeita

Tässä osiossa käydään pikaisesti läpi JavaScriptin alkeita.

Muuttuja

Jotta sama tieto olisi käytössä useammassa paikassa, tarvitsemme jonkun tavan tallentaa tietoa. Javascriptissä, kuten lähes kaikissa muissakin ohjelmointikieliessä, tähän käytetään muuttujia. Muuttujat esitellään sanomalla var muuttujanNimi, eli ensin sana var, jota seuraa nimi muuttujalle. Tämän jälkeen seuraa yhtäsuuruusmerkki, jota seuraa muuttujaan asetettava arvo, esimerkiksi var vitonen = 5;. Edellinen komento luo muuttujan vitonen, ja asettaa siihen arvon 5.

Alla olevassa koodissa asetamme ensin muuttujaan kolme arvon 3, ja kutsumme aiemmin näkemäämme console.log-komentoa siten, että console.log-komento saa parametrina muuttujassa kolme olevan arvon.

Olemassaoleviin muuttujiin voi sijoittaa uuden arvon. Seuraava koodi näyttää ensin numeron 3, ja sitten numeron 4. Huomaa että sana var esiintyy vain kun muuttuja esitellään ensimmäisen kerran. Tämän jälkeen muuttuja on jo olemassa ja sanaa var ei enää tarvita.

Muuttujia voi myös laskea yhteen. Koulussa opittu pluslasku toimii kuten tähänkin mennessä.

Yllä luodaan ensin muuttuja kolme, ja asetetaan siihen arvo 3. Tämän jälkeen luodaan uusi muuttuja nimeltä nelja, ja asetetaan siihen arvo 4. Tämän jälkeen luodaan uusi muuttuja seitseman, ja asetetaan siihen aiemmin määriteltyjen muuttujien (kolme ja nelja) arvojen summa.

Muuttujien tyypit

JavaScript, toisin kuin peruskursseillamme käytetty Java, ei ole vahvasti tyypitetty ohjelmointikieli. Tämä tarkoittaa sitä, että ohjelmointikieli ei rajoita muuttujissa käytettävien arvojen tyyppiä. Muuttujan tyyppi voi olla numero, merkkijono tai vaikkapa funktio (palaamme tähän myöhemmin...). Muuttujaan voi siis asettaa myös merkkijonon. Merkkijono aloitetaan ja lopetetaan hipsuilla ("").

Merkkijonojen yhdistäminen onnistuu plus-merkillä Javasta tutulla tavalla.

Yllä muuttujaan summa asetetaan muuttujien eka ja toka liitos, eli merkkijono "23".

Merkkijonon muuntaminen numeroksi

Jos merkkijonot haluaa muuttaa luvuiksi, tulee muunnoksessa käyttää JavaScriptin parseInt-funktiota. Komento parseInt muuttaa parametrina saadun merkkijonon kokonaisluvuksi.

Arvojen vertaileminen ja lisää tyypeistä

Ohjelmiin tuodaan vaihtoehtoista toiminnallisuutta muuttujien ja vertailuoperaatioiden yhteistyöllä. Luodaan ohjelma, joka kysyy käyttäjältä numeroa. Jos käyttäjän antama numero on 5, sanotaan "Oikein meni!". Vertailu onnistuu if-lauseella ja kahdella yhtäsuuruusmerkillä. Koska funktio prompt palauttaa merkkijonon, muunnetaan saatu luku numeroksi JavaScriptin parseInt-funktiolla.

Jos haluamme sanoa "Oikein meni!" kun käyttäjä antaa numeron 5 tai 7, voimme tehdä erillisen else if-vertailun. Vertailu else if tulee aina vertailun if jälkeen, ja vertailua else if ei voi käyttää ilman if-vertailua.

Lauseita else if voi olla peräkkäin rajoittamaton määrä. Ohjelmakoodissa voi käyttää myös Javasta tuttuja || (tai) ja && (ja) -operaatioita vertailutulosten yhdistämiseksi.

Joskus haluamme tulostaa jotain, vaikka yksikään vertailu ei onnistuisi. Tällöin käytämme ehtoa else, joka tarkoittaa "muutoin". Lisätään yllä olevaan luvun tarkistamiseen else-lause, joka suoritetaan kun yksikään aiemmista ehdoista ei onnistunut.

Muutkin Javasta tutut vertailuoperaatiot ovat käytössä. Suurempi kuin > merkillä tarkistetaan onko luku suurempi kuin joku luku, ja pienempi kuin < merkillä tarkistetaan onko luku pienempi kuin joku luku. Tehdään ohjelma, joka kysyy ikää, ja sanoo "Huijaat!", jos ikä on pienempi kuin 0 tai suurempi kuin 120. Muulloin ohjelma sanoo "Et huijannut!" .

Dynaaminen tyypitys ja vertailuoperaatiot

Yllä olemme vertailleet juurikin muuttujien arvoja. Koska JavaScript on dynaamisesti tyypitetty kieli, voi muuttujan tyyppi muuttua kun siihen asetetaan uusi arvo.

Vertailuoperaattorin == eräs päänvaivaa tuottava ominaisuus on se, että vertailuoperaatio ei välitä muuttujan tyypistä. Esimerkiksi seuraava vertailu tulostaa viestin "Ehdottomasti totta!".

Tyhjä merkkijono vastaa siis totuusarvoa false.

Muuttujan tyypin huomioiva vertailu

Jotta vertailussa otettaisiin huomioon myös muuttujan sen hetkinen tyyppi, käytetään vertailuoperaatiossa ylimääräistä yhtäsuuri kuin -merkkiä. Jos vertailu tehdään kolmella yhtäsuuruusmerkillä (===) tarkistaa JavaScript sekä muuttujan arvon että muuttujan tyypin.

Sama pätee myös erisuuri kuin (!==) -vertailulle.

Toistolauseet

JavaScriptissä on käytössä for- ja while-toistolauseet.

Funktiot, parametrit, ja arvon palauttaminen

Funktioille voi antaa arvoja, joita voidaan käyttää osana funktion lähdekoodia. Tämä on kätevää erityisesti silloin, kun samanlaista toiminnallisuutta tehdään useassa paikassa: toiminnallisuudesta voi tehdä funktion, jota voi kutsua. Funktiot voivat myös palauttaa arvon, joka asetetaan muuttujaan -- muuttujaa taas voidaan käyttää osana muuta ohjelmakoodia.

Funktioiden parametrit toimivat seuraavasti:

function tulostaViesti(viesti) {
    console.log(viesti);
}

Kun ylläolevaa funktiota kutsutaan, sille tulee antaa parametri. Parametrille luodaan funktiokutsussa oma muuttuja viesti, johon parametrin arvo kopioidaan. Funktiota tulostaViesti suoritettaessa funktiolle console.log annetaan aina sen arvon kopio, jonka funktio tulostaViesti saa parametrina.

Funktiot voivat myös palauttaa arvon. Arvo palautetaan komennolla return, jota seuraa palautettava arvo. Seuraavassa esimerkissä on funktio kysyNumeroJaTarkista kutsuu komentoa return ja palauttaa arvon -1 jos käyttäjän syöttämä arvo ei ole numero, muulloin palautetaan luettu arvo.

function kysyNumeroJaTarkista() {
    var syote = prompt("Kirjoita numero");

    if(isNaN(Number(syote))) {
        console.log("Et kirjoittanut numeroa!");
        return -1;
    }

    numero = parseInt(syote);

    if (numero == 5 || numero == 7) {
        console.log("Oikein meni!");
    }

    return numero;
}

Jos komennolle return ei anna palautettavaa arvoa, funktiokutsusta poistutaan ilman arvon palauttamista.

Muuttujien näkyvyys

JavaScriptissä muuttujilla on kaksi näkyvyystyyppiä: paikallinen ja globaali. Paikalliset muuttujat ovat olemassa vain siinä funktiossa missä ne on esitelty. Globaalit muuttujat ovat olemassa kaikkialla. Muuttujia voi esitellä ilman määrettä var, jolloin ne ovat globaaleja. Tämä johtaa ennen pitkää kaaokseen. Testaa alla olevia koodeja, ja huomaa niiden ero!

Käytä avainsanaa var aina kun esittelet muuttujan -- kunnes toisin sanotaan.

Taulukot

Taulukkotyyppisiä muuttujia voidaan luoda komennolla [], joka on lyhenne komennolla new Array();. Esimerkiksi seuraavassa luodaan 3 paikkaa sisältävä taulukko.

var salasanat = ["salasana", "alasanas", "lasagna"];

Taulukkomuuttujan indeksointi tapahtuu hakasuluilla, ja ensimmäinen alkio on indeksissä 0.

var salasanat = ["salasana", "alasanas", "lasagna"];

console.log(salasanat[1]); // tulostaa konsoliin merkkijonon "alasanas"

Taulukot voivat sisältää myös erityyppisiä muuttujia.

var tiedot = ["Mikke", 1984];

Uusia alkioita voidaan lisätä taulukkoon joko indeksiviittauksella (taulukon koko ei ole lyöty kiveen kuten esimerkiksi Javassa), tai taulukon metodilla push.

Oliot

Olioiden luonti tapahtuu JSON, eli JavaScript Object Notation-syntaksin avulla -- tämä saattaa olla joillekin palvelinohjelmointi-kurssilla olleille tutun näköinen notaatio.

Olion määrittely alkaa aaltosululla {, jota seuraa muuttujan nimi ja sille annettava arvo. Arvon asetus oliomuuttujalle tapahtuu kaksoispisteellä, esimerkiksi nimi: "Mikke". Useampia muuttujia voi määritellä pilkulla eroteltuna. Olion määrittely lopetetaan sulkevaan aaltosulkuun }.

var mikke = {nimi: "Mikke", syntymavuosi: 1984};

Olion muuttujiin pääsee käsiksi piste-notaatiolla. Esimerkiksi mikke-olion muuttuja nimi löytyy komennolla mikke.nimi.

var mikke = {nimi: "Mikke", syntymavuosi: 1984};
console.log(mikke.nimi);

Myös uusien oliomuuttujien lisääminen on suoraviivaista. Uuden muuttujan lisääminen tapahtuu myös pistenotaatiolla -- harrastuksen lisääminen tapahtuu mikke-oliolle sanomalla mikke.harrastus = "koodaus";.

var mikke = {nimi: "Mikke", syntymavuosi: 1984};
console.log(mikke.nimi);
mikke.harrastus = "koodaus";
console.log(mikke.harrastus);

Olioiden rakennetta ei siis ole lyöty ennalta lukkoon, ja voimme helposti lisätä oliolle mikke vaimon.

var mikke = {nimi: "Mikke", syntymavuosi: 1984};

/* miken vaimo on ikinuori */
var kate = {nimi: "Kate", syntymavuosi: new Date().getFullYear() - 21};

mikke.vaimo = kate;

/* nyt voimme selvittää miken vaimon nimen seuraavasti */
console.log(mikke.vaimo.nimi);

Web-sivun elementtien arvojen käsittely

Palataan web-maailmaan. JavaScriptiä käytetään ennenkaikkea dynaamisen toiminnallisuuden lisäämiseksi web-sivuille. Esimerkiksi web-sivuilla oleviin elementteihin tulee pystyä asettamaan arvoja, ja niitä tulee myös pystyä hakemaan. JavaScriptissä pääsee käsiksi dokumentissa oleviin elementteihin komennolla document.getElementById("tunnus"), joka palauttaa elementin, jonka id-attribuutti on "tunnus".

Alla on tekstikenttä, jonka HTML-koodi on <input type="text" id="tekstikentta"/>. Kentän tunnus on siis tekstikentta. Jos haluamme päästä käsiksi elementtiin, jonka tunnus on "tekstikentta", käytämme komentoa document.getElementById("tekstikentta"). Tekstikenttäelementillä on attribuutti value, joka voidaan tulostaa.

Tekstikentälle voidaan asettaa arvo kuten muillekin muuttujille. Alla olevassa esimerkissä haetaan edellisen esimerkin tekstikenttä, ja asetetaan sille arvo 5.

Tehdään vielä ohjelma, joka kysyy käyttäjältä syötettä, ja asettaa sen yllä olevan tekstikentän arvoksi.

Arvon asettaminen osaksi tekstiä

Yllä tekstikentälle asetettiin arvo sen value-attribuuttiin. Kaikilla elementeillä ei ole value-attribuuttia, vaan joillain näytetään niiden elementin sisällä oleva arvo. Elementin sisälle asetetaan arvo muuttujaan liittyvällä attribuutilla innerHTML.

Tämän tekstin alapuolella on p-elementti, jonka id on js-hidden-p-element. Elementin sisällä ei ole lainkaan tekstiä, joten sitä ei näytetä. Voit kuitenkin asettaa tekstin allaolevan JavaScript-koodin avulla, jolloin se se tulee näkyville.

Vastaavasti tekstin keskelle -- sisäelementtiin -- voi asettaa arvoja. Elementti span on tähän aivan mainio. Tämä teksti on span-elementin sisällä, jonka tunnus on "spanelementti".

Interaktiivinen toiminta -- reagointi klikkaamiseen

HTML-elementtiin voi lisätä onclick-attribuutin, jolle määritellään arvoksi elementin klikkauksen yhteydessä suoritettava JavaScript-koodi -- <element onclick="ohjelmakoodi tai funktiokutsu">.

Esimerkiksi tämä teksti on määritelty siten, että jos tätä klikkaa, näkyy teksti "wakawaka".

Case: Laskin

Luodaan laskin. Laskimella on kaksi toiminnallisuutta: pluslasku ja kertolasku. Luodaan ensin laskimelle javascriptkoodi, joka on tiedostossa laskin.js. Javascript-koodissa oletetaan, että on olemassa input-tyyppiset elementit tunnuksilla "eka" ja "toka" sekä span-tyyppinen elementti tunnuksella "tulos". Funktiossa plus haetaan elementtien "eka" ja "toka" arvot, ja asetetaan pluslaskun summa elementin "tulos" arvoksi. Kertolaskussa tehdään lähes sama, mutta tulokseen asetetaan kertolaskun tulos. Koodissa on myös apufunktio, jota käytetään sekä arvojen hakemiseen annetuilla tunnuksilla merkityistä kentistä että näiden haettujen arvojen muuttamiseen numeroiksi.

function haeNumero(tunnus) {
    return parseInt(document.getElementById(tunnus).value);
}

function asetaTulos(tulos) {
    document.getElementById("tulos").innerHTML = tulos;
}

function plus() {
    asetaTulos(haeNumero("eka") + haeNumero("toka"));
}

function kerto() {
    asetaTulos(haeNumero("eka") * haeNumero("toka"));
}

Laskimen käyttämä HTML-dokumentti näyttää seuraavalta:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title>Laskin</title>
    </head>
    <body>
        <header>
            <h1>Plus- ja Kertolaskin</h1>
        </header>

        <section>
            <p>
                <input type="text" id="eka" value="0"/ >
                <input type="text" id="toka" value="0" />
            </p>

            <p>
                <input type="button" value="+" onclick="plus();" />
                <input type="button" value="*" onclick="kerto();" />
            </p>


            <p>Laskimen antama vastaus: <span id="tulos"></span></p>
        </section>

        <script src="laskin.js"></script>
    </body>
</html>

Laskin itsessään näyttää seuraavalta:

Plus- ja Kertolaskin

Laskimen antama vastaus:

Oma laskin (2p)

Toteuta edellisen esimerkin perusteella laskin, jossa on plus-, miinus-, kerto- ja jakolaskutoiminnallisuus. Varmista, että sivu on käytettävä ilman erillistä ohjetekstiä, eli että käyttämäsi napit ja tekstit kertovat käyttäjälle kaiken oleellisen.

Kun olet valmis, palauta tehtävä TMC:lle.

Case: Tyylien muuttaminen JavaScriptillä

JavaScriptiä voi käyttää myös tyylien muokkaamiseen. Attribuutin class arvoa voi muuttaa dokumentista saatavan olion attribuutilla className. Tehdään vielä esimerkki, jossa sivulla oleva tieto vaihtuu linkkiä klikkaamalla, mutta selain ei oikeasti siirry sivulta toiselle. Luodaan ensiksi HTML-dokumentti, jossa on valmiiksi paikat tyylitiedostolle style.css ja lähdekoodille script.js.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title></title>
        <link rel="stylesheet" href="style.css" type="text/css" >
    </head>
    <body onload="init();" >
        <header>
            <h1>Kindler</h1>

            <nav>
                <!-- komento return false; estää selaimen siirtymisen toiselle sivulla -->
                <a href="#" onclick="displayArticle(0);return false;">Eka artikkeli</a>
                <a href="#" onclick="displayArticle(1);return false;">Toka artikkeli</a>
            </nav>
        </header>

        <article>
          <h1>Eka artikkeli</h1>

          <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit...</p>
        </article>

        <article>
          <h1>Toka artikkeli</h1>

          <p>Morbi a elit enim, sit amet iaculis massa. Vivamus blandit...</p>
        </article>

        <article>
          <h1>Kolmas artikkeli</h1>

          <p>Now that we know who you are, I know who I am. I'm...</p>
        </article>


        <script src="script.js"></script>
    </body>
</html>

Luodaan seuraavaksi sivulle tyylitiedosto. Määritellään article-elementille tyyliluokka "hidden": jos article-elementillä on tyyliluokka hidden, sitä ei näytetä selaimessa.

article.hidden {
    display: none;
}

Luodaan seuraavaksi JavaScript-toiminnallisuus. Emme käsittele sivua tunnusten avulla, vaan käytämme elementtien läpikäyntiin niiden tyyppejä. Haluamme käytännössä käsitellä kaikkia sivulla olevia article-elementtejä. Tähän on kätevä komento document.getElementsByTagName("elementinNimi"), joka palauttaa taulukon elementeistä, joiden elementin nimi on "elementinNimi". Haluamme myös, että kun sivu on ladattu, näytetään vain ensimmäinen artikkeli. Tätä varten body-elementille on olemassa attribuutti onload, jolle voi määritellä funktion nimen, jota kutsutaan kun sivun lataaminen on valmis.

function init() {
    displayArticle(0);
}

function displayArticle(index) {
    var articles = document.getElementsByTagName("article");

    for(var i = 0; i < articles.length; i++) {
        if (index == i) {
            articles[i].className='';
        } else {
            articles[i].className='hidden';
        }
    }
}

PerusMOOC (3p)

Tehtävässä on käytetty seuraavia värejä:

  • rgb(233, 229, 217);
  • rgb(73, 69, 69);
  • rgb(66, 126, 120);

Fonttien määrittely on muotoa

    font-family: 'Trebuchet MS', Trebuchet, Arial, sans-serif;

Tehtävän mukana tuleva sivu näyttää seuraavanlaiselta:

Tehtävänäsi on ensin lisätä sivulle tyylitiedosto, jonka avulla sivusta tulee seuraavannäköinen. Kun lisäät tyylitiedostoa, lisää valikkoon myös toiminnallisuus, jonka avulla linkin tausta muuttuu kun hiiri on sen päällä.

Kun tyylit ovat valmiit, lisää sivulle toiminnallisuus, jossa vain ensimmäinen osio näkyy ensin. Linkkejä klikkaamalla sivulla vaihdetaan osiosta toiseen. Alla olevassa kuvassa osiota "Materiaali" on juuri klikattu.

Kun olet valmis, ja sivusi näyttää oikealta Chromessa, palauta tehtävä TMC:lle.

DOM

DOM (Document Object Model) on ohjelmointirajapinta HTML (ja XML) -dokumenttien rakenteen ja sisällön muokkaamiseksi. Se sisältää kuvauksen HTML-dokumentin elementeistä ja niiden asemoinnista dokumentissa. HTML-dokumentti kuvataan usein puumaisena tietorakenteena, jossa jokainen sivun elementti on puun solmu (oksa) tai lehti (solmu, josta ei lähde oksia). Jokaisella elementillä on myös nimi, jolla siihen pääsee käsiksi.

Suurin osa nykyaikaisista web-selaimista toteuttaa W3C DOM-standardin, sekä usein tarjoavat omia lisävälineitä dokumenttien muokkaamiseen. W3C DOM-standardi sisältää esimerkiksi aiemmin käyttämämme kutsun document.getElementById("tunnus"), jonka avulla päästään käsiksi dokumentin sisältämään elementtiin, jonka attribuutin id arvo on "tunnus".

HTML-dokumentin elementit ja niihin liittyvät ominaisuudet (attribuutit, tapahtumat, ...) on jäsennelty erilaisiin olioihin. Osaan pääsee käsiksi suoraan. Esimerkiksi window-oliolla päästään käsiksi mm. selainikkunassa tapahtuviin tapahtumiin (esim. näppäimistön kuuntelu), document-olio taas liittyy HTML-dokumenttiin ja sen sisältämiin elementteihin. Kaikkiin dokumentin elementteihin pääsee käsiksi document-elementin kautta.

Esimerkiksi dokumentissa olevan canvas-elementin saa haettua siihen liittyvällä tunnuksella document-oliota käyttäen. Mozillan sovelluskehittäjien sivustolla on hyvä kuvaus elementteihin liittyvistä rajapinnoista, kts. https://developer.mozilla.org/en-US/docs/Gecko_DOM_Reference#HTML_element_interfaces.

DOM-standardi sisältää useita tasoja, eli versioita. Taso 1 sisältää mm. dokumentin elementtien luomisen sekä hakemisen getElementsByTagName-komennolla. Kutsu getElementsByTagName attribuutilla * palauttaa listan, joka sisältää kaikki sivun elementit. Lähes kaikki nykyään käytössä olevat selaimet tukevat tason 1 toiminnallisuutta. Taso 2 sisältää mm. tuen dokumentin tyylien muokkaamiseen DOM-puun kautta sekä erilaisten tapahtumien (mm. hiiri, näppäimistö) käsittelyä tukevan järjestelmän. Taso 3 laajentaa tason 2 toiminnallisuutta mm. dokumentin elementtien ja tapahtumien käsittelyssä.

DOM-spesifikaatio sisältää useita eri komponentteja. Alla kuva DOM-arkkitehtuurista.

W3C työskentelee tällä hetkellä (1.11.2012) tason 4 spesifikaation kanssa. Taso 4 tulee olemaan laajennus aiempiin tasoihin, joka tarjoaa mm. selvennyksiä tapahtumien käsittelyyn ja lisätoiminnallisuuksia dokumentin identifiointiin.

Elementtien valinta

Olemme käyttäneet dokumentin getElementById-kutsua tietyn elementin hakemiseen. Kaikki sivun elementit voi taas hakea esimerkiksi getElementsByTagName("*")-kutsulla. Molemmat ovat kuitenkin hieman kömpelöjä jos tiedämme mitä haluamme hakea verrattuna esimerkiksi CSS:n käyttämään elementtien valintatyyliin (kts. http://www.w3.org/TR/selectors/#selectors.

W3C DOM-määrittely sisältää myös paremman ohjelmointirajapinnan elementtien läpikäyntiin. Selectors API sisältää mm. querySelector-kutsun, jolla saadaan CSS-valitsinten kaltainen kyselytoiminnallisuus.

Selector APIn tarjoamien querySelector (yksittäisen osuman haku) ja querySelectorAll (kaikkien osumien haku) -komentojen avulla kyselyn rajoittaminen vain header-elementissä oleviin a-elementteihin on helppoa.

var linkit = document.querySelectorAll("nav a");
// linkit-muuttuja sisältää nyt kaikki a-elementit, jotka ovat nav-elementin sisällä

Vastaavasti header-elementin sisällä olevat linkit voi hakea seuraavanlaisella kyselyllä.

var linkit = document.querySelectorAll("header a");
// linkit-muuttuja sisältää nyt kaikki a-elementit, jotka ovat header-elementin sisällä

Myös tietyn luokan toteuttavien elementtien haku on helppoa. Alla olevassa esimerkissä on kolme tekstikenttää, joista 2 on piilotettu. Piilotettujen tekstikenttien tyyliluokka on dom-esim-1-hidden.

text 1

text 2

text 3

Voimme hakea querySelectorin avulla myös elementtejä, joilta puuttuu tietty ominaisuus. Alla haemme kaikki tyyliluokan dom-esim-2 toteuttavan elementin sisällä olevat p-elementit, joilla ei ole tyyliluokkaa dom-esim-2-hidden. Lopuksi lisäämme kyselyssä löydetyille elementeille tyyliluokan dom-esim-2-hidden, jolloin elementit piilotetaan.

Alla olevan sivun lähdekoodi on seuraavanlainen (tyyliluokkien oudot nimet johtuvat tämän dokumentin rakenteesta -- haluamme että esimerkit eivät vaikuta toisiin esimerkkeihin).

  <article class="dom-esim-2">
    <p class="dom-esim-2-hidden">text 1</p>
    <p>text 2</p>
    <p class="dom-esim-2-hidden">text 3</p>
  </article>

text 1

text 2

text 3

Mitä käy jos poistat ylläolevasta kyselystä alkuosan dom-esim-2 ja suoritat kyselyn? Pohdi ennen kokeilemista!

Elementtien lisääminen

HTML-dokumenttiin lisätään uusia elementtejä document-olion createElement-metodilla. Esimerkiksi alla luodaan p-elementti, joka asetetaan muuttujaan tekstiElementti. Tämän jälkeen luodaan tekstisolmu, joka sisältää tekstin "o-hai". Lopulta tekstisolmun lisätään tekstielementtiin.

var tekstiElementti = document.createElement("p");
var tekstiSolmu = document.createTextNode("o-hai");

tekstiElementti.appendChild(tekstiSolmu);

Ylläoleva esimerkki ei luonnollisesti muuta HTML-dokumentin rakennetta sillä uutta elementtiä ei lisätä osaksi HTML-dokumenttia. Olemassaoleviin elementteihin voidaan lisätä sisältöä elementin appendChild-metodilla. Alla olevan tekstialue sisältää article-elementin, jonka tunnus on dom-esim-3. Voimme lisätä siihen elementtejä elementin appendChild-metodilla.

Artikkelielementin sekä sen sisältämien tekstielementtien lisääminen onnistuu vastaavasti. Alla olevassa esimerkissä käytössämme on seuraavanlainen section-elementti.

<!-- .. dokumentin alkuosa .. -->
    <section id="dom-esim-4"></section>
<!-- .. dokumentin loppuosa .. -->

Uusien artikkelien lisääminen onnistuu helposti aiemmin näkemällämme createElement-metodilla.

Yleiskone (3p)

Yleiskone on erilaisissa toiminnoissa auttava sivusto, jonka kautta käyttäjä voi käydä etsimässä apua arkisiin ongelmiin. Toteuta yleiskone, jossa on seuraavat kolme toiminnallisuutta -- jokaisen toiminnallisuuden tulee näkyä käyttäjälle erillisenä sivuna, jota käyttäjä osaa käyttää ilman erillistä koulutusta. Panosta siis myös ohjeistukseen!

Yleiskoneen tarjoamat toiminnallisuudet ovat:

  • Celsius-asteista Fahrenheitiksi ja takaisin. Sivuston tulee tarjota laskuri, jonka avulla käyttäjä voi syöttää kenttään Celsius- ja Fahrenheit -asteet, ja muuntaa ne yhdestä muodosta toiseen. Apua celsius-asteista fahrenheitiksi ja fahrenheitista celsius-asteiksi.

  • Jaloista metreiksi ja takaisin. Sivuston tulee tarjota myös laskuri, jonka avulla käyttäjä voi syöttää kenttään jalkojen lukumäärä (engl. feet) sekä metriluku, ja muuntaa ne yhdestä muodosta toiseen.

  • Julkaisulaskuri. Yliopistojen rahoitukseen vaikuttaa 55 opintopistettä vuosittain saaneiden opiskelijoiden lisäksi muunmuassa tutkimuksesta raportoivien julkaisujen laatu ja määrä. Julkaisut voidaan jakaa neljään luokkaan: luokka 0, luokka 1, luokka 2, ja luokka 3. Tällä hetkellä luokan 0 julkaisujen painoarvo on 1, luokan 1 julkaisujen painoarvo on 1.5, luokan 2 julkaisujen painoarvo on 3, ja luokan 3 julkaisujen painoarvo on 3. Toteuta laskuri, johon tutkija voi kirjoittaa kuhunkin luokkaan kuuluvien julkaisujen määrän, ja joka laskee julkaisujen yhteispainomäärän. Esimerkiksi, jos Matti L. julkaisee vuonna 2015 yhden luokkaa 0 olevan artikkelin, kaksi luokkaa 1 olevaa artikkelia, ja yhden luokkaa 2 olevan artikkelin, on hänen julkaisujen yhteispainomäärä 7.

Tämän lisäksi yleiskoneella tulee olla "tykkää"-teksti, jota klikkaamalla tykkää-tekstin viereinen luku kasvaa yhdellä. Tämä toki toteutetaan niin, että tykkäykset ovat käyttäjäkohtaisia, ja katoavat kun sivu ladataan uudestaan.

Kun yleiskone toimii, ja sivulle astuessaan käyttäjä näkee ohjeet, joiden perusteella lisäapua ei tarvita, palauta tehtävä TMC:lle.

Viikko 2

Elementtien poistaminen

Dokumentin puumaisen rakenteen takia elementin lisääminen tapahtuu elementin vanhempaan liittyvällä appendChild-metodilla. Koska elementin vanhempi pitää kirjaa kaikista sen lapsista, tulee elementti myös poistaa sen vanhemman kautta.

DOM-puun elementtien toteuttamat metodit sisältävät metodin removeChild, jota voi käyttää lapsielementin poistamiseen. Alla olevassa esimerkissä haluamme poistaa elementin, jonka tunnus on "poistettava".

// myös document.getElementById("poistettava") käy
var poistettava = document.querySelector("#poistettava");
poistettava.parentNode.removeChild(poistettava);
<!-- .. dokumentin alkuosa .. -->
    <article id="poistettava">
        <p>Lorem Ipsum jne..</p>
    </article>
<!-- .. dokumentin loppuosa .. -->

Lorem Ipsum jne..

Yllä olevan koodin suorituksen jälkeen elementti on poistettu DOM-puusta. Nykyaikaisissa selaimissa oleva roskienkeruumekanismi poistaa myös poistettavien elementtien lapsielementit.

Tapahtumien käsittely

Tason 2 DOM-spesifikaatiossa määriteltiin tuki tapahtumien käsittelylle, kts. http://www.w3.org/TR/DOM-Level-2-Events/. Tason 3 spesifikaatio ei ole vielä valmis, mutta sen viimeisin versio löytyy täältä).

Käytännössä tapahtumien käsittely toimii tapahtumaohjatusti. Jokaisella toiminnolla on kohde, johon se liittyy. Kun sivuilla esimerkiksi klikataan hiiren nappia, DOM-toteutus ohjaa tapahtuman nappiin mahdollisesti liitetylle tapahtumankuuntelijalle. Jos napille on rekisteröity tapahtumankuuntelija, suoritetaan tapahtumankuuntelijan koodi.

Tapahtumien käsittelyä varten sivuille tulee määritellä funktioita. Olemme aiemmin määritelleet tapahtumien sattuessa kutsuttavat funktiot osaksi HTML-dokumenttia seuraavasti.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title></title>
        <link rel="stylesheet" href="style.css" type="text/css" >
    </head>
    <body onload="init();" >
        <header>
            <h1>Kindler</h1>

            <nav>
                <!-- komento return false; estää selaimen siirtymisen toiselle sivulla -->
                <a href="#" onclick="displayArticle(0);return false;">Eka artikkeli</a>
                <a href="#" onclick="displayArticle(1);return false;">Toka artikkeli</a>
            </nav>
        </header>

        <!-- muu sisältö -->

        <!-- lähdekooditiedostojen lataus -->
    </body>
</html>

Vastuiden erottamisen näkökulmasta yllä oleva lähestymistapa on huono, ja haluamme eriyttää sovelluslogiikan käyttöliittymästä. Äärinäkökulmasta katsottuna ainoa HTML-dokumentissa sallittu JavaScript-kutsu on body-elementin onload-attribuutille määriteltävä kutsu, joka suoritetaan kun sisältö on ladattu.

Yksi tapa poistaa ylläolevassa dokumentissa olevat JavaScript-kutsut on lisätä init-metodiin tapahtumankäsittelijöiden lisääminen. Käytetään aiemmin oppimaamme querySelector-toteutusta siten, että lisäämme tapahtumankäsittelijät vain menuvalikon linkkeihin. Jotta saisimme tapahtumankäsittelijän toimimaan oikein a-elementissä, meidän tulee myös kieltää linkin seuraaminen. Tämä onnistuu tapahtumaan liittyvällä kutsulla preventDefault().

function init() {
    var navLinks = document.querySelectorAll("header nav a");
    for(var i = 0; i < navLinks.length; i++) {
        var link = navLinks[i];

        // lisätään elementille id, josta päätellään näytettävä artikkeli
        link.id = i;

        link.onclick = function(eventInformation) {
            var origin = eventInformation.target;

            // kutsutaan erillistä displayArticle-funkiota, joka
            // näyttää halutun artikkelin
            displayArticle(origin.id);

            // kielletään selainta tekemästä oletustoiminto (siirtyminen)
            eventInformation.preventDefault();
        }
    }

    // ...
}

Nyt aiempi sivumme toimii myös seuraavannäköisenä.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" >
        <title></title>
        <link rel="stylesheet" href="style.css" type="text/css" >
    </head>
    <body onload="init();" >
        <header>
            <h1>Kindler</h1>

            <nav>
                <a href="#">Eka artikkeli</a>
                <a href="#">Toka artikkeli</a>
            </nav>
        </header>

        <!-- muu sisältö -->

        <!-- lähdekooditiedostojen lataus -->
    </body>
</html>

Oikeastaan yllä käyttämämme lähestymistapa on myös hieman hölmö. Kun määrittelemme onclick-funktion, korvaamme aiemman funktion. Fiksumpaa olisi lisätä uusi funktio aiempien lisäksi. Tämä onnistuu elementteihin liittyvällä metodilla addEventListener. Metodille addEventListener määritellään tapahtuman nimi (esim click, huomaa ero!), funktio jota kutsutaan (joko funktion nimi tai konkreettinen toteutus), ja totuusarvoinen muuttuja, jolla kerrotaan tuleeko muun dokumentin reagoida tapahtumaan. Tällöinkin tapahtumaan reagoivat vain elementit, jotka ovat tapahtuman laukaisevan elementin vanhempia.

function init() {
    var navLinks = document.querySelectorAll("header nav a");
    for(var i = 0; i < navLinks.length; i++) {
        var link = navLinks[i];

        // lisätään elementille id, josta päätellään näytettävä artikkeli
        link.id = i;

        // lisätään tapahtumankuuntelija tapahtumalle click. huom! ero onclick-attribuuttiin
        link.addEventListener('click', function(eventInformation) {
            var origin = eventInformation.target;

            // kutsutaan erillistä displayArticle-funkiota, joka
            // näyttää halutun artikkelin
            displayArticle(origin.id);

            // kielletään selainta tekemästä oletustoiminto (siirtyminen)
            eventInformation.preventDefault();
        }, false);
    }

    // ...
}

Tapahtumankäsittelyyn liittyvän funktion voi määritellä myös erikseen, jolloin tapahtumankäsittelijää lisättäessä funktioon viitataan sen nimellä.

function handleLinkClick(eventInformation) {
    var origin = eventInformation.target;

    // kutsutaan erillistä displayArticle-funkiota, joka
    // näyttää halutun artikkelin
    displayArticle(origin.id);

    // kielletään selainta tekemästä oletustoiminto (siirtyminen)
    eventInformation.preventDefault();
}

function init() {
    var navLinks = document.querySelectorAll("header nav a");
    for(var i = 0; i < navLinks.length; i++) {
        var link = navLinks[i];

        // lisätään elementille id, josta päätellään näytettävä artikkeli
        link.id = i;

        // lisätään tapahtumankuuntelija tapahtumalle click. huom! ero onclick-attribuuttiin
        link.addEventListener('click', handleLinkClick, false);
    }

    // ...
}

Lisää eri tapahtumatyypeistä ja tapahtumankäsittelystä löydät esim. täältä.

PerusMOOC, jatkoa (1p)

Tehtäväpohjassa on viime viikon loppupuolelta alustava PerusMOOC-sivu. Sivulla on kuitenkin vielä ongelma: html-sivu sisältää JavaScriptiä enemmän kuin on sallittu.

Muuta sivustoa siten, että ainoa index.html-sivun JavaScript-kutsu on body-elementin onload-attribuutille asetettu init();-funktiokutsu. Sivun toiminnallisuuden tulee pysyä ennallaan.

Varaudu myös siihen, että linkeille saatetaan asettaa myöhemmin toteutettavissa koodeissa uusia tapahtumankäsittelijöitä.

Kun sovellus toimii toivotusti, palauta se TMC:lle.

Lisää JavaScriptistä

Käydään läpi hieman tarkemmin JavaScriptiin liittyviä mielenkiintoisuuksia, sekä tutustutaan hyviin ohjelmointikäytänteisiin.

Muuttujien näkyvyys

Olemme aiemmin todenneet, että JavaScriptissä muuttujilla on kaksi eri näkyvyystyyppiä, paikallinen ja globaali. Paikallisella näkyvyydellä tarkoitetaan että muuttujat ovat olemassa vain funktion sisällä, ja globaalilla sitä, että muuttujat ovat näkyvissä kaikkialla. Kun muuttuja määritellään var-etuliitteellä, on se olemassa funktiossa, jossa se on määritelty.

Mitä tarkoittaa "muuttuja on olemassa funktiossa, jossa se on määritelty"? Pohdi seuraavaa ohjelmaa ja päättele mitä tapahtuu kun painat "Suorita koodi!"-nappia.

Uskoisimme, että et arvannut lopputulosta, jollet tuntenut JavaScriptiä ennalta.

Käytännössä JavaScript siirtää muuttujien määrittelyt funktion alkuun, mutta muuttujien arvon asetus tapahtuu alkuperäisen koodin määrittelemässä kohdassa. Ylläoleva koodi tulkitaan JavaScript-tulkin toimesta seuraavasti.

var summa = 21;

function mitaKay() {
    var summa;

    if (false) {
        summa = 42;
    }

    alert(summa);
}

mitaKay();

Koska ylläolevassa ohjelmakoodissa funktion mitaKay sisällä määritellylle muuttujalle summa ei koskaan aseteta arvoa, on sen arvo undefined.

Olio-ohjelmointi ja prototyyppimalli

Olio-ohjelmoinnissa kyse on ennen kaikkea ongelma-alueen käsitteiden mallintamisesta osana ohjelmakoodia. Olio-ohjelmoinnissa ohjelmoijat pyrkivät puhumaan samoilla käsitteillä kuin ohjelmistoa tilaavat asiakkaat. Käsitteet liittyvät maailmassa olevaan dataan, ja ovat interaktiossa toisten käsitteiden kanssa olioihin liittyvien metodien kautta.

Kommunikaation ja käsitemaailman helpottamisen lisäksi olio-ohjelmoinnissa ohjelma hajoitetaan hallittaviksi osiksi, jotka kapseloivat pienempää toiminnallisuutta. Kukin olio voi sisältää dataa sekä lähettää ja vastaanottaa informaatiota.

JavaScriptissä ei ole mm. Javasta tuttuja luokkia, vaan uusien olioiden luominen tapahtuu funktiokutsuilla. JavaScript-kielen oliomalli perustuu funktioihin, joiden prototyyppeihin voidaan liittää uusia funktioita.

Tutkitaan hieman erilaisia koodiesimerkkejä.

Luodaan uusi olio new Object()-kutsulla. Oliolle asetetaan muuttujat nimi ja ika.

var mikke = new Object();
mikke.nimi = "Michael Knight";
mikke.ika = 17;

console.log(mikke.nimi);

Ylläoleva ohjelmakoodi vastaa allaolevaa olion luomista.

var mikke = {nimi: "Michael Knight", ika: 17};
console.log(mikke.nimi);

Olion muuttujiin pääsee käsiksi myös seuraavanlaisen notaation avulla. Notaatio saattaa olla tuttu mikäli on aiemmin ohjelmoinut esimerkiksi PHP:llä tai perlillä.

var mikke = {nimi: "Michael Knight", ika: 17};
console.log(mikke["nimi"]);

Käytännössä oliot JavaScriptissä ovat kokoelmia avain-arvo -pareja. Jokaisella oliolla voi olla omanlaisensa avaimet ja niiden arvot. Kaikki JavaScriptin oliot laajentavat valmista Object-oliota.

Omien olioiden tai "luokkien" luonti

Omien olioiden luonti tapahtuu funktioiden avulla. Huomaa heti alkuun hyvä nimeämiskäytäntö: funktiot, joita käytetään olioiden luomiseen nimetään isolla alkukirjaimella. Luodaan funktio Opiskelija, jota käytetään opiskelija-olioiden luomiseen (Javalla ohjelmoineet voivat ajatella allaolevaa myös luokkana). Opiskelijalla on kaksi attribuuttia: nimi ja opintopisteet.

function Opiskelija(nimi) {
    this.nimi = nimi;
    this.opintopisteet = 0;
}

Huomaa ylläolevan määrittelyn määre this. Määreellä this kerrotaan, että käsitellyn muuttujan arvo liittyy juuri tähän olioon. Kun funktio Opiskelija on määritelty, voimme luoda uusia opiskelijaolioita seuraavasti.

var mikke = new Opiskelija("Michael");
var kasper = new Opiskelija("Casper");
kasper.opintopisteet = 400;

console.log("Nimi " + mikke.nimi + ", noppia: " + mikke.opintopisteet);
console.log("Nimi " + kasper.nimi + ", noppia: " + kasper.opintopisteet);

Ohjelma tuottaa konsoliin seuraavan tekstin

Nimi Michael, noppia: 0
Nimi Casper, noppia: 400

Jokaisella funktiolla on prototyyppi, joka sisältää tiedon funktioon liittyvistä attribuuteista. Prototyypin kautta lisättävien funktioiden avulla pääsemme käsiksi olioiden this-viitteeseen, mikä mahdollistaa olion sisäisen tilan muuttamisen.

Koska attribuutit voivat olla myös funktioita, funktion prototyypille voidaan määritellä uusia metodeja, joilla olion tilaa voidaan muokata. Lisätään funktiolle Opiskelija prototyyppifunktio opiskeleYksin, joka kasvattaa opintopisteiden määrää yhdellä.

Opiskelija.prototype.opiskeleYksin = function() {
    this.opintopisteet++;
}
var mikke = new Opiskelija("Michael");
var kasper = new Opiskelija("Casper");
kasper.opintopisteet = 400;

mikke.opiskeleYksin();

console.log("Nimi " + mikke.nimi + ", noppia: " + mikke.opintopisteet);
console.log("Nimi " + kasper.nimi + ", noppia: " + kasper.opintopisteet);

Nyt näemme viestit

Nimi Michael, noppia: 1
Nimi Casper, noppia: 400

Lisätään funktiolle Opiskelija vielä toinen prototyyppifunktio opiskeleYhdessa, joka saa parametrina toisen opiskelijan. Voimme tarkistaa että parametrina saatu muuttuja on jotain tiettyä tyyppiä instanceof-vertailuoperaatiolla. Viitettä this voi käyttää olioon liittyvien funktioiden kutsumisessa.

Opiskelija.prototype.opiskeleYhdessa = function(kanssaOpiskelija) {
    if(!(kanssaOpiskelija instanceof Opiskelija)) {
        this.opiskeleYksin();
        return;
    }

    this.opintopisteet += 2;
    kanssaOpiskelija.opintopisteet += 2;
}
var mikke = new Opiskelija("Michael");
var kasper = new Opiskelija("Casper");
kasper.opintopisteet = 400;

mikke.opiskeleYksin();
mikke.opiskeleYhdessa(kasper);

mikke.opiskeleYhdessa("porkkana");

console.log("Nimi " + mikke.nimi + ", noppia: " + mikke.opintopisteet);
console.log("Nimi " + kasper.nimi + ", noppia: " + kasper.opintopisteet);
Nimi Michael, noppia: 4
Nimi Casper, noppia: 402

Oliolaskuri (1p)

Luo tehtäväpohjan mukana tulevalle HTML-sivulle toiminnallisuus, jossa nappia painettaessa sivulla näkyvässä tekstissä olevan numeron arvo kasvaa aina yhdellä. HTML-sivulle saa asettaa vain yhden JavaScript-kutsun, joka tulee body-elementin onload-attribuuttiin.

Toteuta apuvälineeksi olion luova funktio Laskin jolla on muuttuja luku. Lisää funktiolle Laskin funktiot kasvata, joka kasvattaa olion luvun arvoa yhdellä, ja annaLuku, joka palauttaa olion luvun arvon.

Varaudu siihen, että napille voidaan asettaa myöhemmin uusia tapahtumankäsittelijöitä.

Kun sovellus toimii toivotusti, palauta se TMC:lle.

Esimerkki: Kurssikirjanpito

Luodaan Opiskelijan lisäksi vielä funktiot Kurssin ja Kurssisuorituksen luomiseen. Kurssilla on nimi ja opintopistemäärä, kurssisuoritus sisältää viitteen suoritettuun kurssiin ja opiskelijaan sekä suorituspäivämäärän ja arvosanan. Suorituspäivämääränä käytetään suoritusolion luontipäivämäärää.

function Kurssi(nimi, opintopisteet) {
    this.nimi = nimi;
    this.opintopisteet = opintopisteet;
}

function Kurssisuoritus(opiskelija, kurssi, arvosana) {
    this.opiskelija = opiskelija;
    this.kurssi = kurssi;
    this.arvosana = arvosana;
    this.paivamaara = new Date();
}

Näiden lisäksi käytössämme on Kirjanpito, johon voi lisätä opiskelijoiden suorituksia. Kirjanpito tarjoaa nopean pääsyn kurssiin liittyviin suorituksiin ja opiskelijan suorituksiin. Alla oletetaan, että kurssien ja opiskelijoiden nimet ovat yksikäsitteiset.

function Kirjanpito() {
    // kaikki suoritukset
    this.kurssisuoritukset = new Array();

    // opiskelijakohtaiset suoritukset
    this.opiskelijanSuoritukset = {};

    // kurssikohtaiset suoritukset
    this.kurssinSuoritukset = {};
}

Kirjanpito.prototype.lisaaSuoritus = function(kurssi, opiskelija, arvosana) {
    var suoritus = new Kurssisuoritus(opiskelija, kurssi, arvosana);

    // metodilla push lisätään listaan
    this.kurssisuoritukset.push(suoritus);

    // erilliset metodit opiskelijakohtaisten ja kurssikohtaisten suoritusten
    // lisäämiseen
    this.lisaaOpiskelijanSuoritus(opiskelija.nimi, suoritus);
    this.lisaaKurssinSuoritus(kurssi.nimi, suoritus);
}

Kirjanpito.prototype.lisaaOpiskelijanSuoritus = function(opiskelijanNimi, suoritus) {
    // jos opiskelijan nimellä ei ole yhtäkään suoritusta, on nimellä
    // saatava arvo false -- luodaan tällöin lista suorituksille
    if(!this.opiskelijanSuoritukset[opiskelijanNimi]) {
        this.opiskelijanSuoritukset[opiskelijanNimi] = new Array();
    }

    this.opiskelijanSuoritukset[opiskelijanNimi].push(suoritus);
}

Kirjanpito.prototype.lisaaKurssinSuoritus = function(kurssinNimi, suoritus) {
    if(!this.kurssinSuoritukset[kurssinNimi]) {
        this.kurssinSuoritukset[kurssinNimi] = new Array();
    }

    this.kurssinSuoritukset[kurssinNimi].push(suoritus);
}

Kirjanpito.prototype.haeOpiskelijanKurssisuoritukset = function(opiskelijanNimi) {
    return this.opiskelijanSuoritukset[opiskelijanNimi];
}

Kirjanpito.prototype.haeKurssinSuoritukset = function(kurssinNimi) {
    return this.kurssinSuoritukset[kurssinNimi];
}

Kirjanpito.prototype.haeKaikkiSuoritukset = function() {
    return this.kurssisuoritukset;
}

Kirjanpidon käyttäminen on helpohkoa. Alla olevassa esimerkissä luodaan kurssi, opiskelija ja kirjanpito-olio, sekä lisätään suoritus kirjanpitoon. Tämän jälkeen kaikki suoritukset lisätään dokumentissa olevaan "data"-tunnuksella merkittyyn elementtiin. Käytössämme on myös apufunktio lisaaSuoritusteksti, joka lisää annettuun elementtiin tekstielementin, jossa on suorituksen tiedot.

function lisaaSuoritusteksti(elementti, suoritus) {
   var teksti = suoritus.kurssi.nimi + " " + suoritus.opiskelija.nimi + " " + suoritus.arvosana;
   elementti.appendChild(document.createTextNode(teksti));
}

var weso = new Kurssi("Web-selainohjelmointi");
var mikke = new Opiskelija("Michael");
var kirjanpito = new Kirjanpito();

kirjanpito.lisaaSuoritus(weso, mikke, 1);

var suoritukset = kirjanpito.haeKaikkiSuoritukset();
var data = document.getElementById("data");

for(var i = 0; i < suoritukset.length; i++) {
    var elementti = document.createElement("p");

    lisaaSuoritusteksti(elementti, suoritukset[i]);
    data.appendChild(elementti);
}

Ylläolevaa sovellusta voisi käyttää hyvin myös käyttöliittymästä. Käytännössä tällöin HTML-dokumenttiin luotaisi lomake, jonka kautta kurssisuorituksia lisättäisiin. Näiden lisäksi todennäköisesti käytössä olisi omat lomakkeet kurssien ja opiskelijoiden lisäämiselle, jolloin kurssin ja opiskelijan voisi hakea kätevästi listasta.

Tavara, Matkalaukku, Ruuma (2p)

Jokaisella tavaralla on nimi ja paino. Matkalaukkuun lisätään tavaroita, ja matkalaukulla on maksimipaino. Ruumaan taas lisätään matkalaukkuja, ja myös ruumalla on maksimipaino. Matkalaukkuun voi lisätä vain tavaroita, ja ruumaan vain matkalaukkuja. Jos matkalaukun ja uuden tavaran yhteispaino on suurempi kuin matkalaukun maksimipaino, ei tavaraa voida lisätä. Vastaavasti ruumalle.

Toteuta olioita luovat funktiot Tavara, Matkalaukku, ja Ruuma lähdekooditiedostoon code.js. Voit käyttää alla olevaa (ja tehtäväpohjassa tulevaa) testikoodia toteutustesi testaamiseen.

var kivi = new Tavara("kivi", 3);
var kirja = new Tavara("kirja", 7);
var pumpuli = new Tavara("pumpuli", 0.001);

var laukku = new Matkalaukku(10);
var vuitton = new Matkalaukku(3);

var schenker = new Ruuma(15);


laukku.lisaa(kivi);
console.log("laukun paino, pitäisi olla 3: " + laukku.paino());
laukku.lisaa(kivi); // virhe: "Tavara lisätty jo, ei onnistu!"

laukku.lisaa(kirja);
console.log("laukun paino, pitäisi olla 10: " + laukku.paino());

laukku.lisaa(pumpuli); // virhe: "Liian painava, ei pysty!"

console.log("laukun paino, pitäisi olla 10: " + laukku.paino());


schenker.lisaa(laukku);
schenker.lisaa(pumpuli); // virhe: Vääränlainen esine, ei onnistu!

console.log("Ruuman paino, pitäisi olla 10: " + schenker.paino());

vuitton.lisaa(pumpuli);
schenker.lisaa(vuitton);
console.log("Ruuman paino, pitäisi olla noin 10.001: " + schenker.paino());

pumpuli.paino = 300;
console.log("Ruuman paino, pitäisi olla 310: " + schenker.paino()); // hups!

Palauta tehtävä TMC:lle kun se toimii toivotusti.

Moduulit

Kun tutkimme edellistä esimerkkiä tarkemmin, huomaamme että prototyyppien avulla määritelty oliotoiminnallisuus ei kapseloi olioiden muuttujia, vaan niihin pääsee käsiksi suoraan. Attribuuttien kapseloinnista on kuitenkin monia hyötyjä, joista tärkein lienee se, että olion sisäistä rakennetta voidaan muuttaa ilman että sitä käyttäviä sovelluksia tarvitsee muuttaa.

Ylläolevaa kirjanpitoa käyttävä ohjelmoija voisi epähuomiossa lisätä uusia kurssisuorituksia esimerkiksi suoraan kirjanpidon sisäiseen muuttujaan kurssisuoritukset, jolloin opiskelijakohtaisia suoritustietoja ei löytyisi nykyisellä toteutuksella.

var weso = new Kurssi("Web-selainohjelmointi");
var mikke = new Opiskelija("Michael");
var kirjanpito = new Kirjanpito();

kirjanpito.kurssisuoritukset.push(new Kurssisuoritus(weso, mikke, 1));

Kun ohjelmoijat käyttävät Kirjanpito-funktion määrittelemää ohjelmointirajapintaa, eli sen funktioita, on ohjelman laajentaminen helpompaa. Tällöin ei tarvitse huolehtia siitä, että esimerkiksi suorituspäivämäärän perusteella tapahtuva haku olisi rikki jo alusta lähtien koska joku käyttää valmista koodia väärin.

Eräs tapa tiedon kapselointiin on Module Pattern-suunnittelumallia. Ennen siihen tutustumista, tutustutaan kuitenkin anonyymeihin funktioihin ja sulkeumiin (Closure).

Anonyymit funktiot

Anonyymit funktiot ovat funktioita, joita ei kiinnitetä muuttujiin, jolloin ne eivät saa nimeä. Esimerkiksi seuraavassa ohjelmassa käytetään anonyymiä funktiota lukuvälin lukujen summan laskemiseen. Mielenkiintoista alla olevassa koodissa on se, että funktio on olemassa vain sen suorituksen ajan.

var lopputulos = (function(alku, loppu) {
                      var summa = 0;
                      for (var i = alku; i < loppu; i++) {
                          summa += i;
                      }
                      return summa;
                  })(1, 3);

console.log(lopputulos); // 3

Sulkeumat

Sulkeumat ovat yleisesti ottaen lauseita, jotka voivat sisältää muuttujia, mutta jotka piilottavat muuttujat sisäänsä. Käytännössä sulkeumia luodaan luomalla funktioita funktion sisään, jolloin ulompi funktio kapseloi sisältönsä. Tutustutaan sulkeumiin hieman tarkemmin.

Olemme aiemmin huomanneet, että funktioiden sisällä määritellyt var -muuttujat eivät näy koko ohjelmalle, vaan ne ovat näkyvillä vain sen funktion sisällä, jossa ne on määritelty. Esimerkiksi seuraavassa funktiossa rajoitettuSumma funktio sisältää muuttujan summa, jota ei näy funktion ulkopuolelle. Kun funktio palauttaa arvon, se palauttaa kopion summa-muuttujan sisällöstä.

function rajoitettuSumma(alku, loppu) {
    var summa = 0;
    for (var i = alku; i < loppu; i++) {
        summa += i;
    }

    return summa;
}
var tulos = rajoitettuSumma(1, 2);
console.log(tulos); // 1

Koska JavaScriptissä muuttujat voivat olla funktioita, voimme palauttaa funktiosta toisen funktion.

function pankki() {
    return function(alku, loppu) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        return summa;
    }
}

Kun käyttäjä kutsuu ylläolevaa funktiota, palauttaa funktio toisen funktion. Palautettu funktio toteuttaa aiemmin näkemämme funktion rajoitettuSumma toiminnallisuuden.

var funktio = pankki();
console.log(funktio(1, 2)); // 1

Oikeastaan, funktion pankki sisälle voi luoda muuttujan rajoitettuSumma, jonka voimme palauttaa.

function pankki() {

    var rajoitettuSumma = function(alku, loppu) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        return summa;
    };

    return rajoitettuSumma;
}

Yllä oleva toiminnallisuus vastaa aiempaa funktiota. Muutetaan funktion rajoitettuSumma toimintaa siten, että muuttuja loppu määritellään funktion pankki sisällä paikalliseksi muuttujaksi.

function pankki() {
    var loppu = 2;

    var rajoitettuSumma = function(alku) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        return summa;
    };

    return rajoitettuSumma;
}

Koska JavaScriptissä on funktionäkyvyys, näkyy muuttuja loppu funktion pankki sisällä olevalle funktiolle. Funktiossa rajoitettuSumma voidaan muuttaa muuttujan loppu arvoa -- itseasiassa muuttuja loppu on olemassa useamman rajoitettuSumma-funktiokutsun ajan.

function pankki() {
    var loppu = 2;

    var rajoitettuSumma = function(alku) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        loppu++;
        return summa;
    };

    return rajoitettuSumma;
}
var funktio = pankki();
console.log(funktio(1)); // tulostaa 1
console.log(funktio(1)); // tulostaa 3
console.log(funktio(1)); // tulostaa 6
console.log(funktio(1)); // tulostaa 10

Voimme palauttaa pankista olion. Alla olevassa esimerkissä palautamme olion, jonka attribuutti alhaaltaRajoitettuSumma käyttää funktiota rajoitettuSumma.

function pankki() {
    var loppu = 2;

    var rajoitettuSumma = function(alku) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        loppu++;
        return summa;
    };

    return {
        alhaaltaRajoitettuSumma: rajoitettuSumma
    };
}

Nyt funktio pankki palauttaa olion, jolla on attribuutti alhaaltaRajoitettuSumma. Attribuuttiin pääsee käsiksi aivan kuten olioiden attribuutteihin normaalistikin.

var funktio = pankki();
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 1
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 3
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 6
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 10

// MUTTA!
console.log(funktio.rajoitettuSumma(1)); // ei onnistu!

Funktioon rajoitettuSumma ei kuitenkaan pääse suoraan käsiksi!

Luodaan pankille vielä toinen funktio, joka asettaa muuttujan loppu arvon.

function pankki() {
    var loppu = 2;

    var rajoitettuSumma = function(alku) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        loppu++;
        return summa;
    };

    var asetaLoppu = function(uusiLoppu) {
        loppu = uusiLoppu;
    }

    return {
        alhaaltaRajoitettuSumma: rajoitettuSumma,
        asetaLoppu: asetaLoppu
    };
}
var funktio = pankki();
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 1

funktio.asetaLoppu(4);
console.log(funktio.alhaaltaRajoitettuSumma(1)); // tulostaa 10

Yllä olevat funktiot voidaan kirjoittaa myös siten, että niille määritellään nimi osana funktiomäärittelyä. Tällöin erilliselle muuttujalle ei ole tarvetta.

function pankki() {
    var loppu = 2;

    function rajoitettuSumma(alku) {
        var summa = 0;
        for (var i = alku; i < loppu; i++) {
            summa += i;
        }

        loppu++;
        return summa;
    };

    function asetaLoppu(uusiLoppu) {
        loppu = uusiLoppu;
    }

    return {
        alhaaltaRajoitettuSumma: rajoitettuSumma,
        asetaLoppu: asetaLoppu
    };
}

Moduulit

Module Pattern hyödyntää sekä anonyymejä funktioita että sulkeumia sovelluksen toimintalogiikan kapselointiin. Ajatuksena on luoda ensin nimiavaruudessa käytettävä muuttuja, jonka kautta sovelluksen eri osia käytetään. Luodaan olio hallinta, johon lisätään kirjanpitotoiminnallisuus.

// huom! luodaan tyhjä olio, jolle voi lisätä attribuutteja
var hallinta = {};

hallinta.kirjanpito = (function() {
    var kurssisuoritukset = new Array();
    var opiskelijanSuoritukset = {};
    var kurssinSuoritukset = {};

    function lisaaSuoritus(kurssi, opiskelija, arvosana) {
        var suoritus = new Kurssisuoritus(opiskelija, kurssi, arvosana);

        // metodilla push lisätään listaan
        kurssisuoritukset.push(suoritus);

        // erilliset metodit opiskelijakohtaisten ja kurssikohtaisten suoritusten
        // lisäämiseen
        lisaaOpiskelijanSuoritus(opiskelija.nimi, suoritus);
        lisaaKurssinSuoritus(kurssi.nimi, suoritus);
    }

    function haeOpiskelijanKurssisuoritukset(opiskelijanNimi) {
        return opiskelijanSuoritukset[opiskelijanNimi];
    }

    function haeKurssinSuoritukset(kurssinNimi) {
        return kurssinSuoritukset[kurssinNimi];
    }

    function haeKaikkiSuoritukset() {
        return kurssisuoritukset;
    }

    // apufunktiot
    function lisaaOpiskelijanSuoritus(opiskelijanNimi, suoritus) {
        // jos opiskelijan nimellä ei ole yhtäkään suoritusta, on nimellä
        // saatava arvo false -- luodaan tällöin lista suorituksille
        if(!opiskelijanSuoritukset[opiskelijanNimi]) {
            opiskelijanSuoritukset[opiskelijanNimi] = new Array();
        }

        opiskelijanSuoritukset[opiskelijanNimi].push(suoritus);
    }

    function lisaaKurssinSuoritus(kurssinNimi, suoritus) {
        if(!kurssinSuoritukset[kurssinNimi]) {
            kurssinSuoritukset[kurssinNimi] = new Array();
        }

        kurssinSuoritukset[kurssinNimi].push(suoritus);
    }

    // julkaistava rajapinta
    return {
        lisaaSuoritus: lisaaSuoritus,
        haeOpiskelijanSuoritukset: haeOpiskelijanKurssisuoritukset,
        haeKurssinSuoritukset: haeKurssinSuoritukset,
        haeKaikkiSuoritukset: haeKaikkiSuoritukset
    };
})();

Yllä oleva moduuli kapseloi kurssihallinnan siten, että hallinnan kapseloimiin tietueisiin ei pääse käsiksi. Voimme jatkossa käyttää kirjanpito-ohjelmaa seuraavasti:

var weso = new Kurssi("Web-selainohjelmointi");
var mikke = new Opiskelija("Michael");

// EI ONNISTU!
// hallinta.kirjanpito.kurssisuoritukset.push(new Kurssisuoritus(weso, mikke, 1));

// ONNISTUU!
hallinta.kirjanpito.lisaaSuoritus(weso, mikke, 1);

Kapseloitu laskuri (1p)

Toteuta tehtäväpohjaan laskin aiemmin esitellyllä Module Patternilla. Laskimen tulee kapseloida muuttuja luku ja tarjoata funktiot kasvata, joka kasvattaa luvun arvoa yhdellä, ja annaLuku, joka palauttaa luvun. Luo laskin tehtäväpohjassa esiteltyyn muuttujaan var laskin;.

Huom! Laskimen sisäiseen rakenteeseen ei tule voida vaikuttaa muuten kuin funktioiden kasvata ja annaLuku kautta.

Palauta sovelluksesi TMC:lle kun se toimii toivotusti.

Henkilolista (2p)

Tehtäväpohjaan on hahmoteltu henkilöiden hallintaan sopivan sovelluksen rakennetta. Tehtäväsi on jatkaa sitä eteenpäin siten, että henkilöiden lisääminen sovellukseen onnistuu.

Tehtävänäsi on toteuttaa:

  1. Lomakkeen napin käsittelytoiminnallisuus (manager.gui.buttonPressed())
  2. Henkilön lisääminen (manager.data.addPerson(person))
  3. Henkilöiden listaaminen (manager.data.list())

Sekä lisätä manager.data-moduuliin funktioiden sopiva näkyvyys. Tehtäväpohjassa on lisää ohjeita.

Huom! Älä poikkea jo hahmotellusta module pattern-suunnittelumallia seuraavasta rakenteesta. Ainoa manager-nimialueen ulkopuolella oleva funktiokutsu on sovellukseen jo määritelty init.

Kun sovelluksesi toimii palauta se TMC:lle.

Keskustelu palvelimen kanssa

Selaimessa toimivia ohjelmistoja rakennettaessa yhdeksi kysymykseksi tulee sovelluksen käyttämän datan säilöminen. Jotta sovelluksen sisältämä tieto olisi kaikkien käyttäjien saatavilla, tulee se tallentaa erilliselle palvelimelle, jonne kaikilla on pääsy. Palvelinohjelmistojen suunnittelu ja toteuttaminen on hyvin laaja alue, jota esimerkiksi kurssi Web-palvelinohjelmointi raapaisee. Kurssin materiaali löytyy osoitteesta https://wepa-2014.herokuapp.com/material/public_html/index.html.

Käytännössä keskustelu palvelimen kanssa tapahtuu HTTP-protokollaa käyttäen. HTTP-protokolla on tekstimuotoinen protokolla, joka sisältää pyyntötyyppejä erilaisten pyyntöjen tekemiseen. Pyyntötyypillä GET haetaan dataa, POST lähetetään dataa palvelimelle, ja DELETE poistetaan dataa. Selaimet tarjoavat abstraktiokerroksen HTTP-protokollan päälle -- itseasiassa kun selaimessa haetaan web-sivua jostain osoitteesta, esimerkiksi osoitteesta http://www.cs.helsinki.fi/group/java/s12-wepa/, selain tekee HTTP-pyynnön osoitteessa www.cs.helsinki.fi -olevalle palvelimelle, ja pyytää sieltä polussa /group/java/s12-wepa/ olevaa resurssia.

Selainohjelmistoja rakennettaessa palvelimelta voidaan pyytää kokonaista HTML-dokumenttia, tai pienempää datamäärää. Nykyään uusissa sovelluksissa suosituin datan siirtoformaatti on JSON.

JSON

JSON (JavaScript Object Notation) on JavaScriptin käyttämä tiedonsiirtoformaatti, jonka suosio perustuu helppoon JavaScript-olioiksi muuttamiseen. Olemme aiemmin luoneet JavaScript-olioita seuraavanlaisella notaatiolla.

var mikke = {nimi: "Mikke", syntymavuosi: 1984};

JSON-formaatti on hyvin samannäköinen. JSON-muodossa data kuvataan merkkijonoina. Luodaan ylläolevaa oliota kuvaava JSON-merkkijono mikkeData. Huomaa kahden erilaisen hipsun käyttö!

var mikkeData = '{"nimi": "Mikke", "syntymavuosi": 1984}';

Nyt käytössämme on merkkijono, jonka sisältö näyttää lähes samalta kuin aiemmin olion luontiin käyttämämme notaatio. Miten tästä saa olion?

JavaScript tarjoaa toiminnallisuuden merkkijonon JavaScript-olioksi ja takaisin muuttamiseen. Funktio JSON.parse muuttaa parametrina annetun merkkijonon JavaScript-olioksi, ja funktio JSON.stringify muuttaa parametrina annetun JavaScript olion merkkijonoksi.

var mikkeData = '{"nimi": "Mikke", "syntymavuosi": 1984}';
var mikke = JSON.parse(mikkeData);
console.log(mikke.nimi);

var mikkeKlooni = JSON.stringify(mikke);
console.log(mikkeKlooni);

Helppoa kuin heinänteko.

Datan hakeminen palvelimelta

Datan noutamiseen palvelimelta käytetään muutamaa erillistä lähestymistapaa. Suurin osa selaimista tukee XMLHttpRequest-oliota, jonka avulla voidaan luoda pyyntöjä palvelimelle. Käytännössä pyynnön lähettäminen ja käsittely tapahtuu kolmessa vaiheessa. Ensin luodaan XMLHttpRequest-olio, sitten määritellään oliolle vastauksen käsittelevä funktio, ja lopuksi lähetetään pyyntö.

// pyyntöolion luonti
var req = new XMLHttpRequest();

// mitä tehdään kun saadaan vastaus (vastauksia voi olla useita)
req.onreadystatechange = function() {
    // jos tila ei ole valmis, ei käsitellä
    if (req.readyState !== this.DONE) {
        console.log("state " + req.readyState);
        return false;
    }

    // jos statuskoodi ei ole 200 (ok), ei käsitellä
    if (req.status !== 200) {
        console.log("status " + req.status);
        return false;
    }

    // näytetään vastaus
    console.log(req.responseText);
}

req.open("GET", "data.json", true);
req.send();

Tutkitaan yllä olevaa koodia hieman tarkemmin. Palvelin voi palauttaa XMLHttpRequest-pyyntöön useamman vastauksen. Attribuutti readyState sisältää arvon väliltä [0, 4], missä 4 tarkoittaa pyynnön olevan valmis. Numeroarvoja vastaavat vakiot (pienimmästä suurimpaan) UNSENT, OPENED, HEADERS_RECEIVED, LOADING sekä DONE joihin päästään käsiksi this:n kautta. Jos attribuutin readyState arvo ei ole neljä (DONE), odotamme lopullista vastausta. Attribuutti status kertoo HTTP-pyynnön statuskoodin. Statuskoodi 200 kertoo pyynnön onnistuneen. Lisää tietoa statuskoodeista löytyy esimerkiksi googlella ja täältä.

Ylläolevassa esimerkiksi vastaus näytetään console.log-komennon avulla. Käytännössä JSON-dataa sisältävän vastauksen voisi muuttaa JSON.parse-funktiolla olioksi.

Tärkeä osa liittyy pyynnön avaamiseen. Komento req.open("GET", "data.json", true); avaa GET-tyyppisen HTTP-yhteyden nykyiseen sivustoon liittyvään osoitteeseen data.json. Koska viimeinen parametri on true, on pyyntötyyppi asynkroninen, eikä selain jää odottamaan vastausta. Vastaukseen reagoidaan kun vastaus saapuu. Asynkronisuuden määrittävä boolenia ei kuitenkaan vaadita ja pyyntö on oletusarvoisesti asynkroninen jollei toisin määritetä. Täten parametri täytyy erikseen asettaa vain jos sen arvoksi haluaa false.

Kun kokeilet ylläolevaa koodia eri osoitteilla, huomaat että datan hakeminen ei aina onnistu. XMLHttpRequest-pyyntöihin liittyy tietoturvarajoitteita, jotka oletuksena rajoittavat pyynnön tekemisen samaan osoitteeseen.

Pyyntöjen tekeminen oman palvelimen ulkopuolelle

Pyyntöjen tekemistä eri osoitteisiin rajoittaa ns. "Same origin policy", jolla pyritään rajoittamaan muunmuassa pyynnön mukana lähetettävän datan (evästeet, kirjautumistiedot ym.) päätymistä vääriin käsiin. Sivustot, jotka koostavat useampia palveluita yhteen kuitenkin tarvitsevat pääsyn ulkopuoliseen dataan.

W3C työskentelee CORS (Cross-origin resource sharing)-spesifikaation kanssa parhaillaan. CORS-spesifikaation tavoitteena on määritellä tuki domain-riippumattomalle resurssien jakamiselle. Käytännössä tuki vaatii sen, että palvelinohjelmiston vastauksessa on otsakkeet, jotka kertovat osoitteet, joissa haettua dataa voi käyttää.

Rajoituksen kiertämiseen on kehitetty useita tekniikoita, mm. proxy-mekanismi, iframe-elementtien kanssa toimiminen, ja JSONP. Proxy-mekanismissa paikalliselle palvelimelle luodaan skripti, joka hakee kolmannen osapuolen datan paikalliselle palvelimelle, jolloin selaimen näkökulmasta data on paikallista. IFrame-elementtiä käytettäessä taas sivu haetaan erilliseen iframe-elementtiin, josta haetaan tarvitut osat.

Pyyntöjen tekeminen omalle koneelle

Osa selaimista kieltää pyyntöjen tekemisen suoraan tiedostojärjestelmään. Voit kiertää tämän esimerkiksi chromessa käynnistämällä chromen parametrilla "--disable-web-security".

Chuckles (2p)

Tutustu ICNDb.com-osoitteessa olevaan JSON-apiin ja toteuta toiminnallisuus, jonka avulla voit hakea satunnaisia viestejä.

Käytä osoitetta http://api.icndb.com/jokes/random/3 testaamiseen.

Yllä olevalla osoitteella sivun tulee näyttää esimerkiksi seuraavalta.

Kun sovelluksesi toimii, ja näet viestit index.html-sivulla, palauta se TMC:lle.

JSONP

JSONP (JSON with padding) hyödyntää tietoa siitä, että script-elementin osoite, eli paikka josta JavaScript-lähdekoodi haetaan, ei ole rajoitettu. Data haetaan asettamalla JavaScriptillä luotavalle script-elementille src-attribuutti, johon asetetaan JSONP-muotoista dataa tarjoavan palvelimen osoite.

Dataa haettaessa pyynnölle annetaan parametrina funktion nimi, jonka nimisellä funktiolla data tulee kapseloida palvelinpäässä. Esimerkiksi, jos pyyntö tehdään osoitteeseen palvelin/data.jsonp?callback=handleResponse, on vastauksessa tulevan datan oltava muotoa handleResponse(data). Funktio handleResponse on määritelty osana dataa hakevaa sivua.

// aiemmin määritelty funktio handleResponse
function handleResponse(content) {
    console.log(content);
}
// dataa haettaessa tehtävä kutsu. Haetaan tietyssä osoitteessa olevasta palvelusta
// jsonp-muotoista dataa.
var script = document.createElement("script");
script.setAttribute("src", "osoite/data.jsonp?callback=handleResponse");
document.body.appendChild(script);

Sivu voi palauttaa esimerkiksi seuraavanlaisen vastauksen vastauksen.

handleResponse({"nimi":"mikke", "ika":17})

Datan lähettäminen palvelimelle

Datan lähettämiseen liittyy samat haasteet kuin datan vastaanottamiseen. Yksinkertaisin tapa lähettää tietoa palvelimelle on XMLHttpRequest-olion GET-pyyntö siten, että lähetettävä data asetetaan mukaan pyynnön osoitteeseen. Käytännössä parametrina oleva data käsitellään palvelinpuolella pyyntöä kuuntelevassa ohjelmistossa.

// pyyntöolion luonti
var req = new XMLHttpRequest();
var parametrit = "nimi=mikke&ika=17";
req.open("GET", "dataprocessor.html?" + parametrit);
req.send();

GET-pyyntö on hieman huono siinä mielessä, että lähetettävä data näkyy kaikkialle. Esimerkiksi jos pyyntö kulkee useamman reitittimen läpi ennen pääsyä palvelimelle, jokainen reititin näkee parametrit. Toinen vaihtoehto on POST-pyyntö, jossa data lähetetään osana pyynnön runkoa. Tällöin pyynnölle tulee myös määritellä lähetettävän datan muoto. Allaolevassa esimerkissä sanomme datan olevan lomakkeelta.

var req = new XMLHttpRequest();
var data = "nimi=mikke&ika=17";
req.open("POST", "dataprocessor.html");

req.setRequestHeader("Content-Type","application/x-www-form-urlencoded");

req.send(data);

JSON-muotoisen datan lähettäminen osana POST-pyyntöä onnistuu vastaavasti.

var mikke = {nimi: "Michael", ika: 17};
var data = JSON.stringify(mikke);

var req = new XMLHttpRequest();
req.open("POST", "jsonprocessor.html");

req.setRequestHeader("Content-Type","application/json");

req.send(data);

Submission (1p)

Toteuta tehtäväpohjassa olevaan lähdekooditiedostoon submission.io-moduuli, jolla on kaikille näkyvä funktio send. Funktio send saa parametrina JavaScript-olion, joka tulee lähettää palvelimelle JSON-muodossa.

Lähetä data osoitteeseen http://bad.herokuapp.com/app/in. Voit tarkistaa menikö data perille osoitteessa http://bad.herokuapp.com/app/out. Ennenkuin aloitat, kannattaa vierailla sivulla http://bad.herokuapp.com/ niin varmistat että sovellus on päällä.

Kun sovelluksesi lähettää dataa palvelimelle, ja näet lähetetyn datan palvelimella, palauta tehtävä TMC:lle. Jos data menee palvelimelle, ja näet virheen "XMLHttpRequest cannot load http://bad.herokuapp.com/app/in. Origin null is not allowed by Access-Control-Allow-Origin." -- älä välitä siitä. Sovellus herokussa ei ole konfiguroitu täysin oikein.

Chat-chat (4p)

Tämä on avoin tehtävä, jonka tekemisestä saa 4 pistettä. Kannattaa varata tehtävän tekemiseen reilusti aikaa -- voit mahdollisesti myös tehdä tätä tehtävää ennen seuraavan tehtävän.

Osoitteessa http://bad.herokuapp.com/app/ toimii chat-sovelluksen backend-toiminnallisuus. Tässä tehtävässä rakennetaan chatille selainpuolen toiminnallisuus.

Sisäänkirjautuminen

Kun käyttäjä avaa chat-sivun, näytetään hänelle login-näkymä, joka näyttää seuraavalta.

Kun käyttäjä kirjoittaa käyttäjätunnuksen ja painaa Login-nappia, selainsovellus lähettää palvelimelle JSON-merkkijonon, joka on muotoa { "nickname": nick }, missä nick on käyttäjän kirjoittama käyttäjätunnus. Kirjautumispyyntö tehdään HTTP POST-pyyntönä osoitteeseen http://bad.herokuapp.com/app/auth. Jos kirjautuminen onnistuu, palvelin palauttaa statuskoodin 200, muuten statuskoodi on jokin muu. Ilman kirjautumista palvelimelle ei voi lähettää viestejä.

Viestien listaaminen

Kirjautumisen onnistuessa käyttäjälle näytetään chat-näkymä, joka näyttää tyhjänä seuraavalta:

Kirjautumisen yhteydessä palvelimelta tulee myös hakea lista viimeisimmistä viesteistä, jotka näytetään näkymässä. Viestit saa haettua HTTP GET-pyynnöllä osoitteesta http://bad.herokuapp.com/app/messages. Jos viestien hakeminen onnistuu, palvelin palauttaa statuskoodin 200, muuten statuskoodi on jokin muu.

Palvelin palauttaa viimeisimmät viestit JSON-taulukossa (array). Yksittäinen viesti sisältää seuraavat tiedot:

{
    "id": 4,
    "timestamp": 1352114153691,
    "nickname": "El Barto",
    "message": "Hello world!"
}

Viestin aikaleima kuvaa millisekunteja epoch-ajankohdasta (1.1.1970). Esimerkiksi JavaScriptin Date-oliot osaavat tulkita tällaista lukua ja palauttaa normaalin päivämäärän ja kellonajan sen perusteella. Palvelin palauttaa viestit siten, että uusin viesti on ensimmäinen.

Jos palvelimelle on jo lähetetty aiemmin viestejä, kirjautumisen jälkeen chat-näkymä näyttää esimerkiksi seuraavalta:

Viestin lähetys

Toteuta viestin lähettäminen chat-näkymään. Send-nappia painettaessa sovelluksen tulee lähettää tekstikentässä oleva viesti palvelimelle. Uusi viesti tulee lähettää HTTP POST-pyynnöllä osoitteeseen http://bad.herokuapp.com/app/messages. Pyynnössä lähetettävä viesti näyttää esimerkiksei seuraavalta:

{
    "nickname": "El Barto",
    "message": "Huh-huh!"
}

Hae viestin lähettämisen jälkeen palvelimelta uusimmat viestit ja päivitä chat-näkymä saaduilla viesteillä, jotta juuri lähetetty viesti näkyy sivulla.

Viestien päivittäminen ja uloskirjautuminen

Toteuta chat-näkymän Refresh-napin toiminnallisuus. Napin painalluksen tulee hakea palvelimelta uusimmat viestit ja näyttää ne chat-näkymässä.

Toteuta chat-näkymän Logout-napin toiminnallisuus. Napin painalluksen tulee piilottaa chat-näkymä ja palauttaa login-näkymä sivulle, jotta chattiin voi kirjautua uudella nimimerkillä. Nimimerkille varatun tekstikentän tulee olla tyhjä. Samoin uudelleen kirjauduttaessa sisään chat-näkymän viestille varatun tekstikentän tulee olla tyhjä.

Kun olet valmis, lähetä toteutuksesi TMC:lle.

Oliot ja Moduulit

Syvennytään lisää olioihin ja moduuleihin.

Oliot

Oliot ovat funktioiden ilmentymiä, jotka luodaan new-avainsanalla. Oliolla on oma oliokohtainen tila, mihin pääsee käsiksi this-operaattorilla. Esimerkiksi alla on luotu funktio Kirja, josta voi luoda olioita. Kirjalle on määritelty attribuutit nimi ja julkaisuvuosi.

function Kirja(nimi, julkaisuvuosi) {
    this.nimi = nimi;
    this.julkaisuvuosi = julkaisuvuosi;
}

// funktiosta luodaan olio new-operaattorilla
kalevala = new Kirja("Kalevala", 1835);

Olioille määritellään metodeja prototyyppiperinnän avulla. Prototyypin muokkauksen jälkeen olioilla on käytössä juuri määritellyt funktiot.

function Kirja(nimi, julkaisuvuosi) {
    this.nimi = nimi;
    this.julkaisuvuosi = julkaisuvuosi;
}

Kirja.prototype.tulostaNimi = function() {
    console.log(this.nimi);
}

// funktiosta luodaan olio new-operaattorilla
kalevala = new Kirja("Kalevala", 1835);
kalevala.tulostaNimi(); // Kalevala

elefantinMatka = new Kirja("Elefantin matka", 2008);
elefantinMatka.tulostaNimi(); // Elefantin matka

Metodit määritellään käytännössä aina heti olion luovan funktion määrittelyn jälkeen. Käytännössä new Kirja("Kalevala", 1835)-kutsun suorituksessa uusi kalevala-olio peritään Kirja.prototype -prototyypistä. Tämän jälkeen sen konstruktori suoritetaan, ja sille allokoidaan olion attribuuttien tarvitsema tila. Lopulta konstruktori palauttaa viitteen uuteen olioon.

JavaScript ei mahdollista this-operaattorilla esiteltyjen attribuuttien kapselointia, vaan ne ovat julkisia.

function Kirja(nimi, julkaisuvuosi) {
    this.nimi = nimi;
    this.julkaisuvuosi = julkaisuvuosi;
}

Kirja.prototype.tulostaNimi = function() {
    console.log(this.nimi);
}

// funktiosta luodaan olio new-operaattorilla
kalevala = new Kirja("Kalevala", 1835);

// olion attribuutteihin pääsee käsiksi suoraan
kalevala.nimi = "Valekala";

kalevala.tulostaNimi(); // Valekala

Konstruktorifunktiot nimetään isolla alkukirjaimella, olioiden nimet pienellä alkukirjaimella.

Puhelinmuistio (1p)

Toteuta funktio Puhelinmuistio, joka luo puhelinmuistio-olion. Puhelinmuistioon voi lisätä nimiä ja numeroita. Jokaiseen nimeen voi liittyä useampi numero. Numeroiden lisäämisen tulee tapahtua lisaaNumero-funktiolla, ja puhelinmuistion tulee tarjota metodi annaNumerot, jolle annetaan parametrina nimi.

Jos samalle henkilölle yritetään asettaa sama numero useampaan kertaan, numero tallennetaan henkilölle vain kerran. Useammalla henkilöllä voi olla sama numero.

Tehtäväpohjan mukana olevalle HTML-sivulle ei tarvitse tehdä mitään. Voit hyödyntää tehtäväpohjassa tulevaa Array-funktion laajennusta contains omassa toteutuksessasi. Kun tehtävä toimii seuraavilla esimerkeillä, palauta se TMC:lle.

muistio = new Puhelinmuistio();
muistio.lisaaNumero("mikke", "044-33669933");
muistio.lisaaNumero("mikke", "044-33669933");
console.log(muistio.annaNumerot("mikke")); // numero 044-33669933 vain kerran

muistio.lisaaNumero("mikke", "231");
console.log(muistio.annaNumerot("mikke")); // numerot 044-33669933 ja 231

console.log(muistio.annaNumerot("matti")); // tyhjä lista
muistio.lisaaNumero("matti", "1111");
console.log(muistio.annaNumerot("matti")); // numero 1111

console.log(muistio.annaNumerot("mikke")); // numerot 044-33669933 ja 231

Viikko 3

Moduulit

Moduulit toteutetaan anonyymien funktioiden avulla. Muuttujien funktionäkyvyyden takia muuttujat voidaan kapseloida anonyymin funktion sisään, jolloin niihin ei pääse käsiksi funktion ulkopuolelta. Anonyymin funktion sisälle määritellyt funktiot pääsevät käsiksi muuttujiin, jolloin sisäfunktioissa voidaan muokata muuttujien arvoja. Moduuli palauttaa moduulissa määritellyn rajapinnan, jossa on viittaukset sisäfunktioihin.

Hahmotellaan kaupan hallinnointiin tarvittavaa järjestelmää. Luodaan ostoskorimoduuli, joka tarjoaa julkisen rajapinnan tuotteiden lisäämiseen ja tuotteiden lukumäärän laskemiseen.

var kauppa = {};

kauppa.ostoskori = (function() {
    var ostokset = [];

    function lisaaOstos(tuotteenNimi) {
        if(!ostokset[tuotteenNimi]) {
            // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
            ostokset[tuotteenNimi] = 0;
        }

        // kasvatetaan tuotteen lukumäärää yhdellä
        ostokset[tuotteenNimi]++;
    }

    function tuotteitaYhteensa() {
        var lukumaara = 0;
        for(var tuotteenNimi in ostokset) {
            lukumaara += ostokset[tuotteenNimi];
        }

        return lukumaara;
    }

    // rajapinta
    return {
        lisaa: lisaaOstos,
        tuotteidenLukumaara: tuotteitaYhteensa
    };
})();

Ostoskoria voi käyttää nyt seuraavasti:

kauppa.ostoskori.lisaa("keksi");
kauppa.ostoskori.lisaa("keksi");
kauppa.ostoskori.lisaa("omena");
console.log(kauppa.ostoskori.tuotteidenLukumaara()); // 3

Anonyymille funktiolle voi antaa parametreja. Luodaan hinnastomoduuli, joka palauttaa tuotteen nimen perusteella sen hinnan. Vaikka hinnastomoduulimme palauttaa kaikkien tuotteiden hinnaksi 3, voisi sen toteutus myös hakea hinnat esimerkiksi erilliseltä palvelimelta.

kauppa.hinnasto = (function() {
    function annaHinta(tuote) {
        return 3;
    }

    return {
        hinta: annaHinta
    };
})();

Laajennetaan ostoskorimoduulia siten, että se saa hinnaston parametrina. Lisätään ostoskorille myös funktio ostoskorissa olevien tuotteiden hinnan laskemiseen.

kauppa.ostoskori = (function(hinnasto) {
    var ostokset = [];

    function lisaaOstos(tuotteenNimi) {
        if(!ostokset[tuotteenNimi]) {
            // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
            ostokset[tuotteenNimi] = 0;
        }

        // kasvatetaan tuotteen lukumäärää yhdellä
        ostokset[tuotteenNimi]++;
    }

    function tuotteitaYhteensa() {
        var lukumaara = 0;
        for(var tuotteenNimi in ostokset) {
            lukumaara += ostokset[tuotteenNimi];
        }

        return lukumaara;
    }

    function yhteishinta() {
        var summa = 0;
        for(var tuotteenNimi in ostokset) {
            summa += ostokset[tuotteenNimi] * hinnasto.hinta(tuotteenNimi);
        }

        return summa;
    }

    // rajapinta
    return {
        lisaa: lisaaOstos,
        tuotteidenLukumaara: tuotteitaYhteensa,
        yhteishinta: yhteishinta
    };
})(kauppa.hinnasto);

Huomaa miten riippuvuus hinnastoon nimetään moduulin sisällä uudestaan anonyymin funktion parametrien kautta. Moduulin sisällä hinnastoon viitataan muuttujalla hinnasto.

Ostoskorin tilaaminen ja varasto (2p)

Jatkokehitetään yllä olevaa esimerkkiä. Tehtävänäsi on luoda varastokirjanpitoa varten moduuli kauppa.varasto, joka tarjoaa seuraavat funktiot:

  1. lisaa(tuote, lukumaara) lisää annetun lukumärään tuotteita varastoon.
  2. ota(tuote, lukumaara) ottaa varastosta tuotteita halutun lukumäärän.
  3. saldo(tuote) palauttaa tuotteen varastosaldon.

Varastosaldo voi olla myös negatiivinen.

Lisää lisäksi ostoskorille funktio tilaa, jonka avulla käyttäjä voi tilata ostoskorissa olevat tuotteet. Kun ostoskori tilataan, varastosta otetaan tuotteita ostoskorissa oleva määrä. Tyhjennä tilauksen lopuksi myös ostoskori.

Kytke varasto ostoskoriin siten, että ostoskori tietää varastosta. Kun sovelluksesi toimii seuraavalla koodilla, palauta se TMC:lle.

kauppa.ostoskori.lisaa("kivi");
kauppa.ostoskori.lisaa("kivi");
kauppa.ostoskori.lisaa("kivi");
console.log(kauppa.ostoskori.tuotteidenLukumaara()); // 3
console.log(kauppa.varasto.saldo("kivi")); // 0

kauppa.ostoskori.tilaa();
console.log(kauppa.ostoskori.tuotteidenLukumaara()); // 0
console.log(kauppa.varasto.saldo("kivi")); // -3

console.log(kauppa.varasto.saldo("paperi")); // 0

kauppa.ostoskori.lisaa("kivi");
kauppa.ostoskori.lisaa("kivi");
kauppa.ostoskori.lisaa("paperi");

kauppa.ostoskori.tilaa();
console.log(kauppa.varasto.saldo("kivi")); // -5
console.log(kauppa.varasto.saldo("paperi")); // -1

kauppa.varasto.lisaa("kivi", 7);
console.log(kauppa.varasto.saldo("kivi")); // 2

Tilaamisen tulee vain vähentää tavarat varastosta ja tyhjentää ostoskori, muuta toiminnallisuutta ei vielä tarvitse.

Edellä esitellystä moduulista ei voi tehdä olioita, joten ostoskoreja voi olla vain yksi. Tämä ei kuitenkaan aina ole toivottavaa.

Olioiden tila ja new

Operaatiota new kutsuttaessa funktiosta luodaan kopio, jolloin käytännössä varataan tilaa oliolle ja sen this-operaattorilla merkatuille muuttujille. Uusi, juuri luotava olio, on käytännössä joukko avain-arvo -pareja, jossa arvo voi olla funktio, muuttuja, tai olio. Koska muuttujat voivat olla funktioita, voi this-operaattorilla viitata funktioon.

Luodaan funktio Laskuri, jonka sisällä on muuttuja luku. Muuttujaa luku ei määritellä this-operaatiolla, vaan se on funktion sisälle kapseloitu.

function Laskuri() {
    var luku = 0;
}

Ylläolevaa funktiota voi kutsua sekä new-operaation avulla että ilman. Jos funktiota kutsutaan ilman new- kutsua, kutsu on normaali funktiokutsu. Toisaalta, jos funktiota kutsutaan new-operaation kanssa, luodaan uusi olio.

Laskuri(); // suorittaa funktion sisällä olevan koodin

var olio = new Laskuri(); // suorittaa funktion sisällä olevan koodin, luo olion, ja palauttaa sen erilliseen muuttujaan

Yllä luotu olio on kopio Laskuri-funktion sisäisestä tilasta. Tilaan ei kuitenkaan pääse mitenkään käsiksi.

Lisätään funktioon Laskuri kaksi this-operaatiolla määriteltyä funktiota. Koska operaatiolla this määritellyt muuttujat ovat oliokohtaisia, ovat myös funktiot oliokohtaisia.

function Laskuri() {
    var luku = 0;

    this.kasvata = function() {
        luku++;
    }

    this.tulosta = function() {
        console.log(luku);
    }
}

Tutkitaan nyt mitä tapahtuu kun kutsumme Laskuri-funktiota sekä ilman new-operaatiota, että new-operaation kanssa. Kutsutaan funktiota ensin ilman new-operaatiota.

Laskuri(); // yrittää suorittaa funktion sisällä olevan koodin, ei toimi

Kun funktiota Laskuri kutsutaan ilman new-operaatiota, näemme virheen "Cannot set property 'kasvata' of undefined". Tämä johtuu siitä, että this liittyy aina olioon. Käytännössä yritämme lisätä oliolle uutta muuttujaa kasvata. Yllä tämä epäonnistuu, sillä oliota, mille muuttujaa yritetään asettaa ei ole olemassa.

Kutsutaan seuraavaksi funktiota Laskuri new-operaation kanssa, eli luodaan siitä olio.

var laskin = new Laskuri();

Yllä olevassa kutsussa luodaan klooni funktion Laskuri sisällöstä, ja palautetaan viite klooniin. Klooni kapseloi muuttujan luku, mutta siihen pääsee käsiksi oliomuuttujien kasvata ja tulosta kautta. Voimme luoda yllä olevasta funktiosta useamman kopion.

var laskin = new Laskuri();
laskin.kasvata();
laskin.tulosta(); // 1

var toinen = new Laskuri();
toinen.tulosta(); // 0
laskin.tulosta(); // 1

laskin.kasvata();
laskin.tulosta(); // 2
toinen.tulosta(); // 0

Javascriptiin tutustuessa huomasimme, että uusia JavaScript-olioita voi luoda aaltosulkujen avulla. Oikeastaan, operaatio this-lisää oliolle uusia muuttujia aivan kuten aaltosulkunotaatiolla luotavalle oliolle lisätään uusia muuttujia. Yllä olevan laskurin voi toteuttaa myös seuraavasti:

function Laskuri() {
    var luku = 0;

    return {
        kasvata: function() {
            luku++;
        },
        tulosta: function() {
            console.log(luku);
        }
    };
}

Ja seuraavasti:

function Laskuri() {
    var luku = 0;

    function kasvata() {
        luku++;
    }

    function tulosta() {
        console.log(luku);
    }

    return {
        kasvata: kasvata,
        tulosta: tulosta
    };
}

Oleellista on se, että Kutsu new Laskuri() palauttaa uuden olion. Uudella oliolla on funktion Laskuri kapseloima muuttuja luku, sekä luodun olion tarjoamat julkiset funktiot kasvata ja tulosta. Huomaa että kutsu {} luo uuden Object-tyyppisen olion -- yllä olevan olion tyyppi ei siis ole Laskin!

Tavara ja Matkalaukku (2p)

Muokataan viime viikolla ollutta tehtävää siten, että käytetään edellä esitettyä olioiden esitystapaa. Muokkaa tehtäväpohjassa olevia konstruktorifunktiota Tavara ja Matkalaukku siten, että konstruktorifunktiot sisältävät luotaviin olioihin liitettävät metodit. Muokkaa ohjelmaa siten, että se toimii alla olevalla esimerkillä.

Kun ohjelmasi toimii kuten toivottu, lähetä se TMC:lle. Huom! Viimeiset 2 riviä saavat rikkoa ohjelman. Älä (vieläkään) aseta matkalaukkuun omaa paino-muuttujaa, vaan laske matkalaukun paino tavaroiden painosta.

var kivi = new Tavara("kivi", 3);
var kirja = new Tavara("kirja", 7);
var pumpuli = new Tavara("pumpuli", 0.001);

var laukku = new Matkalaukku(10);
var vuitton = new Matkalaukku(3);

laukku.lisaa(kivi);
console.log("laukun paino, pitäisi olla 3: " + laukku.paino());
laukku.lisaa(kivi); // virhe: "Tavara lisätty jo, ei onnistu!"

laukku.lisaa(kirja);
console.log("laukun paino, pitäisi olla 10: " + laukku.paino());

laukku.lisaa(pumpuli); // virhe: "Liian painava, ei pysty!"

console.log("laukun paino, pitäisi olla 10: " + laukku.paino());

vuitton.lisaa(pumpuli);
console.log("vuittonin paino, pitäisi olla 0.001: " + vuitton.paino());

// seuraavien komentojen ei pitäisi ainakaan muuttaa vuittonin painoa
pumpuli.paino = 300; // jos tavaralla on metodi paino, hajottaa ohjelman seuraavassa, muuten ei
console.log("vuittonin paino, pitäisi olla vieläkin 0.001: " + vuitton.paino()); // paino ei ole muuttunut

Moduulien ja olioiden yhdistäminen

Suurimmat syyt moduulien käyttöön ovat käytettävien globaalien muuttujanimien vähentäminen sekä tiedon kapselointi. Aiemmin käyttämämme moduulit voidaan nähdä singleton-suunnittelumallia seuraavina olioina tai staattisina funktioina, jotka muokkaavat staattista tilaa. Moduuleista ei ole voinut luoda ilmentymiä.

Pohditaan aiempaa kauppakassaesimerkkiä, jossa olimme varanneet kaupan toiminnallisuutta varten muuttujan kauppa. Aiempi toteutuksemme ostoskorista oli moduuli, joka tarkoitti sitä, että ostoskoreja voi olla vain yksi kerrallaan. Alustava toteutus näytti seuraavalta:

var kauppa = {};

kauppa.ostoskori = (function() {
    var ostokset = [];

    function lisaaOstos(tuotteenNimi) {
        if(!ostokset[tuotteenNimi]) {
            // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
            ostokset[tuotteenNimi] = 0;
        }

        // kasvatetaan tuotteen lukumäärää yhdellä
        ostokset[tuotteenNimi]++;
    }

    function tuotteitaYhteensa() {
        var lukumaara = 0;
        for(var tuotteenNimi in ostokset) {
            lukumaara += ostokset[tuotteenNimi];
        }

        return lukumaara;
    }

    // rajapinta
    return {
        lisaa: lisaaOstos,
        tuotteidenLukumaara: tuotteitaYhteensa
    };
})();

Muutetaan ylläoleva moduuli funktioksi siten, että ostoskorista voi tehdä uusia olioita. Muokataan funktioita lisaaOstos ja tuotteitaYhteensa myös siten, että niiden nimet vastaavat yllä määriteltyä rajapintaa.

var kauppa = {};

kauppa.Ostoskori = function() {
    var ostokset = [];

    this.lisaa = function(tuotteenNimi) {
        if(!ostokset[tuotteenNimi]) {
            // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
            ostokset[tuotteenNimi] = 0;
        }

        // kasvatetaan tuotteen lukumäärää yhdellä
        ostokset[tuotteenNimi]++;
    }

    this.tuotteidenLukumaara = function() {
        var lukumaara = 0;
        for(var tuotteenNimi in ostokset) {
            lukumaara += ostokset[tuotteenNimi];
        }

        return lukumaara;
    }
}

Voimme nyt luoda uusia ostoskoreja new-operaatiolla.

var a = new kauppa.Ostoskori();
a.lisaa("kekseja");
a.lisaa("kekseja");
console.log(a.tuotteita()); // 2

var b = new kauppa.Ostoskori();
console.log(b.tuotteita()); // 0
console.log(a.tuotteita()); // 2

Aiemmassa esimerkissämme ostoskorilla oli tiedossa hinnasto, jota ei ylläolevassa esimerkissä ole. Hinnaston lisääminen jokaisen ostoskorin konstruktorikutsun yhteydessä ei ole miellyttävää, joten muokataan edellisestä ostoskorista moduuli, joka kapseloi hinnaston. Haluamme myös säilyttää mahdollisuuden useamman ostoskorin luomiseen. Muokataan ensin ostoskoritoteutusta siten, että se on kapseloitu moduulin sisälle. Luodaan moduuli Ostoskori, joka funktiokutsun yhteydessä palauttaa moduulin kapseloiman konstruktorifunktion nimeltä Kori.

var kauppa = {};

kauppa.Ostoskori = (function() {

    // konstruktori
    function Kori() {
        // oliokohtaiset muuttujat
        var ostokset = [];

        // oliokohtaiset metodit
        this.lisaa = function(tuotteenNimi) {
            if(!ostokset[tuotteenNimi]) {
                // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
                ostokset[tuotteenNimi] = 0;
            }

            // kasvatetaan tuotteen lukumäärää yhdellä
            ostokset[tuotteenNimi]++;
        }

        this.tuotteidenLukumaara = function() {
            var lukumaara = 0;
            for(var tuotteenNimi in ostokset) {
                lukumaara += ostokset[tuotteenNimi];
            }

            return lukumaara;
        }
    }

    return Kori;
})();

Ylläolevassa koodissa määritellään anonyymin funktion sisällä konstruktorifunktio Kori, joka kapseloi ostoskorin toiminnallisuuden. Anonyymi funktio suoritetaan heti, sillä sen lopussa on sulut. Käytännössä funktio palauttaa konstruktorifunktion, joka asetetaan olion kauppa muuttujaan Ostoskori. Aiemmin tekemämme ohjelma toimii vieläkin.

var kori = new kauppa.Ostoskori();
kori.lisaa("kekseja");
kori.lisaa("kekseja");
console.log(kori.tuotteidenLukumaara()); // 2

var laukku = new kauppa.Ostoskori();
console.log(laukku.tuotteidenLukumaara()); // 0
console.log(laukku.tuotteidenLukumaara()); // 2

Lisätään ostoskorille hinnasto. Käytämme hinnaston toteutuksena aiemmin luomaamme seuraavanlaista hinnastoa.

kauppa.hinnasto = (function() {
    function annaHinta(tuote) {
        return 3;
    }

    return {
        hinta: annaHinta
    };
})();

Hinnaston lisääminen onnistuu antamalla se parametriksi anonyymille funktiolle.

kauppa.Ostoskori = (function(hinnasto) {
    // moduulin sisäinen muuttuja, joka näkyy kaikille moduulin sisällä
    var hinnat = hinnasto;

    // konstruktori
    function Kori() {
        // oliokohtaiset muuttujat
        var ostokset = [];

        // oliokohtaiset metodit
        this.lisaa = function(tuotteenNimi) {
            if(!ostokset[tuotteenNimi]) {
                // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
                ostokset[tuotteenNimi] = 0;
            }

            // kasvatetaan tuotteen lukumäärää yhdellä
            ostokset[tuotteenNimi]++;
        }

        this.tuotteidenLukumaara = function() {
            var lukumaara = 0;
            for(var tuotteenNimi in ostokset) {
                lukumaara += ostokset[tuotteenNimi];
            }

            return lukumaara;
        }
    }

    return Kori;
})(kauppa.hinnasto);

Nyt ostoskorilla on käytössä hinnasto. Lisätään ostoskorille vielä metodi ostoskorissa olevien tuotteiden hinnan laskemiseen.

kauppa.Ostoskori = (function(hinnasto) {
    // moduulin sisäinen muuttuja, joka näkyy kaikille moduulin sisällä
    var hinnat = hinnasto;

    // konstruktori
    function Kori() {
        // oliokohtaiset muuttujat
        var ostokset = [];

        // oliokohtaiset metodit
        this.lisaa = function(tuotteenNimi) {
            if(!ostokset[tuotteenNimi]) {
                // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
                ostokset[tuotteenNimi] = 0;
            }

            // kasvatetaan tuotteen lukumäärää yhdellä
            ostokset[tuotteenNimi]++;
        }

        this.tuotteidenLukumaara = function() {
            var lukumaara = 0;
            for(var tuotteenNimi in ostokset) {
                lukumaara += ostokset[tuotteenNimi];
            }

            return lukumaara;
        }

        this.yhteishinta = function() {
            var summa = 0;
            for(var tuotteenNimi in ostokset) {
                summa += ostokset[tuotteenNimi] * hinnat.hinta(tuotteenNimi);
            }

            return summa;
        }
    }

    return Kori;
})(kauppa.hinnasto);
var kori = new kauppa.Ostoskori();
kori.lisaa("kekseja");
kori.lisaa("kekseja");
console.log(kori.tuotteidenLukumaara()); // 2
console.log(kori.yhteishinta()); // 6

var laukku = new kauppa.Ostoskori();
console.log(laukku.tuotteidenLukumaara()); // 0
console.log(laukku.yhteishinta()); // 0

console.log(kori.tuotteidenLukumaara()); // 2
console.log(kori.yhteishinta()); // 6

Ylläolevassa esimerkissä käytetään moduulille parametrina annettua hinnastoa hintojen laskemiseen. Itseasiassa, koska hinnasto on moduulin parametrina, on se käytössä myös moduulin sisällä. Moduuli ei siis tarvitse erillistä hinnat-muuttujaa.

kauppa.Ostoskori = (function(hinnasto) {

    // konstruktori
    function Kori() {
        // oliokohtaiset muuttujat
        var ostokset = [];

        // oliokohtaiset metodit
        this.lisaa = function(tuotteenNimi) {
            if(!ostokset[tuotteenNimi]) {
                // jos tuotetta ei ole lisätty ostoskoriin, lisätään se sinne
                ostokset[tuotteenNimi] = 0;
            }

            // kasvatetaan tuotteen lukumäärää yhdellä
            ostokset[tuotteenNimi]++;
        }

        this.tuotteidenLukumaara = function() {
            var lukumaara = 0;
            for(var tuotteenNimi in ostokset) {
                lukumaara += ostokset[tuotteenNimi];
            }

            return lukumaara;
        }

        this.yhteishinta = function() {
            var summa = 0;
            for(var tuotteenNimi in ostokset) {
                summa += ostokset[tuotteenNimi] * hinnasto.hinta(tuotteenNimi);
            }

            return summa;
        }
    }

    return Kori;
})(kauppa.hinnasto);
var kori = new kauppa.Ostoskori();
kori.lisaa("kekseja");
kori.lisaa("kekseja");
console.log(kori.tuotteidenLukumaara()); // 2
console.log(kori.yhteishinta()); // 6

var laukku = new kauppa.Ostoskori();
console.log(laukku.tuotteidenLukumaara()); // 0
console.log(laukku.yhteishinta()); // 0

console.log(kori.tuotteidenLukumaara()); // 2
console.log(kori.yhteishinta()); // 6

Ostoskorin tilaaminen ja varasto, osa 2 (1p)

Yhdistä tehtävän 18 ratkaisusi edellä olevaan esimerkkiin siten, että ostoskoreja voi luoda useampia, ja että jokaisen voi tilata erikseen. Kun sovelluksesi toimii oikein, palauta se TMC:lle -- voit käyttää allaolevaa koodia avuksi ohjelman testaamiseen.

var kori = new kauppa.Ostoskori();
kori.lisaa("kivi");
kori.lisaa("kivi");
kori.lisaa("kivi");
console.log(kori.tuotteidenLukumaara()); // 3
console.log(kauppa.varasto.saldo("kivi")); // 0

kori.tilaa();
console.log(kori.tuotteidenLukumaara()); // 0
console.log(kauppa.varasto.saldo("kivi")); // -3

console.log(kauppa.varasto.saldo("paperi")); // 0

kori.lisaa("kivi");

var uusikori = new kauppa.Ostoskori();

uusikori.lisaa("kivi");
uusikori.lisaa("paperi");

console.log(kori.tuotteidenLukumaara()); // 1
console.log(uusikori.tuotteidenLukumaara()); // 2

kori.tilaa();
uusikori.tilaa();

console.log(kauppa.varasto.saldo("kivi")); // -5
console.log(kauppa.varasto.saldo("paperi")); // -1

kauppa.varasto.lisaa("kivi", 7);
console.log(kauppa.varasto.saldo("kivi")); // 2

MV* ja Web-sovelluksen rakenne

Termi MVC (Model, View, Controller) esiintyy lähes kaikkialla ohjelmistotekniikassa. MVC on suunnittelumalli, joka pilkkoo sovelluksen kolmeen osaan: dataan (model), näkymään (view), ja käyttäjän interaktioita hallinnoivaan sovelluslogiikkaan eli kontrolleriin (controller). MVC-mallia on käytetty alunperin työpöytäsovelluksissa, mutta se on otettu käyttöön myös palvelin- ja selainpuolen ohjelmistoihin niiden kehittyessä.

Perusideat ovat säilyneet samoina. Käyttäjän tehdessä jotain, esimerkiksi painaessa sivulla olevaa nappia, toimintoon liittyvä tieto välittyy kontrollerille, joka päättää mitä seuraavaksi tehdään. Yleisin toiminto on mallin muokkaaminen tai korvaaminen palvelimelta haetulla datalla, ja uuden näkymän näyttäminen muokattuun malliin perustuen. Palvelinohjelmistoja rakennettaessa tämä tapahtuu esimerkiksi lähettämällä web-sivulla olevan lomakkeen data tiettyyn osoitteeseen, jossa kontrolleri odottaa pyyntöä. Kontrollerin vastaanottaessa pyynnön, pyyntöön liittyvä mahdollinen data tallennetaan. Tämän jälkeen luodaan uusi model, johon haetaan tietoa esimerkiksi tietokantapalvelusta. Model ohjataan näkymän luovalle komponentille, joka lopulta palauttaa uuden näkymän käyttäjälle.

Dynaamista toiminnallisuutta sisältävissä selainohjelmistoissa erityisesti vastaukset voivat sisältää paljon vähemmän dataa. Koko näkymää ei tarvitse hakea uudestaan jokaisen kyselyn yhteydessä.

Esimerkki: Muistuttaja

Luodaan sovellus, johon käyttäjä voi lisätä päiväkohtaisia tapahtumia. Jokaiseen tapahtumaan liittyy nimi ja aika. Luodaan aluksi sovellukselle nimiavaruus muistutus, johon sovelluksen toiminnallisuus lisätään.

Vaikka sovelluksessa käydään läpi sovelluksen osat termeillä Model, View, Controller, ei sovelluksen arkkitehtuuri seuraa MVC-mallia sen perinteisessä mielessä.

var muistutus = {};

View

MVC-mallissa näkymä vastaanottaa dataa, ja päättää miten se näytetään. Näkymä voi käyttää olemassaolevaa HTML-dokumenttia, ja asettaa siihen dataa, tai se voi luoda uusia elementtejä DOMin avulla. Huomaa että näkymä ja data on erotettu toisistaan, eli näkymä ei tiedä -- eikä välitä -- mallista. Se käsittelee vain dataa, jota sille annetaan. Luodaan HTML-dokumentti, jossa on paikka tapahtumille ja kentät uuden tapahtuman lisäämiselle.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>What's Happening?!</title>
    </head>
    <body onload="muistutus.init();" >

        <section id="tapahtumat">
        </section>

        <section id="uusitapahtuma">
            <label>Nimi: <input type="text" id="nimi" /></label>
            <label>Aika (vvvv-kk-pp): <input type="text" id="aika" /></label>
            <input type="button" id="lisaa" />
        </section>


        <script src="muistutus.js"></script>
    </body>
</html>

Luodaan näkymää varten oma nimiavaruus muistutus.view, ja lähdetään rakentamaan näkymän generointiin tarvittavaa toiminnallisuutta.

muistutus.view = {};

Luodaan tapahtumien listaamiseen tarvittava näkymä. Huomaa, että näkymä voi tarkoittaa myös sivun sisällä olevaa elementtiä. Luodaan listaus siten, että sille annetaan parametrina elementti, johon tapahtumia lisätään. Tapahtumien listaaminen tapahtuu metodissa listaaTapahtumat. Metodin listaaTapahtumat lisäksi näkymällä on metodi paivita, jota kutsutaan kun näkymä halutaan päivittää. Päivitä-metodille annetaan parametrina data, joka halutaan näyttää näkymässä.

muistutus.view.Listaus = function(elementti) {

    // julkiset metodit
    this.listaaTapahtumat = function(tapahtumat) {
        tyhjenna();

        for (var i = 0; i < tapahtumat.length; i++) {
            lisaaTapahtuma(tapahtumat[i]);
        }
    }

    this.paivita = function(tapahtumat) {
        // päivitysoperaatio kutsuu listausoperaatiota
        this.listaaTapahtumat(tapahtumat);
    }

    // kapseloidut apufunktiot
    function lisaaTapahtuma(tapahtuma) {
        var tapahtumaElementti = document.createElement("h2");
        var teksti = tapahtuma.nimi + ' (' + tapahtuma.aika + ')';

        tapahtumaElementti.appendChild(document.createTextNode(teksti));
        elementti.appendChild(tapahtumaElementti);
    }

    function tyhjenna() {
        while(elementti.firstChild) {
            elementti.removeChild(elementti.firstChild);
        }
    }
}

Huomaa, että näkymässä aiheutuviin tapahtumiin liittyvää koodia ei sisällytetä näkymän koodiin, vaan ne jätetään kontrollerille. Koska elementti, johon tapahtumat lisätään, annetaan konstruktorifunktiolle parametrina, on se käytössä myös olion metodeissa.

Model

Tapahtumakalenteriin liittyvän mallin luominen on helpohkoa. Luodaan ensin oma nimiavaruus muistutus.domain.

muistutus.domain = {};

Luodaan nimiavaruuteen muistutus.domain konstruktori Tapahtumalista, joka tarjoaa toiminnallisuuden tapahtumien lisäämiseen ja kapselointiin. Tapahtumalista tietää jostain-näkymästä, jonka päivitysoperaatiota se kutsuu kun tapahtumia lisätään.

muistutus.domain.Tapahtumalista = function(view) {
    var tapahtumat = [];

    this.lisaaTapahtuma = function(tapahtuma) {
        tapahtumat.push(tapahtuma);

        view.paivita(tapahtumat);
    }

    this.annaTapahtumat = function() {
        return tapahtumat;
    }
}

Vaikka haluaisimme myös tehdä erillisen konstruktorin tapahtumalle, käytetään tapahtumia varten JavaScriptin omia olioita. Näin olioiden tallentamistoiminnallisuuden mahdollinen toteutus on helpompaa, sillä funktiota JSON.parse voi käyttää suoraan tapahtumat-muuttujaan.

Controller

Luodaan seuraavaksi kontrolleri. Kontrollerin tehtävänä on reagoida käyttöliittymässä tapahtuviin tapahtumiin, sekä toimia niiden pohjalta jotenkin. Luomme kontrollereille ensin oman nimiavaruuden muistutus.controller.

muistutus.controller = {};

Kontrolleri LomakeKontrolli tarjoaa rajapinnan lomake-elementtien käsittelyyn. Kontrollerille voi lisätä elementtejä, joita se kuuntelee. Se tarjoaa myös metodin lisaaTapahtuma, jota voi kutsua tapahtumankäsittelyn yhteydessä. Metodi lisaaTapahtuma käy läpi rekisteröidyt elementit, ja luo niiden pohjalta olion. Olio lähetetään lopulta mallille.

muistutus.controller.LomakeKontrolli = function(model) {
    var elementit = {};

    this.lisaaDataelementti = function(nimi, elementti) {
        elementit[nimi] = elementti;
    }

    this.lisaaTapahtuma = function(eventInformation) {
        var data = haeData();

        model.lisaaTapahtuma(data);

        tyhjennaElementit();
    }

    function haeData() {
        var data = {};
        for (var nimi in elementit) {
            data[nimi] = elementit[nimi].value;
        }

        return data;
    }

    function tyhjennaElementit() {
        for (var nimi in elementit) {
            elementit[nimi].value = "";
        }
    }
}

Kontrolleri sisältää toiminnallisuuden kontrolloitavien elementtien lisäämiseen, sekä elementtien sisältämän datan lähettämiseen tapahtumalistalle. Huomaa, että kontrolleri ei oikeastaan tiedä tapahtumien muodosta. Se vain kontrolloi lomakkeen elementtejä.

Sovelluksen alustaminen

Luodaan lopuksi alustusfunktio, joka luo sovelluksessa käytetyt oliot, sekä kytkee HTML-dokumentin elementit kontrolleriin.

muistutus.init = function() {
    // luodaan palaset
    var listausnakyma = new muistutus.view.Listaus(document.getElementById("tapahtumat"));

    var lista = new muistutus.domain.Tapahtumalista(listausnakyma);
    listausnakyma.listaaTapahtumat(lista.annaTapahtumat());

    var kontrolli = new muistutus.controller.LomakeKontrolli(lista);

    // kytketään kontrolli elementteihin
    kontrolli.lisaaDataelementti("nimi", document.getElementById("nimi"));
    kontrolli.lisaaDataelementti("aika", document.getElementById("aika"));

    document.getElementById("lisaa").addEventListener("click", kontrolli.lisaaTapahtuma, false);
}

Toimii! Sovellusta voisi esimerkiksi jatkokehittää siten, että se sisältäisi datan lähettämisen erilliselle palvelinkomponentille. Tämän lisäksi tapahtumia tulisi pystyä poistamaan.

Validointi (3p)

Tehtäväpohjan mukana tulee edellä käsitelty muistutussovellus. Jatkokehitä sovellusta siten, että sovelluksessa on validointitoiminnallisuus. Kun käyttäjä yrittää lisätä tapahtumaa, tulee tapahtuman tiedot validoida.

Toteuta validointitoiminnallisuus siten, että kontrolleriin voi lisätä validoijia. Kun käyttäjä lisää tapahtumaa, kaikki validoijat käydään läpi yksitellen siten, että data annetaan kullekin validoijalle vuorollaan. Jos validoijan palauttama viesti ei ole tyhjä, eli validoijalla on jotain valitettavaa, viesti näytetään käyttäjälle ja validointi sekä tapahtuman lisääminen lopetetaan. Toteuta validoijat tehtäväpohjassa annetun Validoija-konstruktorin pohjalta siten, että kukin validoija on olio, jolla on validoitavan kentän nimen lisäksi validointifunktio, jota kontrollerin tulee kutsua dataa validoitaessa. Validoijan sisältämälle funktiolle annetaan parametrina kentän arvo, ja se palauttaa merkkijonon.

Esimerkki validoijaoliosta:

var validoija = new Validoija("nimi", function(data) {
    if(!data) {
        return "Nimi ei saa olla tyhjä!";
    }

    return "";
});

Jos ylläoleva validoija on lisätty kontrollerille, datan lisäyksen ei tule toimia jos nimikenttä on tyhjä.

Kun kontrolleri tukee validoijien lisäämistä, lisää sinne yllä oleva validoija. Luo myös validoija , joka tarkastaa että aika on muotoa yyyy-MM-dd, esimerkiksi 2012-12-24. Kukin numero saa olla mitä tahansa numeroiden 0 ja 9 välillä. Kannattaa tutustua säännöllisiin lausekkeisiin (google esim. "javascript regular expressions date").

Kun sovelluksesi toimii kuten haluttu, palauta se TMC:lle.

Kontrollerin rooli selainohjelmistoissa

Yllä olevaa sovellusta luodessa huomaamme, että kontrollerin rooli ei ole kovin selkeä. Muistutus-esimerkissä sovelluksessa kontrolleri toimi elementtien rekisterinä siihen asti, kunnes käyttäjä painoi käyttöliittymän nappia. Napin painalluksenkin rekisteröinti tapahtui kontrollerin ulkopuolella. Sovelluksen voi toteuttaa myös ilman kontrolleria siten, että kontrollerin toiminnallisuus sisällytettäisiin alustukseen.

muistutus.init = function() {
    // luodaan palaset
    var listausnakyma = new muistutus.view.Listaus(document.getElementById("tapahtumat"));

    var lista = new muistutus.domain.Tapahtumalista(listausnakyma);
    listausnakyma.listaaTapahtumat(lista.annaTapahtumat());

    document.getElementById("lisaa").addEventListener('click', function() {
        var tapahtuma = {
            nimi: document.getElementById("nimi").value,
            aika: document.getElementById("aika").value
        };

        lista.lisaaTapahtuma(tapahtuma);

        document.getElementById("nimi").value = "";
        document.getElementById("aika").value = "";
    }, false);
}

Onko kontrolleri tarpeellinen?

Jos sovelluksessa on useampia näkymiä, joiden välillä haluaisimme siirtyä, kontrollerista on hyötyä. Pienessä sovelluksessa erillisen kontrollerin käyttö saattaa kuitenkin monimutkaistaa sovelluksen rakennetta -- esimerkiksi yllä kontrollerin toiminnallisuuden pystyi lisäämään osaksi init-funktiota. Kontrollereita käytetään myös erityisesti käyttäjän ohjaamiseen useamman näkymän välillä.

Esimerkki: Spoilaaja

Toteutetaan seuraavaksi sovellus, jossa ei ole eksplisiittistä kontrolleria. Sovelluksessa näkymässä tapahtuvat päivitykset siirtyvät mallille näkymään liitettyjen tapahtumankäsittelijöiden kautta. Tapahtumankäsittelijät luodaan sovelluksen alustusvaiheessa, jolloin tapahtumankäsittelijät toimivat siltana mallin ja näkymän välillä. Käytännössä näkymä ei tiedä mallista, eikä malli näkymästä.


   VIEW    <--- näytä ---    MODEL LISTENER
     |                             |
   muutos                        muutos
     |                             |
VIEW LISTENER --- päivitä -->    MODEL

Sovelluksen aihepiiri on spoilaaja, eli sillä näytetään kirjoihin liittyviä spoilauksia. Hahmotellaan ensin sovelluksen käyttöliittymä. HTML-dokumentin rakenne näyttää seuraavalta:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>Spoilerit!</title>
    </head>
    <body onload="spoilaaja.init();">
        <header>
            <h1>Spoilerit</h1>
        </header>

        <section id="spoilaukset">
        </section>

        <section id="input">
            <h2>Syötä uusi</h2>
            <label>kirja <input type="text" id="kirja"/></label>
            <label>spoilaus <input type="text" id="spoilaus"/></label>
            <input type="button" id="button" value="Lisää!"/>
        </section>

        <script src="spoilaus.js"></script>
    </body>
</html>

Ohjelmakoodi lisätään nimiavaruuteen spoilaaja.

var spoilaaja = {};

View

Luodaan sovelluksen näkymään liittyvä koodi. Ainoa alue, josta olemme kiinnostuneet, on tunnuksella spoilaukset merkitty alue. Näkymälle asetetaan init-funktiolla alueen tunnus, jonka sisälle se lisää dataa. Kun näkymään lisätään dataa, se luo jokaista kirjaa varten oman tekstikentän, joka sisältää kyseisen kirjan spoilauksen. Kun spoilauksen sisältö muuttuu, kutsutaan erillistä näkymälle asetettavaa tapahtumankuuntelijafunktiota muutokseen liittyvällä datalla.

Tapahtumankuuntelija lisätään sovellukseen sovellusta käynnistettäessä funktiolla setListener. Aina kun näkymään lisätään uutta spoilausta, lisätään tekstikenttään myös tapahtumankuuntelija. Tapahtumankuuntelija lähettää viestin mallille, jos data muuttuu.

spoilaaja.View = function(containerId) {
    var container = document.getElementById(containerId);
    var listener;

    // julkiset metodit
    this.render = function(data, key, value) {
        if(data !== false) {
            renderAll(data);
        } else {
            renderSingle(key, value);
        }
    }

    this.setListener = function(actionListener) {
        listener = actionListener;
    }

    // apufunktiot
    function renderAll(data) {
        clear();

        for (var key in data) {
            renderSingle(key, data[key]);
        }
    }

    function clear() {
        while(container.firstChild) {
            container.removeChild(container.firstChild);
        }
    }

    function renderSingle(key, value) {
       var element = document.getElementById(key);

       if(!element) {
           // jos elementtiä ei ole vielä olemassa, luodaan sellainen
           createElement(key, value);
       }

       document.getElementById(key).value = value;
    }

    function createElement(key, value) {
        var article = document.createElement("article");
        var label = document.createElement("label");
        var textField = document.createElement("input");
        textField.type="text";

        label.appendChild(document.createTextNode(key));
        textField.id = key;
        textField.value = value;

        article.appendChild(label);
        article.appendChild(textField);

        container.appendChild(article);

        // jokaiseen elementtiin lisätään tapahtumankuuntelija
        if(listener) {
            textField.addEventListener("change", function(eventInformation) {
                var textField = eventInformation.target;
                listener(textField.id, textField.value);
            }, false);
        }
    }
}

Seuraavaksi datan säilytys ja esitys.

Model

Model kapseloi sovelluksessa käytettävän datan sisäänsä. Tämän lisäksi se tarjoaa aksessorit dataan, sekä tapahtumankäsittelijäfunktion, jota kutsutaan, jos mallin sisältämään dataan tehdään muutoksia. Sovelluksen sisältämä data on käytännössä olio, eli joukko avain-arvo -pareja. Avain on aina kirjan nimi, ja arvo kirjaan liittyvä spoilaus.

spoilaaja.Model = function(initialData) {
    var data = initialData;
    var listener;

    this.update = function(key, value) {
        data[key] = value;

        if(!listener) {
            console.log("Model update called, but listener has not been set :(");
            return;
        }

        listener(key, value);
    }

    this.get = function(key) {
        return data[key];
    }

    this.getAll = function() {
        return data;
    }

    this.setListener = function(action) {
        listener = action;
    }
}

Tässä vaiheessa saatat huomata, että ylläolevassa Model-toteutuksessa ei ole minkäänlaista viittausta sovelluksen käyttötarkoitukseen. Tämä on tarkoituskin. Itseasiassa, ylläolevaa toteutusta voisi käyttää monissa muissakin yhteyksissä...

Sovelluksen alustaja

Luodaan seuraavaksi sovelluksen alustaja. Alustajan tehtävänä on alustaa sovellus, ja kytkeä näkymä ja malli toisiinsa tapahtumankäsitelijöiden kautta. Luodaan ensin sovelluksessa käytettävä data, jonka jälkeen lisätään näkymälle tapahtumankuuntelija. Näkymään liitettävä tapahtumankuuntelija kutsuu mallin update-funktiota jos käsitelty data on muuttunut. Huomaa, että tapahtumalogiikka ei ole osa näkymää, vaan osa sovelluksen alustusta.

spoilaaja.init = function() {
    var data = {};
    data["Running Blind"] = "Murhaaja on nainen!";
    data["Cat's cradle"] = "se jäätävä homma...";

    var model = new spoilaaja.Model(data);
    var view = new spoilaaja.View("spoilaukset");

    // tapahtuman kuuntelijat viewlle (näin alustava tulostus ei muuta modelia
    view.setListener(function(key, value) {
        console.log("Listener in view called");
        if(model.get(key) !== value) {
            model.update(key, value);
        }
    });

Kun näkymässä on toiminnallisuus mallin päivittämiseen, kutsutaan näkymän render-metodia. Metodi render luo näkymän annetun datan pohjalta.

view.render(model.getAll(), false, false);

Lisätään vielä tapahtumankuuntelija modelille. Nyt jos modelissa tapahtuu muutos, se päivitetään myös näkymälle. Tällöin näkymä saa tietoonsa muutokset, jotka tapahtuvat muualla. Luotava tapahtumankuuntelija ja kuuntelijan käsittelyn toteutus mallissa aiheuttaa käytännössä sen, että näkymä saa tietoonsa mallin muutokset.

model.setListener(function(key, value) {
    view.render(false, key, value);
});

Kytketään lopuksi käyttöliittymässä olevaan nappiin spoilauksen lisäystoiminnallisuus.

// kytketään nappi toimimaan
var nappi = document.querySelector("#input #button");

nappi.addEventListener("click", function(eventInformation) {
    var kirja = document.querySelector("#input #kirja").value;
    if(!kirja) {
        return;
    }

    var spoilaus = document.querySelector("#input #spoilaus").value;
    model.update(kirja, spoilaus);

}, false);

Observer Pattern

Suunnittelumallia, jossa komponenttiin voi lisätä muiden komponenttien kutsufunktioita siten, että niitä kutsutaan komponentin päivittyessä kutsutaan Observer Patterniksi. Käytännössä komponentti ei tiedä muista komponenteista mitään, sillä on vain pääsy niiden tarjoamassa rajapinnassa olevaan yksittäiseen kutsufunktioon.

Spoilaajan Backend (3p)

Muistellaan taas JSON-kyselyjen tekemistä palvelimelle. Käy kertaamassa kappale 7 ennen tätä tehtävää. Tehtävänäsi on tässä lisätä edellä esitettyyn ohjelmaan backend-kytkös. Backend-kytköksen tehtävänä on sovelluksen käynnistyessä hakea olemassaolevat spoilaukset palvelimelta, sekä lähettää palvelimelle mahdolliset muutokset.

Spoilausten tallentamiseen käytetty palvelinsovellus toimii osoitteessa http://bad.herokuapp.com/app/. Kannattaa tehdä kysely palvelimelle selaimella ennen tehtävän tekemistä, jotta palvelin on varmasti käynnissä. Palvelin tarjoaa osoitteessa http://bad.herokuapp.com/app/spoilers/ toimivan rajapinnan. Kun rajapintaan tehdään GET-kysely, palvelin palauttaa kaikki tallennetut spoilaukset.

Uusien spoilausten lisääminen tai olemassaolevien muokkaaminen tapahtuu POST-pyynnöllä rajapintaan. POST-pyyntö tehdään siten, että sen mukana lähetetään JSON-muotoista dataa merkkijonomuodossa. JSON-datan tulee näyttää seuraavalta oliomuodossa:

var lahetettava = {
    name: "spoilattava",
    spoiler: "spoilaus"
};

Toteuta sovellus aluksi niin, että toteutat vain komponentin, jonka tehtävänä on kommunikoida palvelimen kanssa -- mutta -- älä toteuta itse kommunikointia vielä. Kun saat sovelluksen kommunikoimaan backend-komponentin kanssa, lisää backendille viestien lähetys ja vastaanottaminen palvelimelle.

MVC, MVP, MVVM, ...

Käytännössä kaikissa MV* -suunnittelumalleissa ajatuksena on näkymän ja sovelluslogiikan erottaminen toisistaan. Näimme aiemmin MVC-mallin, jossa pyyntö käytännössä kulkee näkymältä kontrollerille, joka ohjaa pyynnön mallille. Malli taas päivittää näkymää tarpeen vaatiessa. Käytännössä pyynnön kulku MVC-mallissa näyttää seuraavalta:

                  kliksu
                    ||
                    \/
                   VIEW
               /         /\
              /           \
ohjauspyyntö /             \  päivitys
            /               \
           \/                \
  CONTROLLER  - muokkaus - >  MODEL

Kaksi viime aikoina päätänsä nostanutta MVC-varianttia ovat MVP (Model, View, Presenter) ja MVVM (Model, View, ViewModel). Tutustutaan niihin pikaisesti.

MVP

Joissain tapauksissa sovelluksissa ei ole suoraa mahdollisuutta näkymän ja mallin toisiinsa kytkemiseen. Tällöin sovellus tarvitsee näkymän ja mallin välille erillisen komponentin, joka ohjaa pyyntöjä näkymältä malliin ja mallilta näkymään. ASCII-kaaviona sovelluksen MVP näyttää seuraavalta:

               kliksu
                 ||
                 \/
                VIEW
                / /\
               /  /
              /  /
ohjauspyyntö /  /  päivitys
            /  /
           \/ /
        PRESENTER
            \  /\
             \  \
              \  \
    muokkaus   \  \   muutos/tapahtuma
                \  \
                 \  \
                 \/  \
                  MODEL

Voimme muokata aiemmin tekemäämme muistutussovellusta seuraamaan MVP-mallia poistamalla mallilta riippuvuuden näkymään, ja lisäämällä kontrolleriin päivityksen tekemisen näkymälle. Käytännössä Presenter-oliolle tulisi lisätä myös tapahtumankuuntelija, jota model voisi kutsua tarvittaessa.

MVVM

MVVM on muunnos MVP:hen, jossa ViewModel on mallista valittua näkymää varten muokattu esitys käytössä olevasta datasta. ViewModel on kytkeytynyt näkymään näkymän tarjoaman funktion kautta. Kun ViewModelissa oleva data muuttuu, se kutsuu näkymän tarjoamaa funktiota siten, että näkymä päivittää itsensä ViewModel-olion pohjalta.

               kliksu
                 ||
                 \/
                VIEW
                / /\
               /  /
              /  /
ohjauspyyntö /  /  tapahtumakutsu
            /  /
           \/ /
        VIEWMODEL
            \  /\
             \  \
              \  \
    muokkaus   \  \   muutos/tapahtuma
                \  \
                 \  \
                 \/  \
                  MODEL

Valmiit JavaScript-kirjastot

Osa aiemmin toteuttamistamme ohjelmista ei toimi kaikilla nykyaikaisilla selaimilla. Osassa taas toistetaan samoja asioita uudestaan ja uudestaan. Yhteensopivuusongelmat johtuvat suurelta osin selainvalmistajien heikosta standardien seuraamisesta, ja innottomuudesta vanhempien selainten päivittämiseen. Selainohjelmistoja kehitettäessä tulee huomioida myös vanhempien selainten käyttäjät -- sovelluksen tilaajan määrittelemään pisteeseen asti.

Selainohjelmistojen tekemiseen on huomattava määrä valmiita kirjastoja, joiden yksi tarkoitus on poistaa joidenkin selainten tietynlaiset JavaScript-syntaksin vaatimukset. Kirjastot tarjoavat myös apufunktioita toistuvan koodin ja toiminnallisuuden vähentämiseen. Mielenkiintoista JavaScript-kirjastojen ilmentymisessä on se, että JS-yhteisössä on havaittavissa samanlaista käyttäytymistä kuin palvelinpuolen yhteisöissä muutamia vuosia sitten.

Kyllähän se kirjasto xxx on parempi kun se tekee tän yhdellä rivillä, sun softalla siihen menee seitsemän! -- hyi kun toi xxx näyttää raskaalta ja vaikealta käyttää, miksi siinä on noita tommosia turhia komentoja!

Aivan kuten ohjelmointikielten tapauksessa, tietyt ohjelmistokirjastot sopivat joihinkin asioihin paremmin, toiset toisiin. Tutustutaan seuraavaksi tällä hetkellä -- vieläkin -- ehkäpä eniten käytettyyn JavaScript-kirjastoon, jQueryyn.

jQuery

jQuery on JavaScript-kirjasto, jonka tarkoitus on helpottaa selainohjelmistojen toteutusta. Se tarjoaa tuen mm. DOM-puun muokkaamiseen, tapahtumien käsittelyyn sekä palvelimelle tehtäviin kyselyihin, ja sen avulla toteutettu toiminnallisuus toimii useimmissa selaimissa.

Uusimman jQuery-version saa ladattua täältä. Käytännössä jQuery on JavaScript-tiedosto, joka ladataan sivun latautuessa. Tiedoston voi asettaa esimerkiksi head-elementin sisään, tai ennen omia lähdekooditiedostoja.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Selaimen palkissa ja suosikeissa näkyvä otsikko</title>
     </head>
    <body>

        <!-- sivun sisältö -->

        <script src="javascript/jquery-2.1.3.min.js"></script>
        <script src="javascript/koodi.js"></script>
    </body>
</html>

Valitsimet

Olemme tähän mennessä käyttäneet valmiita JavaScriptin DOM-toiminnallisuuksia. Elementtien etsimiseen on käytetty mm. Selectors APIn querySelector-kutsua, esimerkiksi komennolla var elementti = document.querySelector("#nimi"); haetaan elementti, jonka tunnus on "nimi". JQuery käyttää Sizzle-kirjastoa elementtien valinnan helpottamiseen. Esimerkiksi sivun elementti, jonka tunnus on "nimi", löytyy seuraavalla komennolla.

var elementti = $("#nimi");

Kyselyiden formaatti on siis $("kysely"), missä kysely on hyvin samankaltainen kuin aiemmin käyttämämme Selector APIn kyselyrajapinta. Vastaavasti kaikki header-elementissä olevat a-elementteihin löytyy komennolla.

var elementit = $("header a");

Myös tietyn luokan toteuttavien elementtien haku on helppoa. Alla olevassa esimerkissä on kolme tekstikenttää, joista 2 on piilotettu. Piilotettujen tekstikenttien tyyliluokka on jquery-dom-1-hidden.

text 1

text 2

text 3

Huomaa, että koodi toimii, sillä jQuery on ladattu osaksi tätä sivua. Huomaa myös, että ylläolevan koodin voi tehdä huomattavasti tehokkaammin.

DOM-puun muokkaus

JQuery lisää DOM-puun elementteihin toiminnallisuuksia, jotka helpottavat DOM-puun muokkausta. Esimerkiksi metodi removeClass poistaa elementiltä tai kokoelmalta elementtejä halutun luokan. Alla on sama esimerkki kuin yllä, mutta nyt piilotettujen elementtien tyyliluokka on jquery-dom-2-hidden.

text 1

text 2

text 3

Yllä olevassa esimerkissä haetaan kaikki elementit, joiden tyyliluokka on "jquery-dom-2-hidden", ja poistetaan niiltä haluttu tyyli. Koska uudet toiminnallisuudet on lisätty elementteihin, voidaan kyselyt myös ketjuttaa. Alla haetaan ensin kaikki elementit, joiden tyyliluokka on jquery-dom-3-hidden, jonka jälkeen haluttu tyyliluokka poistetaan.

text 1

text 2

text 3

Kyselyiden avulla voidaan luoda myös monimutkaisen näköisiä rakenteita. Alla haetaan kaikki body-elementin sisällä olevat solmut, joilla ei ole tunnusta "jquery-dom-4-js-esim", ja jotka eivät ole sen alla olevia textare tai input-elementtejä. Kun solmut on haettu, solmuille lisätään tyyliluokka "hidden".

Jos klikkaat ylläolevaa nappia, joutunet lataamaan sivun uudestaan tai muokkaamaan sivua konsolista, jotta saat sivun takaisin näkyville.

Tapahtumien käsittely

JQuery rakentaa JavaScriptin valmiiden komponenttien päälle, joten sillä on toiminnallisuus myös tapahtumankäsittelijöiden rekisteröimiseen sivun komponenteille. Tutkitaan seuraavaa jo tutuhkoa HTML-dokumenttia.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" ></meta>
        <title>Kindler</title>
        <link rel="stylesheet" href="style.css" type="text/css" >
    </head>
    <body onload="init();">
        <header>
            <h1>Kindler</h1>

            <nav>
                <a href="#">Eka artikkeli</a>
                <a href="#">Toka artikkeli</a>
                <a href="#">Kolmas artikkeli</a>
            </nav>
        </header>

        <section>
          <article>
            <h1>Eka artikkeli</h1>

            <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit...</p>
          </article>

          <article>
            <h1>Toka artikkeli</h1>

            <p>Morbi a elit enim, sit amet iaculis massa. Vivamus blandit...</p>
          </article>

          <article>
            <h1>Kolmas artikkeli</h1>

            <p>Now that we know who you are, I know who I am. I'm...</p>
          </article>
        </section>

        <!-- lähdekooditiedostojen lataus -->
        <script type="text/javascript" src="javascripts/jquery-2.1.3.min.js"></script>
        <script type="text/javascript" src="javascripts/koodit.js"></script>
    </body>
</html>

Olemme aiemmin määritelleet tapahtumankäsittelyn osana HTML-dokumenttia siten, että JavaScript-kutsut on lisätty erillisessä init-metodissa (unohdamme MVC-mallin hetkeksi esimerkin yksinkertaistamiseksi). Alla oleva lähdekoodi käyttää aiemmin oppimaamme querySelector-toteutusta siihen, että tapahtumankäsittelijät lisätään vain menuvalikon linkkeihin. Kutsu preventDefault() estää linkin seuraamisen.

function init() {
    var navLinks = document.querySelectorAll("header nav a");
    for(var i = 0; i < navLinks.length; i++) {
        var link = navLinks[i];

        // lisätään elementille id, josta päätellään näytettävä artikkeli
        link.id = i;

        // lisätään tapahtumankuuntelija tapahtumalle click. huom! ero onclick-attribuuttiin
        link.addEventListener('click', function(eventInformation) {
            var origin = eventInformation.target;

            // kutsutaan erillistä displayArticle-funkiota, joka
            // näyttää halutun artikkelin
            displayArticle(origin.id);

            // kielletään selainta tekemästä oletustoiminto (siirtyminen)
            eventInformation.preventDefault();
        }, false);
    }

    // ...
}

function displayArticle(index) {
    var articles = document.getElementsByTagName("article");

    for(var i = 0; i < articles.length; i++) {
        if (index == i) {
            articles[i].className='';
        } else {
            articles[i].className='hidden';
        }
    }
}

Muokataan ylläolevaa esimerkkiä siten, että käytämme JQueryä.

Muokataan ensin funktio displayArticle toiminnallisuutta. Toteutetaan se siten, että funktiokutsussa piilotamme aina ensin kaikki artikkelit, jonka jälkeen näytämme indeksin määräämän artikkelin. Valitsimella ":eq(indeksi)" voimme valita elementin tietystä indeksistä.

function displayArticle(index) {
    $("article").addClass("hidden");
    $("article:eq(" + index + ")").removeClass("hidden");
}

Lähdetään seuraavaksi pilkkomaan function init-toiminnallisuutta. Asetetaan funktion init toiminnallisuus ensin $(document).ready(function(){ });-lohkon sisään. Lohkon sisältö suoritetaan kun sivun lataaminen on valmis -- emme enää tarvitse body-elementin onload-attribuuttia. Koodi näyttää nyt seuraavalta:

$(document).ready(function(){
    var navLinks = document.querySelectorAll("header nav a");
    for(var i = 0; i < navLinks.length; i++) {
        var link = navLinks[i];

        // lisätään elementille id, josta päätellään näytettävä artikkeli
        link.id = i;

        // lisätään tapahtumankuuntelija tapahtumalle click. huom! ero onclick-attribuuttiin
        link.addEventListener('click', function(eventInformation) {
            var origin = eventInformation.target;

            // kutsutaan erillistä displayArticle-funkiota, joka
            // näyttää halutun artikkelin
            displayArticle(origin.id);

            // kielletään selainta tekemästä oletustoiminto (siirtyminen)
            eventInformation.preventDefault();
        }, false);
    }

    // ...
});

function displayArticle(index) {
    $("article").addClass("hidden");
    $("article:eq(" + index + ")").removeClass("hidden");
}

Luodaan ensin toiminnallisuus, jolla linkkeihin asetetaan tunnus-attribuutit. JQueryssä on kätevä kokoelman iterointiin tarkoitettu each-komento, joka saa JQueryltä parametrinaan iteroitavan elementin indeksin. Muuttuja $(this) viittaa kyseisellä hetkellä läpikäytävään muuttujaan. Allaoleva komento käy läpi jokaisen linkki-elementin, ja kutsuu kullekin each-komennolle parametrina antamaamme funktiota. Komento attr asettaa (tai hakee jos toista parametria ei määritellä) elementin komennossa määritellyn attribuutin.

$("header nav a").each(function(index) {
    $(this).attr("id", index);
});

Lisätään seuraavaksi jokaiselle linkille tapahtumankäsittelijä. Komento click auttaa tässä huomattavasti. Voimme hyödyntää myös aiemmin huomaamaamme each-komentoa. Alla lisäämme jokaiseen linkkiin funktion, joka kuuntelee klikkausta. Funktion sisältö lienee tuttu.

$("header nav a").each(function(index) {
    $(this).click(function(eventInformation) {
        displayArticle(eventInformation.target.id);
        eventInformation.preventDefault();
    });
});

Each-komento tarjoaa meille indeksin, joten muokataan edellistä vielä hieman. Käytetään suoraan each-komennon tarjoamaa indeksiä artikkelin näyttämiseen.

$("header nav a").each(function(index) {
    $(this).click(function(eventInformation) {
        displayArticle(index);
        eventInformation.preventDefault();
    });
});

Huomaamme vielä, että voimme yhdistää linkin tunnuksen lisäämisen ja tapahtumankäsittelyn lisäämisen saman each-komennon sisään.

$("header nav a").each(function(index) {
    $(this).attr("id", index);
    $(this).click(function(eventInformation) {
        displayArticle(index);
        eventInformation.preventDefault();
    });
});

Koodimme näyttää nyt kokonaisuudessaan seuraavalta:

$(document).ready(function(){

    // sattumalta saa parametrina indeksin
    $("header nav a").each(function(index) {
        $(this).attr("id", index);
        $(this).click(function(eventInformation) {
            displayArticle(index);
            eventInformation.preventDefault();
        });
    });
});

function displayArticle(index) {
    $("article").addClass("hidden");
    $("article:eq(" + index + ")").removeClass("hidden");
}

HTML-dokumentista on myös poistettu body-elementin onload-attribuutti.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" ></meta>
        <title>Kindler</title>
        <link rel="stylesheet" href="style.css" type="text/css" >
    </head>
    <body>

        <!-- sama sisältö kuin aiemminkin -->

        <script type="text/javascript" src="javascripts/jquery-2.1.3.min.js"></script>
        <script type="text/javascript" src="javascripts/koodit.js"></script>
    </body>
</html>

JQueryMOOC (1p)

Muokkaa tehtäväpohjan mukana tulevaa MOOC-sivua siten, että elementtien näyttäminen ja piilottaminen toteutetaan JQueryn tarjoamien apuvälineiden avulla.

Poista lopulta myös turhaksi tullut myös turha body-elementin onload-attribuutti.

Kun olet vaihtanut toteutuksen JQueryksi, ja toteutuksen toiminnallisuus on ennallaan, palauta tehtävä TMC:lle.

Joudut myös hakemaan jQueryn -- joko käsin jQueryn sivuilta, tai ottamalla selvää siitä, miten NetBeansin avulla lisätään kirjastoja projektiin.

Kyselyt palvelimelle

JQuery tarjoaa myös tuen kyselyjen tekemiseen erilliselle palvelinkomponentille.

Kyselyt hoituvat kätevästi JQueryn $.getJSON-funktiolla. Alla olevassa esimerkissä haemme ICNDb.comista oleellista dataa.

Kyselyn palauttama data ohjataan $.getJSON-funktion toisena parametrina määriteltävään funktioon. Alla olevassa esimerkissä kutsumme vain console.log-komentoa kaikelle palautettavalle datalle.

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        console.log(data);
    }
);

Ylläoleva esimerkki tulostaa vastaukset konsoliin -- huomaa, että jQuery muuntaa merkkijonomuotoiset vastaukset automaattisest JSON-olioksi. Käytetään JQueryn each-komentoa listassa olevien elementtien iterointiin. Komennolle each voi antaa parametrina iteroitavan listan, sekä funktion, jota kutsutaan jokaisella listassa olevalla oliolla.

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        $.each(data.value, function(i, item) {
            console.log(i);
            console.log(item);
            console.log("-----");
        });
    }
);

Nyt ylläoleva komento tulostaa vastauksen value-kentässä olevat oliot yksitellen. Oletetaan, että käytössämme on elementti, jonka tunnus on "vitsit". JQuery tarjoaa myös mahdollisuuden nopeaan tekstielementtien luontiin komennolla $("<p/>"). Elementteihin voi asettaa tekstin text-komennolla, ja elementin voi lisätä tietyllä tunnuksella määriteltyyn elementtiin komennolla appendTo("#tunnus").

$.getJSON("http://api.icndb.com/jokes/random/5",
    function(data) {
        $.each(data.value, function(i, item) {
            $("<p/>").text(item.joke).appendTo("#vitsit");
        });
    }
);

XMLHttpRequest

Jos tiedämme, että palvelu palauttaa JSON-dataa, voimme käyttää yllä käsiteltyä lähestymistapaa. Esimerkiksi viestien noutaminen Chat-chat -tehtävän viestipalvelimelta onnistuu seuraavalla komennolla. Tässä tapauksessa lisäämme jokaiseen viestiin liittyvän message-attribuutin "vitsit"-tunnuksella määriteltyyn elementtiin.

$.getJSON("http://bad.herokuapp.com/app/messages", function(data) {
    $.each(data, function(i, item) {
        $("<p/>").text(item.message).appendTo("#vitsit");
    });
});

Yllä oleva komento on lyhenne alla määritellystä komennosta.

$.ajax({
    url: "http://bad.herokuapp.com/app/messages",
    dataType: 'json',
    success: parseMessages
});

function parseMessages(messages) {
    $.each(messages, function(i, item) {
        $("<p/>").text(item.message).appendTo("#vitsit");
    });
}

Komennolle $.ajax voi lisätä myös dataa, mitä lähetetään palvelimelle. Esimerkiksi seuraavalla komennolla lähetetään osoitteeseen http://bad.herokuapp.com/app/in olio, jonka sisällä on attribuutit name ja details. Lähetettävän datan tyyppi asetetaan attribuutilla contentType, alla ilmoitamme että data on json-muotoista, ja että se käyttää utf-8 -merkistöä.

var dataToSend = JSON.stringify({
        name: "bob",
        details: "i'm ted"
    });

$.ajax({
    url: "http://bad.herokuapp.com/app/in",
    dataType: 'json',
    contentType:'application/json; charset=utf-8',
    type: 'post',
    data: dataToSend
});

Pyynnössä voi sekä lähettää että vastaanottaa dataa. Attribuutin success asettaminen ylläolevaan pyyntöön aiheuttaa success-attribuutin arvona olevan funktion kutsun kun pyyntö on onnistunut.

JQuerySpoilaajanBackend (2p)

Toteuta Spoilaajan Backend-tehtävä tässä JQueryn avulla. Jos hyödynsit aiemmin synkronisia kutsuja, kannattaa hyödyntää niitä myös tässä. Vinkki: $.ajax-komennolle voi asettaa attribuutin async: false, jolloin tulee määritellä success-attribuutille funktio, jota kutsutaan kun data on noudettu.

Joudut myös hakemaan jQueryn -- kannattanee viimeistään tässä vaiheessa ottaa selvää siitä, miten NetBeansin avulla lisätään kirjastoja projektiin.

Näkymätemplatet ja Mustache.js

Selainohjelmistojen rakennetta suunniteltaessa yksi huolenaihe on sivun näkymien järkevä hallinta. Aiemmin näkemässämme esimerkissä sivun osia piilotetaan ja näytetään dynaamisesti, toisaalta, olemme myös generoineet HTML-koodia Javascriptin sisältä. Tässä olemme huomanneet että generointi on auttamatta työlästä.

Näkymätemplatet ovat HTML-koodipätkiä, jotka sisältävät halutun HTML-rakenteen sekä paikat datalle. Eräs projekti näkymätemplatejen generointiin on Mustache.js, joka on mustache-projektin osa.

Käytännössä näkymätemplatejen käyttö toimii siten, että HTML-dokumenttiin piilotetaan osa, joka sisältää kaikki HTML-templatet. Kun käyttäjä esimerkiksi klikkaa linkkiä, renderöidään näkyvälle alueelle templaten ja datan pohjalta uusi sisältö. Tutkitaan alla olevaa esimerkkiä, jossa on osio tunnuksella "view", ja toinen script-tägillä merkitty osio, joka sisältää templatet. Script-tägillä merkityt osiot eivät renderöidy selaimelle.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" ></meta>
        <title></title>
    </head>
    <body>

        <section id="view"></section>

        <script id="template" type="text/html">
            <article>
                <h2>{{nimi}} {{ika}}</h2>
            </article>
        </script>

        <!-- lähdekooditiedostojen lataus -->
        <script type="text/javascript" src="javascripts/jquery-2.1.3.min.js"></script>
        <script type="text/javascript" src="javascripts/mustache.js"></script>
        <script type="text/javascript" src="javascripts/koodi.js"></script>
    </body>
</html>

Lähdekooditiedosto koodi.js sisältää seuraavanlaisen lähdekoodin.

$(document).ready(function() {
    var data = {
        nimi: "Bob",
        ika: function() {
            return 14+7;
        }
    };

    var html = Mustache.render($("#template").html(), data);
    $("#view").html(html);
});

Tutkitaan koodia hieman tarkemmin. Komennolla $(document).ready(function() { ... }); määritellään toiminnallisuus, joka suoritetaan kun sivu on latautunut. Toiminnallisuus sisältää data-olion luomisen, jolla on attribuutit nimi (arvo "Bob") ja ika (funktion palauttama arvo).

Tämän jälkeen oleva koodi ei olekaan vielä tuttua. JQuery tarjoaa pääsyn elementin html-koodiin komennolla html, eli komento $("#template").html() palauttaa tunnuksella "template" merkityn elementin sisältämän HTML-koodin. Tämä koodi annetaan Mustachen render-komennolle yhdessä data-olion kanssa. Mustachen render-komento asettaa olion datan sisällön HTML-koodissa {{avain}}-notaatiolla merkityille paikoille, ja palauttaa uuden merkkijonon. Merkkijono sisältää HTML-koodin, jossa merkittyihin kohtiin on asetettu datasta saadut arvot.

Lopuksi html-koodi asetetaan tunnuksella "view" määritellyn elementin sisään.

Listojen läpikäynti

Listojen läpikäynti onnistuu {{#muuttuja}}-operaattorilla, joka aloittaa läpikäynnin. Läpikäynnin lopetus tapahtuu {{/muuttuja}}-operaattorilla. Esimerkiksi alla on määritelty messages-attribuutissa olevien olioiden läpikäynti siten, että jokaiselta oliolta kutsutaan attribuuttia nickname ja message.

<script id="template">
     <article>
         {{#messages}}
             <p><strong>{{nickname}}:</strong> {{message}}</p>
         {{/messages}}
    </article>
</script>

Voimme kytkeä ylläolevan templaten helposti Chat-chat -tehtävän viestit hakevaan palveluun.

function parseMessages(messages) {
    var data = {
        messages: messages
    };

    var html = Mustache.render($("#template").html(), data);
    $("#view").html(html);
}

$(document).ready(function() {
    $.ajax({
        url: "http://bad.herokuapp.com/app/messages",
        dataType: 'json',
        success: parseMessages
    });
});

Ehkäpä tärkein muistettava ylläolevassa esimerkissä on se, että data tulee antaa Mustachelle olion sisällä. Jos render-komentoa kutsutaan suoraan messages-oliolla, joka sisältää listan chat-viestejä, ei Mustache tiedä miten toimia.

Movember! (2p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että näkymä generoidaan tarvittaessa HTML-templateista. Tiedostossa code.js olevaan muuttujaan data on asetettu valmiiksi HTML-dokumentin oleelliset.

Tehtävänäsi on muokata tiedostoa index.html siten, että se sisältää template-elementin, jonka pohjalta sivun näkymiä (perusmooc, materiaali, ...) voidaan luoda.

Muokkaa myös tiedostoa code.js siten, että kukin näkymä generoidaan aina linkkiä klikattaessa. Generoitu näkymä tulee asettaa osaksi näkymäaluetta, jonka joudut myös luomaan. Kannattaa hyödyntää aiemmin tekemääsi JQueryMOOC-pohjaa.

Etsi myös JQuery ja Mustache.js projektiisi.

Case: Henkilötiedot

Rakennetaan sovellus, jossa käyttäjä voi etsiä henkilöitä ja katsoa yksittäisen henkilön tietoja. Henkilöiden etsiminen tapahtuu kirjoittamalla tekstikenttään osa henkilön nimestä, tai henkilön nimi kokonaan. Henkilön nimeä kirjoitettaessa sovellus ehdottaa vaihtoehtoja. Kun oikea vaihtoehto löydetään, käyttäjä voi klikata nimeä ja katsoa henkilöön liittyviä tietoja.

Käytössämme on seuraavanlainen datajoukko.

var data = [{ "id": 1, "name": "homer", "age": 44 },
  { "id": 2, "name": "bart", "age": 12 },
  { "id": 3, "name": "maggie", "age": 4 },
  { "id": 4, "name": "lisa", "age": 10 },
  { "id": 5, "name": "marge", "age": 43 },
  { "id": 6, "name": "abraham", "age": 85 },
  { "id": 7, "name": "mona", "age": 81 },
  { "id": 8, "name": "amber", "age": 51 }]

Henkilöiden listaaminen

Jotta henkilöä voi etsiä, tulee sovelluksessa olla tekstikenttä nimen täyttämiseen, sekä alue henkilöiden näyttämiseen. Luodaan sovellukselle elementit etsimiseen ja tulosten listaamiseen.

<section>
    <input type="text" id="searchbox"/>
</section>

<section id="resultview"></section>

Tunnuksella searchbox merkittyä elementtiä käytetään tekstin syöttämiseen, ja tunnuksella resultview merkittyä elementtiä tulosten näyttämiseen. Luodaan aivan ensin toiminnallisuus datajoukon näyttämiseen osana tuloslistaa. Käytetään tähän templatea.

<script id="searchresulttemplate" type="text/html">
    {{#list}}
    <p>{{name}}</p>
    {{/list}}
</script>

Yllä oleva template käy listan nimeltä list läpi siten, että se tulostaa jokaisen listalla olevan elementin attribuutin name p-elementin sisään. Käytännössä sille tulee siis antaa olio, jolla on attribuutti list, jotta se voi tulostaa alkiot.

var data = [{ "id": 1, "name": "homer", "age": 44 },
  { "id": 2, "name": "bart", "age": 12 },
  { "id": 3, "name": "maggie", "age": 4 },
  { "id": 4, "name": "lisa", "age": 10 },
  { "id": 5, "name": "marge", "age": 43 },
  { "id": 6, "name": "abraham", "age": 85 },
  { "id": 7, "name": "mona", "age": 81 },
  { "id": 8, "name": "amber", "age": 51 }]

// huom! attribuutti list sisältää datan
var dataForTemplate = {
  list: data
}

Luodaan toiminnallisuus, jonka avulla lista näytetään kun käyttäjä on kirjoittanut jotain tekstialueeseen. Käytetään tähän keyup-komentoa.

$(document).ready(function() {
    $("#searchbox").keyup(function() {
      showdata();
    });
});

Funktio showdata näyttää datan sivulla. Luodaan aivan ensiksi funktio, joka näyttää aina koko datan. Jotta datan renderöinti onnistuu aiemmin määrittelemämme templaten avulla, tulee data asettaa erillisen olion attribuutin list arvoksi.

function showdata() {
  var dataForTemplate = {
    list: data
  }

  // renderöidään tulokset mustachen avulla
  var html = Mustache.render($("#searchresulttemplate").html(), dataForTemplate);
  // näytetään tulokset
  $("#resultview").html(html);
}

Nyt sivumme näyttää tulokset.

Henkilöiden filtteröinti

Toteutetaan seuraavaksi henkilöiden filtteröinti. Filtteröinnin toteutus onnistuu helpohkosti käyttämällä edellä olevaa lähestymistapaa, ja muokkaamalla muuttujaa dataForTemplate JQueryn valmiin grep-komennon avulla. Komennolle grep annetaan parametrina funktio, joka saa grep-komennolta sille parametrina annetusta listasta aina yksittäisen alkion ja sen indeksin. Funktion tulee palauttaa arvo true tai false riippuen siitä halutaanko alkio säilyttää. Alla olevassa esimerkissä tarkistetaan onko parametrina saadun alkion attribuutti nimi "Mikke".

data = $.grep(data, function(element, index) {
  return element.name === "Mikke";
});

Omassa toteutuksessamme haluamme etsiä henkilöä, jonka nimessä on searchbox-elementissä oleva arvo. Koska käsittelemämme lista on muuttujan dataForTemplate attribuutti, annetaan grep-komennolla parametrina kyseinen attribuutti.

function showdata() {
  var dataForTemplate = {
    list: data
  }

  // filtteröinti
  var mustContain = $("#searchbox").val();
  dataForTemplate.list = $.grep(dataForTemplate.list, function(person, index) {
    return person.name.indexOf(mustContain) != -1;
  });

  // renderöidään tulokset mustachen avulla
  var html = Mustache.render($("#searchresulttemplate").html(), dataForTemplate);
  // näytetään tulokset
  $("#resultview").html(html);
}

Kirjahaku (3p)

Toteuta aiempaa esimerkkiä seuraten toiminnallisuus kirjojen hakemiseen. Kirjojen haun tulee tapahtua tehtäväpohjassa tulevan JSON-datan sisältämien olioiden title-kentän perusteella.

Toimivassa sovelluksessa on hakukenttä sekä lista kirjoja. Kun hakukenttään kirjoitetaan kirjan nimi tai sen osa, tulee listauksessa olevien kirjojen päivittyä siten, että listalla näytetään vain kirjat, jotka osuvat hakusanaan. Toteuta hakutoiminnallisuudesta sellainen, että kirjainten suuruudella (tai pienuudella) ei ole väliä.

Kannattanee noutaa myös oleelliset javascript-kirjastot projektiin sekä viilata käyttöliittymästä käytettävä.

Viikko 4

Debuggerin käyttö Chromessa

JavaScript-koodia voi debugata monella eri tavalla, esim. tulostamalla muuttujien arvoja console.log() komennolla selaimen konsoliin. Tämä tapa on hyvä ja toimiva, mutta voi monesti olla hieman raskasta kirjoitella usealle eri muuttujalle eri puolille sovellusta omia logituksia.

Eräs toinen tapa on käyttää debuggeria. Sen käyttö on hyvin yksinkertaista. JavaScript-koodiin kirjoitetaan haluttuun kohtaan debugger;. Kun selain suorittaa kyseisen rivin koodista, koodin suorittaminen keskeytyy.

Kun suoritus on keskeytynyt, voidaan konsolissa tutkailla sillä hetkellä näkyvissä olevien muuttujien arvoja. Oletuksena Chrome avaa debuggeriin törmätessään dev-työkaluissa Sources-välilehden ja korostaa rivin, jolla debugger; pysäytti koodin suorittamisen:

Avaamalla Console-välilehden, voi kyseisen for-loopin muuttujien arvoja tutkia kirjoittamalla muuttujien nimiä aktiivisena olevalle tekstikentälle konsolin alaosassa:

Mikäli edellä mainitussa for-loopissa ensimmäisen kierroksen arvot eivät ole debuggauksen kannalta oleellisia, voi koodia suorittaa askel askeleelta eteenpäin kunnes ollaan sellaisessa tilassa, josta arvoja halutaan tarkastella:

Kuvan vasemman puoleinen nappi jatkaa koodin suorittamista normaaliin tapaan ja oikean puolinen nappi mahdollistaa koodin suorituksen askel kerrallaan eteenpäin.

Aiheesta voi lukea lisää osoitteesta https://developer.chrome.com/devtools/docs/javascript-debugging

Sovelluksen rakenteen hallinta: AngularJS

Olemme tähän mennessä onnistuneesti pystyneet esittämään sovelluksemme dataa käyttöliittymässä ja jopa välittämään käyttöliittymästä tehtyjä muutoksia sovelluslogiikkaan. Mustache tekee datan esittämisestä näkymässä melko helppoa, mutta kuten olet ehkä huomannut, datan muokkaaminen näkymän kautta ei ole aivan niin helppoa. Ongelmat ilmenevät etenkin sovelluksen kasvaessa, jolloin sen rakenteesta tulee aina vain sekavampi. Rakennetta selkeyttää huomattavasti käyttöliittymän erottaminen sovelluksen datasta, johon jo mainittu MVC-arkkitehtuuri pyrkiikin. Selkeän MVC-arkkitehtuurin aikaansaaminen on kuitenkin yllättävän vaikeaa, jos työkalut eivät ole oikeat.

Apuun tulee suuren suosion saavuttanut AngularJS MVW-sovelluskehys. Edellisessä lauseessa ei ole kirjoitusvirhettä, Angularia ei voi rajata pelkäksi MVC-sovelluskehykseksi (vaikkakin niin usein tehdään), vaan tarkempi termi onkin "Model View Whatever". Se siis esittää mallia näkymässä ja välittää näkymässä tehtyjä muutoksia takaisin malliin. Mitä mallin ja näkymän rajapinnassa tapahtuu, on ohjelmoijan itsensä päätettävissä.

Mikä Angularissa on hienoa, on se, että se tekee luonteeltaan staattisesta HTML:stä dynaamista, jolloin malli on sekä helppo esittää näkymässä, että helppo muokata näkymän kautta. Otetaan pieni esimerkki, joka havainnollistaa, kuinka paljon voimme saada aikaan kirjoittamatta riviäkään JavaScriptiä:

<html>
  <head>
  </head>
  <body>
    <div ng-app>
      <h1>Hello {{name}}!</h1>
      <input type="text" ng-model="name">
    </div>
  </body>
</html>

Huomaat ensimmäiseksi, että Angularissa ja Mustachessa on sama syntaksi muuttujan upottamiseksi näkymään, eli {{muuttuja}}. Esimerkissä olemme upottaneet näkymäämme muuttujan name. Muuttuja ei ole vielä määritelty, joten näkymässä on pelkästään otsikko "Hello !". Otsikon alapuolella on tekstikenttä, jolle on määritelty attribuutti ng-model, jonka arvo on name. Tämä tarkoittaa sitä, että olemme sitoneet tekstikentän arvon muuttujaan name. Siis, jos muutamme tekstikentän arvoa, myös otsikon sisältö muuttuu. Tulemme myös myöhemmin huomaamaan, että jos muutamme muuttujan name arvoa sovelluslogiikassa, muuttuu myös tekstikentän arvo. Tämän yksinkertaiselta kuulostavan toiminnon toteuttaminen käyttämällä jQuerya ja Mustachea olisi helposti vienyt näin monta riviä koodia:

var name = "";

function renderHeading(){
  var html = Mustache.render('Hello {{name}}!', { name: name });
  $('h1').html(html);
}

$(document).ready(function(){
  renderHeading();

  $('input[type="text"]').on('keyup', function(){
    name = $(this).val();
    renderHeading();
  });
});

Kokeile Angularilla toteutetua esimerkkiä vielä itse:

Kontrollerit

Kuten on jo mainittu, kontrolleri on MVC-arkkitehtuurissa mallin ja näkymän yhdistävä tekijä. Se siis välittää esitettävän datan mallilta näkymälle ja muokkaa mallia näkymän pyynnöstä. Katsotaan, miten voimme liittää edelliseen esimerkkiimme kontrollerin käyttämällä Angularia:

function HelloController($scope){
  $scope.name = 'Kalle';
}

Se on siinä! Määrittelemme siis vain funktion nimeltä HelloController, jolla on yksi parametri. Parametrina saadun muuttujan $scope ("scope", suomeksi "näkyvyysalue") avulla toteuttamamme kontrolleri keskustelee näkymän kanssa. Se on objekti, johon voimme liittää kenttiä, joita voimme näyttää ja muokata näkymässä. Näkymään voi välittää sen kautta oikeastaan mitä vain, merkkijonoja, kokonaislukuja, objekteja tai jopa funktioita. Tässä esimerkissä välitämme näkymäämme vain muuttujan name, joka onnistuu lisäämälle $scope-objektiin kentän name.

Jotta näkymä tietäisi, minkä kontrollerin kanssa se on vuorovaikutuksessa, täytyy näkymään määrittää kontrollerin näkyvyysalue lisäämällä johonkin HTML-tagiin attribuutti ng-controller="HelloController" ja se elementti lapsineen muodostaa kontrollerin näkyvyysalueen. Liitetään toteuttamamme HelloController näkymäämme:

<html>
  <head>
  </head>
  <body ng-app>
    <div ng-controller="HelloController">
      <h1>Hello {{name}}!</h1>
      <input type="text" ng-model="name">
    </div>
  </body>
</html>

Nyt kontrollerimme näkyvyysalue on div-elementti, ja kaikki sen lapset. Näkymän otsikon sisältö ei ole enää pelkästään "Hello!", vaan muuttujalla name on asetettu kontrollerissa alkuarvo, "Kalle". Otsikon sisältö on siis "Hello Kalle!" ja lisäksi tekstikentän, jonka arvo on sidottu muuttujaan name, sisältö on "Kalle". On myös tärkeää lisätä ng-app-attribuutti johonkin DOM-elementtiin, joka sisältää kaikki elementit, jotka ovat kytköksissä Angulariin. Lisäsin sen tässä esimerkissä body-tagiin, mutta olisin voinyt lisätä sen yhtä hyvin html-tagiin.

My name is Bond, James Bond (1p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että näkymän otsikon sisältö määräytyy "etunimi" ja "sukunimi" tekstikenttien perusteella. Jos siis "etunimi"-kentän arvo on "Kalle" ja "sukunimi"-kentän arvo on "Ilves", on otsikon sisältö "My names is Ilves, Kalle Ilves". Otsikon alkuarvon tulee olla "My name is Bond, James Bond". Joudut tekemään muutoksia näkymän lisäksi BondController-kontrolleriin, joka sijaitsee tiedostossa app/app.js.

Näkymät

Angularissa jo pelkästään mallin esittäminen näkymässä on huomattavasti monipuolisempaa, kuin esimerkiksi Mustachen kanssa. Havainnollistetaan sitä tämän esimerkkikontrollerin avulla:

function ExampleController($scope){
  $scope.person = {
    name: 'Kalle',
    age: 22,
    happy: true,
    friends: ['Henri', 'Arto', 'Elina', 'Jorma']
  };
}

Määritin siis ExampleController-funktiossa person-objektin, jonka haluan välittää näkymälle.

Muuttujien upottaminen

Toteutetaan seuraavaksi näkymä, joka esittää person-objektin kentät. Aloitetaan name- ja age-kenttien esittämisellä:

<html>
  <head>
  </head>
  <body ng-app>
    <div ng-controller="ExampleController">
      <h1>{{person.name}}, {{person.age}} vuotta vanha</h1>
    </div>
  </body>
</html>

Näkymässä ei tapahdu mitään kovin kummallista, siihen upotetaan arvot person.age ja person.name syntaksilla {{muuttuja}}, joka on jo ennestään tuttu Mustachen kanssa. Näkymään ilmestyy siis otsikko, jonka sisältö on "Kalle, 22 vuotta vanha".

Ehtolauseet ja DOM-elementtien muokkaus ehtojen perusteella

Lisätään seuraavaksi näkymään tieto henkilön mielentilasta:

<h1>{{person.name}}, {{person.age}} vuotta vanha</h1>
<p>
  Fiilis on
  <span ng-if="person.happy">:)</span>
  <span ng-if="!person.happy">:(</span>
</p>

Lisäämällä ng-if-attribuutin elementtiin, pystymme määrittämään ehdon sen esittämiselle. Ehto määräytyy ng-if-attribuutin arvon perusteella, jonka tulee olla jokin totuusarvo. Koska kontrollerissa on määritelty person.happy = true, toteutuu attribuutissa määritelty ehto, joten span-elementti sisältöineen on näkymässä.

On lisäksi monia muita tapoja manipuloida DOM-elementtejä ehtojen perusteella. Voimme esimerkiksi lisätä span-elementtiin luokan green, jos ehto person.happy toteutuu. Vastaavasti, jos se ei toteudu, voimme lisätä siihen luokan red:

<h1>{{person.name}}, {{person.age}} vuotta vanha</h1>
<p>
  Fiilis on
  <span ng-if="person.happy" ng-class="{ 'green': person.happy }">:)</span>
  <span ng-if="!person.happy" ng-class="{ 'red': person.happy }">:(</span>
</p>

ng-class-attribuutin sisältö on JSON-muodossa oleva merkkijono, jonka kenttinä on lisättävä luokka ja arvona ehto, joka kyseisen luokan lisäämiseen kuuluu. Samalla tavalla toimii mm. ng-attr, joka lisää DOM-elementtiin tietyn attribuutin, jos annettu ehto toteutuu.

Listojen läpikäynti

Lopuksi täytyy vielä esittää lista henkilön ystävistä. Kuten Mustachessa, myös Angularissa on oma toistorakenteensa:

<h1>{{person.name}}, {{person.age}} vuotta vanha</h1>
<p>
  Fiilis on
  <span ng-if="person.happy">:)</span>
  <span ng-if="!person.happy">:(</span>
</p>
<ul>
  <li ng-repeat="friend in person.friends">{{friend}}</li>
</ul>

Toisto siis onnistuu lisäämällä attribuutti ng-repeat elementtiin, jonka sisältöä halutaan toistaa. Attribuutin arvo on muotoa iteroitavaAlkio in lista. Toistojen määrää voi rajoittaa lisäämällä ng-repeat-attribuuttiin limitTo filtteri seuraavasti:

<ul>
  <li ng-repeat="friend in person.friends | limitTo: 5">{{friend}}</li>
</ul>

Yllä olevassa esimerkissä tulostetaan vain viisi ensimmäistä kaveria, koska limitTo filtterille on annettu parametriksi kokonaisluku 5, joka kertoo rajan toistettaville alkioille. Toinen hyödillinen filtteri on orderBy, joka toistaa taulukon alkiot tietyssä järjestyksessä, esimerkiksi nimen perusteella:

<ul>
  <li ng-repeat="friend in person.friends | orderBy">{{friend}}</li>
</ul>

Koska taulukko sisältää merkkijonoja, ei orderBy-filtterille tarvitse antaa parametreja. Jos friend-muuttuja olisi ollut objekti, jolla on kenttä name olisi filtterin pitänyt olla orderBy: 'name'. Jos haluaa järjestää ystävät nimen mukaan käänteisessä järjestyksessä filtteri olisi taas orderBy: '-name'.

Tustustutaan vielä lopuksi filtteriin filter, joka toistaa vain alkiot, jotka toteuttavat annetun ehdon, esimerkiksi sisältävät kirjaimen "a":

<ul>
  <li ng-repeat="friend in person.friends | filter: 'a'">{{friend}}</li>
</ul>

filter-filtterin parametri olisi yhtä hyvin voinut olla esimerkiksi muuttuja tai funktio.

Nättiä dataa (1p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että se listaa (vinkki: ng-repeat) index.html-näkymässä MovieController-kontrollerissa (löytyy tiedostosta app/app.js) määritellyn taulukon movies elokuvat siten, että lista näyttää kutakuinkin tältä:

The Lord of the Rings: The Fellowship of the Ring (2001)

A meek hobbit of the Shire and eight companions set out on a journey to Mount Doom to destroy the One Ring and the dark lord Sauron.

Director

Peter Jackson

Oscar awards (4)

  • Best Cinematography
  • Best Makeup
  • Best Music, Original Score
  • Best Effects, Visual Effects

Roles

  • Elijah Wood (Frodo)
  • Sean Astin (Sam)
  • Billy Boyd (Pippin)
  • Dominic Monaghan (Merry)
  • Viggo Mortensen (Aragorn)
  • Orlando Bloom (Legolas)

Honey, I Shrunk the Kids (1989)

The scientist father of a teenage girl and boy accidentally shrinks his and two other neighborhood teens to the size of insects. Now the teens must fight diminutive dangers as the father searches for them.

Director

Joe Johnston

Roles

  • Rick Moranis (Wayne Szalinski)
  • Marcia Strassman (Diane Szalinski)
  • Kristine Sutherland (Mae Thompson)

My Neighbor Totoro (1988)

When two girls move to the country to be near their ailing mother, they have adventures with the wonderous forest spirits who live nearby.

Director

Hayao Miyazaki

Roles

  • Toshiyuki Amagasa (Kanta)
  • Brianne Brozey (Michiko)
  • Cheryl Chase (Mei)
  • Dakota Fanning (Satsuki)

Elokuvan otsikko sisältää sen nimen ja julkaisuvuoden. Elokuvan nimen tulee olla linkki sen sivulle IMDb:ssä (vinkki: ng-href). Oscar-palkinnot tulee näyttää vain, jos elokuvalla niitä on (vinkki: ng-if). "Oscar awards" otsikon vieressä suluissa on Oscar-palkintojen lukumäärä. Elokuvan näyttelijät tulee listata (vinkki: ng-repeat) niin, että näyttelijän nimi on ensin ja sen jälkeen suluissa näyttelijän roolinimi elokuvassa. Järjestä elokuvien lista julkaisuvuoden perusteella (vinkki: orderBy), niin että uusin elokuva on listan kärjessä.

Mallin muokkaaminen näkymässä

Pelkkä mallin esittäminen näkymässä ei riitä, jos sitä ei pysty myös muokkaamaan. Angularissa mallin pystyy helposti sitomaan lomakkeen elementteihin, kuten tekstikenttiin, radio-painikkeisiin ja checkboxeihin. Palataan takaisin ExampleController-kontrollerin pariin, jossa välitimme näkymään objektin person. Katsotaan, miten voimme muokata person-objektia näkymästämme käsin. Aloitetaan name-kentän muokkaamisella, jota haluan pystyä muokkaamaan tekstikentän avulla:

<label>Nimi</label>
<input type="text" ng-model="person.name">

Riittää siis vain määrittää tekstikentän ng-model attribuuttiin muuttuja, jonka arvo halutaan sitoa sen arvoon. Nyt kun tekstikentän arvo muuttuu, muuttuu myös person-objektin name kentän arvo.

Totuusarvoa on kätevä muuttaa näkymissä sitomalla sen arvo checkboxin arvoon. Voimme siis helposti muuttaa person-objektin happy-kentän arvoa seuraavasti:

<label>Iloinen?</label>
<input type="checkbox" ng-model="person.happy">

Angularissa pystyt sitomaan muuttujan arvon lähes jokaiseen eri lomakkeen elementtiin lisäämällä sille ng-model-attribuutin, jonka arvona on sidotun muuttujan nimi.

ng-model-attribuutissa määritellyissä muuttujissa on käytössä nk. "two way data binding", jolloin mallin muuttaminen kontrollerissa aiheuttaa näkymän päivityksen ja samoin mallin muuttaminen näkymässä aiheuttaa siihen muutoksen kontrollerissa.

Elokuvafiltteri (1p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että index.html-näkymässä muodostettua elokuvien listaa pystyy filtteröimään nimen, julkaisuvuoden ja ohjaajan perusteella käyttämällä näkymässä olevia kenttiä. Jos siis käyttäjä syöttää esimerkiksi "nimi"-kenttään arvon "The Lord" ja "ohjaaja"-kenttään arvon "Peter", tulee näkymässä listata vain elokuvat, joiden nimestä löytyy sana "The Lord" ja ohjaajan nimestä löytyy sana "Peter". Filtteröintiin tutustumisesta on tehtävässä paljon apua. Taulukko movies löytyy kontrollerista MovieController (joka löytyy tiedostosta app/app.js). Älä tee muutoksia movies-taulukkoon, mutta muuten voit tehdä kontrolleriin haluamiasi muutoksia.

Lomakkeiden validointi

Katsotaan seuraavaksi hieman, kuina pystymme validoimaan käyttäjän syötteitä Angularin avulla. Otetaan esimerkiksi tämä lomake, joka sisältää kentät henkilötiedoille:

<form name="infoForm">
  <p>
    <label>Etunimi</label>
    <input name="firstName" type="text" ng-model="firstName">
  </p>
  <p>
    <label>Sukunimi</label>
    <input name="lastName" type="text" ng-model="lastName">
  </p>
  <p>
    <label>Puhelinnumero</label>
    <input name="phoneNumber" type="text" ng-model="phoneNumber">
  </p>
  <p>
    <label>Sähköpostiosoite</label>
    <input name="emailAddress" type="text" ng-model="emailAddress">
  </p>
  <p>
    <input type="submit" value="Lähetä">
  </p>
</form>

Haluaisin, että yllä oleva lomake validoitaisiin seuraavasti:

Huh, kuulostaa aikamoiselta urakalta! Onneksi se ei Angularin kanssa ole. Aloitetaan etunimen ja sukunimen validoinnista. Jos kentät eivät ole valideja, lisätään niiden alapuolelle virheilmoitukset:

<p>
  <label>Etunimi</label>
  <input name="firstName" type="text" ng-model="firstName" required ng-minlength="2">
</p>
<p>
  <label>Sukunimi</label>
  <input name="lastName" type="text" ng-model="lastName" required ng-minlength="2">
</p>

Kenttien validointi tapahtuu lisäämälle niihin validointiin liittyviä attribuutteja. Molempiin tekstikenttiin on lisätty attribuutti required, joka kertoo, että kentässä tulee olla sisältöä. Lisäksi molemmissa on attribuutti ng-minlength, jonka arvo on 2. Se kertoo, että kentän pituuden tulee olla vähintään kaksi merkkiä.

Puhelinnumeron ja sähköpostiosoitteen kentät ovat hieman hankalampia, niissä pitää käyttää säännöllistälauseketta. Ei hätää, voimme määrittää niille attribuutin ng-pattern, jonka arvoksi määritämme haluamamme säännöllisenlausekkeen:

<p>
  <label>Puhelinnumero</label>
  <input name="phoneNumber" type="text" ng-model="phoneNumber" ng-pattern="/^[0-9]{9,}$/">
</p>
<p>
  <label>Sähköpostiosoite</label>
  <input name="emailAddress" type="text" ng-model="emailAddress" ng-pattern="/^.+@.+\..+$/">
</p>

Nyt puhelinnumerossa täytyy olla vähintään yhdeksän numeroa ja sähköpostiosoite on muotoa jotain@jotain.jotain. Huomaa, että säännöllinen lauseke tulee sijoittaa merkkien /^ ja $/ väliin.

Seuraavaksi käyttäjälle pitäisi näyttää virheilmoitukset, jos hän on täyttänyt lomakkeet väärin. Lisätään jokaisen kentän alle virheilmoitus, jos se ei ole validi:

<div ng-app>
  <form name="infoForm">
    <p>
      <label>Etunimi</label>
      <input name="firstName" type="text" ng-model="firstName" required ng-minlength="2">
      <div ng-show="infoForm.firstName.$invalid && infoForm.firstName.$dirty">
        Etunimi ei ole validi!
      </div>
    </p>
    <p>
      <label>Sukunimi</label>
      <input name="lastName" type="text" ng-model="lastName" required ng-minlength="2">
      <div ng-show="infoForm.lastName.$invalid && infoForm.lastName.$dirty">
        Sukunimi ei ole validi!
      </div>
    </p>
    <p>
      <label>Puhelinnumero</label>
      <input name="phoneNumber" type="text" ng-model="phoneNumber" required ng-pattern="/^[0-9]{9,}$/">
      <div ng-show="infoForm.phoneNumber.$invalid && infoForm.phoneNumber.$dirty">
        Puhelinnumero ei ole validi!
      </div>
    </p>
    <p>
      <label>Sähköpostiosoite</label>
      <input name="emailAddress" type="text" ng-model="emailAddress" required ng-pattern="/^.+@.+\..+$/">
      <div ng-show="infoForm.emailAddress.$invalid && infoForm.emailAddress.$dirty">
        Sähköpostiosoite ei ole validi!
      </div>
    </p>
    <p>
      <input type="submit" value="Lähetä" ng-disabled="infoForm.$invalid">
    </p>
  </form>
</div>

Jokaiseen kenttään on lisätty virheilmoitus, joka näytetään, jos kenttä ei ole validi ja sitä on muokattu. Kentän oikeellisuuden pystyy tarkastamaan arvosta lomakkeenName.kentanName.$invalid, jossa lomakkeenName on form-elementin name-attribuutin arvo, jonka sisällä kenttä sijaitsee ja kentanName kyseisen kentän name-attribuutin arvo. Esimerkiksi infoForm.firstName.$invalid on false, jos etunimen kenttä on validi ja true, jos se ei ole validi. Voimme tarkistaa onko koko lomake validi arvosta lomakkeenName.$invalid. Lisäksi voimme tarkistaa onko kenttä validi yksittäinen validaattorin perusteella, esimerkiksi required-validaattorin perusteella, syntaksilla lomakkeenName.kentanName.$error.required (true, jos kenttä ei ole validi require-validaattorin perusteella). Esimerkissä otin "Lähetä"-painikkeen pois käytöstä, jos lomake ei ole validi. Arvo lomakkeenName.kentanName.$dirty taas kertoo, onko kentän arvoa muutettu. Se on lisätty ehtoon, koska emme halua näyttää virheilmoituksia ennen kuin käyttäjä on ehtinyt syöttää mitään kenttään.

Lisää lomakkeista ja niiden validoinnista voit lukea täältä.

Tässä vielä lopputulos:

Validi sen olla pitää! (2p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että siinä oleva lomake validoidaan. Lomake on validi, jos seuraavat ehdot ovat voimassa:

  • Käyttäjätunnus on vähintään kolme merkkiä pitkä.
  • Käyttäjän salasanan pituus on vähintään kahdeksan merkkiä ja sisältää vähintään yhden numeron ja ison kirjaimen.
  • Käyttäjän antama salasana ja sen vahvistus vastaavat toisiaan. Käytetään tähän Angulariin toteutettua direktiiviä, jonka käyttöön löydät ohjeet täältä. Asennus on jo tehty, tutustu vain lyhyesti käyttöohjeisiin.
  • Sekä etu-, että sukunimi on pituudeltaan vähintään kaksi merkkiä ja koostuvat pelkistä kirjaimista.
  • Henkilötunnus on täällä määritellyn mukainen. (Vinkki: katso "Custom validation" täältä.)
  • Käyttäjä on hyväksynyt käyttöehdot (checkboxi on valittu).

Poista lomakkeen lähetyspainike käytöstä, kunnes lomake on validi (vinkki: ng-disabled). Näytä lisäksi kenttiin kohdistuvat virheilmoitukset, jos niille on tarvetta, kunhan käyttäjä on ehtinyt syöttää kenttään arvon. Yksittäisen validaattorin virheitä ei tarvitse näyttää, riittää ilmoittaa, jos kenttä ei ole validi jonkin validaattorin perusteella. Muista sitoa jokainen kenttä jonkin muuttujan arvoon ng-model-attribuutin avulla, muuten validointi ei toimi.

Tapahtumat

Puhutaan seuraavaksi hieman siitä, kuinka voimme sitoa tapahtumia näkymäämme. Puhuimme jo siitä, että näkymään voi välittää $scope-parametrin kautta myös funktioita. Kuulostaa siltä, että voisimme siis kutsua kontrollerissa määriteltyä funktiota näkymästä käsin.

Olemme tottuneet jQueryssa siihen, että tapahtumat sidotaan johonkin valitsimeen, esimerkiksi id:llä nappula varustettuun painikkeeseen sidottaisiin click, eli hiirenpainallus elementin päällä seuraavasti:

$('#nappula').on('click', function(){
  alert('Painoit nappulaa!');
});

Tapahtumat sidotaan siis jQueryssa käyttöliittymälogiikan puolella. Tässä tavassa ei periaatteessa ole mitään vikaa, mutta kun tapahtumien määrä kasvaa, täyttyy käyttöliittymälogiikka oudoista valitsimista ja kukaan ei enää muista, mihin näkymän elementtiin kukin valitsin liittyi.

Angular lähestyy tapahtumien sitomista elementteihin toisesta näkökulmasta sitomalla tapahtumat näkymässä suoraan DOM-elementtiin valitsimen sijaan. Tapa muistuttaa hieman kurssin alkupuolella esitettyä tapaa sitoa tapahtumia DOM-elementteihin. Äskeinen jQuery esimerkki voidaan toteuttaa Angularilla seuraavasti:

<div ng-controller="NotificationController">
  <button ng-click="notify('Painoit nappulaa!')" id="nappula">Paina nappulaa!</button>
</div>

Sidoimme siis button-elementtiin click-tapahtuman asettamalla sille ng-click-attribuutin, jonka arvona on funktionkutsu. Eri tapahtumia on lukuisia ja yleensä ne muistuttavat nimissään jQueryn tapahtumia, esimerkiksi ng-mousemove, ng-keyup ja ng-focus. Nyt koodin ulkopuolinen katselijakin näkee helposti, mitä tapahtumia mihinkin DOM-elementtiin on sidottu ilman, että hänen täytyy tulkita valitsimia. Yllä oleva koodi ei tosin itsessään tee vielä mitään, koska funktiota notify ei ole määritelty. Toteutetaan siis kontrolleri, josta kyseinen funktio löytyy:

function NotificationController($scope){
  $scope.notify = function(notification){
    alert(notification);
  }
}

Kuten jo puhuimme, näkymään voi välittää myös funktion, jonka nyt teimme lisäämällä $scope-objektiin kentän notify, jonka arvona on funktio.

Palataan hetkeksi ExampleController-funktion pariin, jonka kanssa työskentelimme vähän aikaa sitten. Toteutetaan siihen toiminto, jonka avulla henkilön ystäviä voi lisätä ja poistaa:

function ExampleController($scope){
  $scope.person = {
    name: 'Kalle',
    age: 22,
    happy: true,
    friends: ['Henri', 'Arto', 'Elina', 'Jorma']
  };

  $scope.addFriend = function(){
    $scope.person.friends.push($scope.newFriend);
  }

  $scope.removeFriend = function(index){
    $scope.person.friends.splice(index, 1);
  }
}

Ennen kuin etenemme pidemmälle, katsotaan, mitä muutoksia teimme. Lisäsimme siis funktiot addFriend ja removeFriend parametrin $scope kentiksi, jolloin ne ovat käytettävissä näkymässä. Funktio addFriend lisää ystävät sisältävään taulukkoon uuden ystävän. Emme ole vielä määritelleet muuttujaa newFriend, mutta tulemme tekemään sen pian näkymässämme. Toteutimme myös funktion removeFriend, joka poistaa ystävän taulukon tietystä indeksistä käyttäen splice-funktiota, joka poistaa taulukosta tietyn määrän alkioita alkaen tietystä indeksistä (järjestys on splice(indeksi, lukumäärä)).

Nyt kaikki kontrollerissa tehdyt muutokset on käyty läpi, joten siirrytäänpä näkymän pariin. Aloitetaan lisäystoiminnosta. Tarvitsemme tekstikentän, johon käyttäjä voi lisätä uuden ystävän nimen ja painikkeen, jota painamalla ystävä lisätään:

<div ng-app>
  <div ng-controller="ExampleController">
    <input type="text" ng-model="newFriend">
    <button ng-click="addFriend()">Lisää ystävä</button>
  </div>
</div>

Sidoimme muuttujan newFriend arvon tekstikenttään, jolloin sen arvo välittyy muuttujaamme. Lisäksi sidoimme painikkeeseen hiirenpainallustapahtuman, jonka seurauksena kutsutaan funktiota addFriend.

Seuraavaksi haluamme toteuttaa toiminnon ystävän poistamiselle. Olemme jo toteuttaneet näkymään ystävien listaamisen, lisätään vain jokaisen ystävän kohdalle painike sen poistamiseksi:

<div ng-app>
  <div ng-controller="ExampleController">
    <ul ng-repeat="friend in person.friends">
      <li>{{friend}} <button ng-click="removeFriend($index)">Poista</button></li>
    </ul>
    <input type="text" ng-model="newFriend">
    <button ng-click="addFriend()">Lisää ystävä</button>
  </div>
</div>

Toteutimme removeFriend-funktion niin, että se poistaa ystävän taulukon tietystä indeksistä. Se tehtiin siksi, että ng-repeat-toistorakenteessa iteroitavan alkion indeksi on helppo saada muuttujasta $index. Funktion olisi voinut myös toteuttaa niin, että se poistaa ystävän tietyllä nimellä käyttäen jQueryn $.grep-funktiota siten, että se valitsee vain ystävät, joilla ei ole parametrina annettua nimeä. Tällöin taulukossa ei tosin olisi voinut olla saman nimisiä ystäviä.

Tämä pieni sovellus alkaa olla paketissa, katsotaan vielä, mitä olemme saaneet aikaan:

Laskin (1p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että käyttäjä voi laskea kerto-, plus-, erotus- ja jakolaskuja syöttämällä haluamansa luvut kahteen kenttään ja painamalla jotain neljästä painikkeesta, jolloin valittu laskuoperaatio suoritetaan luvuille ja tulos näytetään käyttäjälle kenttien yläpuolella. Jos käyttäjä yrittää jakaa lukua nollalla, ota jakolaskupainike pois käytöstä (vinkki: ng-disabled). Toteuta laskin tekemällä tarvittavat muutokset CalculatorController-kontrolleriin (löytyy tiedostosta app/app.js) ja index.html-näkymään.

Mallin tarkkailu

Tulee usein tilanne, jossa haluamme tarkkailla muutosta mallissa. Esimerkiksi, jos henkilön nimeä muuttaa, hänen ystävän poistetaan, koska henkilö ei ole enää sama kuin ennen, joten hänellä ei ole myöskään samoja ystäviä.

Toteutetaan kuitenkin hieman yksinkertaisempi esimerkki, jossa käyttäjä syöttää nimensä tekstikenttään ja jos sen arvo on "James Bond", näytetään alert-ikkuna "I've been expecting you, mr. Bond". Aloitetaan luomalla näkymä, joka sisältyy BondController-kontrollerin näkyvyysalueeseen:

<div ng-controller="BondController">
  <h1>My name is {{name}}</h1>
  <input type="text" ng-model="name">
</div>

Se on siinä! Sidoimme vain name-muuttujan arvon tekstikentän arvoon ja lisäsimme otsikon, joka esittää name-muuttujan arvon. Seuraavaksi toteutetaan funktio BondController, jossa itse magia tapahtuu:

function BondController($scope){
  $scope.name = "Kalle";

  $scope.$watch('name', function(newValue, oldValue){
    if(newValue == 'James Bond'){
      alert('I\'ve been expecting you, mr. Bond');
    }
  });
}

Lisäämme objektiin $scope tarkkailijan, joka tarkkailee $watch-funktion ensimmäisenä saatua parametria. Parametri annetaan merkkijonona, joka vastaa tarkkailtavan muuttujan nimeä. Kun muuttujan arvo vaihtuu, kutsutaan toisena parametrina annettua anonyymiä-funktiota, joka saa parametreikseen muuttujan uuden ja vanhan arvon. Funktion sisällä voimme tarkkailla muuttujan uutta arvoa ja tehdä jotain sen perusteella. Tarkkailtavan muuttujan ei tarvitse olla merkkijono, se voi olla myös mm. objekti tai taulukko. Kannattaa kuitenkin olla tarkkana, sillä Angular ei välttämättä ole kanssasi samaa mieltä siitä, onko muuttujan arvo muuttunut. Angular tarkastaa oletusarvoisesti, onko muuttujan viite muuttunut, jolloin objektien kanssa tulee ongelmia. Ongelma ratkeaa lisäämällä $watch-funktioon kolmas parametri, jonka arvo on true. Tällöin Angular vertaa muuttujan sisältöä viitteen sijaan.

Tässä vielä lopputulos:

Riippuvuuksien injektointi

Riippuvuuksien injektointi (Dependency Injection) on suosittu suunnittelumalli sovelluskehityksessä, johon myös Angular hyvin vahvasti nojautuu. Dependency Injection suunnittelumallin suosio perustuu siihen, että se vähentää sovelluksen sisäisiä riippuvuuksia (tekee "Single Responsibility Principle"-periaatteen noudattamisesta helpompaa) ja tekee koodista uudelleenkäytettävämpää sekä helpommin testattavaa.

Katsotaan, miten riippuvuuksien injektointi on toteutettu Angularissa. Tähän mennessä olemme määritelleet kontrollerimme tähän tapaan:

function MyController($scope){
  $scope.message = 'Hello World!';
}

Tavassa ei periaatteessa ole mitään vikaa, mutta tulevaisuudessa haluamme käyttää muita komponentteja kontrollereissamme, jolloin meidän täytyy injektoida ne kontrolleriimme. Jotta riippuvuuksien injektointi olisi helpompaa, määrittelemme kontrollerimme tulevaisuudessa seuraavanlaisesti:

var App = angular.module('MyApp', []);

App.controller('MyController', function($scope){
  $scope.message = 'Hello World!';
});

Toteutimme siis moduulin nimeltä MyApp ja sille kontrollerin MyController, johon injektoin muuttujan $scope. Huomaa, että tyhjä taulukko angular.module-kutsun toisena parametrina tarkoittaa sitä, etten injektoi moduuliini toisia moduuleja. Tulemme kuitenkin tekemään niin tulevaisuudessa. Moduuli on Angularissa pelkästään laatikko, joka sisältää sovelluksemme komponentit, kuten kontrollerit. Se on siis tapa organisoida sovelluksen eri osia. Moduulin käyttö lähtee liikkeelle näkymästä, jossa se otetaan käyttöön lisäämällä se ng-app-attribuutin arvoksi:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <h1>{{message}}</h1>
    </div>
  </body>
</html>

Ei siis sen kummempaa. Tehdään hommasta hieman jännittävämpää ja injektoidaan MyController-kontrolleriin $scope-muuttujan lisäksi funktio $interval, joka kutsuu parametrina saatua funktiota tietyn aikajakson välein:

App.controller('MyController', function($scope, $interval){
  $scope.message = 60;

  $interval(function(){
    $scope.message--;
  }, 1000);
});

Käytän injektoitua $interval-funktiota vähentääkseni muuttujan message arvoa joka sekunti (sekunti on 1000 millisekuntia).

Pikakirjoitus (2p)

Muokkaa tehtäväpohjassa olevaa dokumenttia siten, että käyttäjä voi testata pikakirjoitustaitojaan. Pikakirjoituspelin tulee toimia niin, että käyttäjä näkee sivulla pitkän "Lorem ipsum dolor sit amet..."-tekstin, jonka sisältö löytyy muuttujasta text. Kun käyttäjä painaa "Aloita!"-painiketta, hänen täytyy alkaa kirjoittamaan näkeemäänsä tekstiä tekstikenttään niin nopeasti kuin mahdollista. Käyttäjällä on aikaa kirjoittaa tekstiä 15 sekuntia. Varmista, ettei käyttäjä tee kirjoitusvirhettä niin, ettet hyväksy tekstikenttään virheellisiä merkkejä. Samaan aikaan, kun käyttäjä kirjoittaa, sivulla näkyvän laskurin arvo vähenee joka sekunti. Kun laskurin arvo on 0, tulee käyttäjälle ilmoittaa alert-ikkunan kautta, kuinka monta merkkiä hän onnistui kirjoittamaan. Pelin uudelleen aloittamiseksi riittää, että käyttäjä päivittää sivun, mutta voit halutessasi alustaa pelin uudelleen, kun laskurin arvo on 0.

Toteuta pikakirjoituspeli käyttämällä $watch-funktiota tarkkailemaan esimerkiksi timeLeft (kuinka paljon laskurissa on aikaa) ja userText nimisten muuttujien (käyttäjän kirjoittama teksti) arvoja. Kun timeLeft muuttujan arvo on 0, ilmoita käyttäjälle, kuinka monta merkkiä hän onnistui kirjoittamaan oikein (esim. "Onnistuit kirjoittamaan 30 merkkiä!"). Tarkkaile userText-muuttujaa kirjoitusvirheiden varalta esimerkiksi seuraavasti:

var lastChar = newVal.charAt(newVal.length - 1);

if(lastChar != $scope.text.charAt(newVal.length - 1)){
  $scope.userText = $scope.userText.slice(0,-1);
}

Pelin aloittaa "Aloita!"-painikkeen klikkaaminen. Aloita siis laskurin vähentäminen siitä hetkestä käyttämällä $interval-funktiota. TypeController-kontrollerin pohja löytyy tiedostosta app/app.js ja näkymä tiedostosta index.html.

TodoApp (3p)

Seuraavaksi pääset toteuttamaan hieman suurempaa sovellusta, muistilistaa. Muistilistan avulla käyttäjä voi lisätä itselleen tehtäviä, jotka hän itse priorisoi. Lisätyn tehtävän voi merkata tehdyiksi, poistaa ja sen prioriteettia voi muuttaa. Tehtävät tulee järjestää muistilistaan niiden prioriteetin mukaan. Lopullinen muistilista muistuttaa tätä (pelkkä käyttöliittymä, toiminallisuus puuttuu):

Muistilista

Toteuta valmiiseen käyttöliittymään seuraavat toiminnot:

  • Käyttäjä voi lisätä tehtävän listaan "Uusi tehtävä"-tekstikentän nimen perusteella. Älä anna käyttäjän lisätä tehtävää tyhjällä nimellä.
  • Käyttäjä voi merkata merkata tehtävän tehdyksi klikkaamalla checkboxia. Tehdyn tehtävän nimi vedetään yli (voit lisätä tehtävän nimeen tällöin luokan todo-done (vinkki: ng-class).
  • Käyttäjä voi merkata kaikki tehtävät tehdyksi klikkaamalla "Merkkaa kaikki tehdyiksi"-painiketta.
  • Käyttäjä voi poistaa tehtävän listasta painamalla oikeasta laidasta "Poista"-painiketta.
  • Käyttäjä voi poistaa kaikki tehtävät klikkaamalla "Poista kaikki"-painiketta. Varmista painikkeen klikkaamisen jälkeen, että käyttäjä haluaa varmasti poistaa kaikki tehtävät (vinkki: confirm).
  • Tehtävään liittyy prioriteetti. Jokaiselle tehtävälle lisätään sen lisäämisen yhteydessä prioriteetti 1. Mitä pienempi prioriteetti on, sitä tärkeämpi tehtävä on. Käyttäjä voi muokata tehtävän prioriteettia vaihtamalla sen vieressä olevan tekstikentän arvoa. Järjestä tehtävät prioriteetin mukaan niin, että tärkeimmät tehtävät ovat listan yläpäässä (vinkki: liitä prioriteetin sisältävään tekstikenttään ng-blur-kuuntelija ja järjestä tehtävien taulukko prioriteetin mukaan, kun kenttä menettää fokuksen käyttämällä sort-funktiota).
  • Käyttäjä voi nähdä muistilistan alalaidasta, kuinka monta tehtävää hän on tehnyt ja kuinka monta on vielä tekemättä. Käytä selkeää suomen kieltä, jolloin "1 tehtävä tehty" on oikein ja "1 tehtävää tehty" on väärin (vinkki: ng-pluralize). Voit toteuttaa toiminnon käyttämällä $watch-funktiota niin, että seuraat tehtävät sisältävää taulukkoa ja päivittää esimerkiksi muuttujien todosDone ja todosRemaining arvot aina, kun taulukossa tapahtuu muutoksia. Muista lisätä $watch-funktiokutsun viimeiseksi parametriksi true, niin Angular tarkastaa onko taulukossa tapahtunut muutoksia sen sisällön, eikä pelkän viitteen perusteella.

TodoController-kontrollerin valmis pohja löytyy tiedostosta app/controllers/todo_controller.js ja näkymä tiedostosta index.html.

Direktiivit

Jos olemme tarkkoja, emme ole puhuneet asioista täysin niiden oikeilla nimillä. Olemme puhuneet mm. ng-repeat, ng-if ja ng-controller yhteydessä pelkistä attribuuteista, joita annetaan DOM-elementeille. Toisaalta ne ovat sitä, mutta Angularin yhteydessä niillä on toinen nimi, direktiivi (directive). Direktiivit ovat yksinkertaisesti tunnuksia (yleensä juuri attribuutteja) DOM-elementeissä, jotka kertovat Angularin HTML-kääntäjälle, että niihin pitää liittää erityisiä ominaisuuksia tai muuttaa elementtiä ja sen lapsielementtejä esimerkiksi lisäämällä niiden sisään toisia DOM-elementtejä tai muokkaamalla olemassaolevien elementtien esitystä.

Sovellusta toteuttaessa tulee usein vastaan tilanne, jossa valmista direktiiviä ei löydy, jolloin se pitää joko toteuttaa itse, tai etsiä muualta. Angulariin on toteuttu lukuisia ulkopuolisia direktiivejä, mutta toteutetaan harjoituksen vuoksi yksi itse. Oman direktiivin luonti tapahtuu seuraavasti:

var App = angular.module('MyApp', []);

App.directive('counter', function(){
  // ...
});

Huomaat, että direktiivin luonti muistuttaa erehdyttävästi kontrollerin luontia. Kutsumme vain moduulillemme directive-funktiota ja annamme ensimmäiseksi parametriksi direktiivin nimen ja toiseksi anonyymin funktion. Tässä esimerkissä loin direktiivin counter, jonka voin liittää DOM-elementtiin seuraavasti:

<button counter>Kasvata</button>

counter-direktiivi on nyt liitetty button-elementtiin, se ei tosin vielä tee mitään. Muista nimeämisessä että, jos direktiivisi nimi on esimerkiksi MinunOmaDirektiivini, voi sen lisätä DOM-elementtiin attribuutilla minun-oma-direktiivini (CamelCase-tyyli muuttuu viivoilla erotetuiksi sanoiksi). Laitetaan seuraavaksi direktiivimme esittämään käyttäjälle alert-ikkuna, kun siihen liittyvää elementtiä klikataan:

App.directive('counter', function(){
  return {
    link: function(scope, elem, attrs){
      $(elem).on('click', function(){
        alert('Klikkasit!');
      });
    }
  }
});

Direktiivin ominaisuudet liitetään objektiin, jonka toisena parametrina annettu anonyymifunktio palauttaa. Voimme liittää palautettuun objektiin kentän link, joka sisältää funktion, joka ottaa parametreikseen näkyvyysalueen, jossa direktiivi sijaitsee (scope), elementin, johon direktiivi on liitetty (elem) ja attribuutit, mikä kyseiseen elementtiin liittyy (attrs). Funktion avulla voimme manipuloida DOM-elementtiä, johon direktiivimme on liitetty, kuten liittää siihen tapahtumankuuntelijan. Voimme lisäksi funktiossa päästä käsiksi koko kontrollerin näkyvyysalueeseen, jonka sisällä direktiivimme sijaitsee scope-parametrin avulla. Nyt kun käyttäjä klikkaa "Kasvata"-painiketta, johon counter-direktiivi on liitetty, ilmestyy alert-ikkuna "Klikkasit!".

link-kentässä määritellyn funktion ensimmäisen parametrin, scope avulla pääsemme käsiksi koko direktiivin näkyvyysalueeseen, joka on koko direktiivin ulkoinen näkyvyysalue. Usein haluamme kuitenkin määrittää direktiivillemme nk. eristetyn näkyvyysalueen, jonka avulla voimme kuvata direktiivin ulkoisen näkyvyysalueen sen sisäiseksi näkyvyysalueeksi. Se on kätevää, koska silloin direktiivinen toteutuksen ei tarvitse riippua siitä, minkä kontrollerin sisällä se on käytössä. Eristetyn näkyvyysalueen toteuttaminen onnistuu määrittelemällä palautettavaan objektiin kenttä scope, johon määrittelemme direktiivin näkyvyysalueen. Käytännössä voimme esimerkiksi käyttää ng-model-direktiivissä määriteltyä muuttujaa direktiivissämme seuraavasti:

App.directive('counter', function(){
  return {
    scope: {
      number: '=ngModel'
    },
    link: function(scope, elem, attrs){
      $(elem).on('click', function(){
        alert(scope.number);
      });
    }
  }
});

Lisäsimme siis palautettavaan objektiin scope kentän, jossa määritimme, että lisäämme direktiivimme näkyvyysalueseen muuttujan number, jonka arvo vastaa ng-model-direktiivissä määriteltyä arvoa. Merkkijonossa =ngModel, =-merkki tarkoittaa, että sidomme ulkoisen näkyvyysalueen muuttujan direktiivimme näkyvyysalueeseen ja ngModel viittaa vain direktiiviin ng-model (huomaathan, että attribuutti on muotoa attribuutin-nimi, mutta siihen viitataan nimellä attribuutinNimi). Muokkasimme myös link-kentessä määriteltyä funktiota niin, että alert-ikkunaan ilmestyy näkyvyysalueeseen lisätyn number-muuttujan arvo. Muokataan vielä näkymää niin, että button-elementtiin lisätään ng-model-direktiivi, jonka arvo on muuttuja value:

Laskurin arvo on: {{value}}
<button counter ng-model="value">Kasvata</button>

Koska muuttujan value arvoa ei ole vielä asetettu, "Kasvata"-painikkeen klikkaamisesta ilmestyvän alert-ikkunan sisältö on "undefined". Muokataan direktiiviämme vielä niin, että painikkeen klikkaaminen kasvattaa ng-model-direktiivissä määriteltyä arvoa:

App.directive('counter', function(){
  return {
    scope: {
      number: '=ngModel'
    },
    link: function(scope, elem, attrs){
      if(typeof scope.number == 'undefined'){
        scope.number = 0;
      }

      $(elem).on('click', function(){
        scope.$apply(function(){
          scope.number++;
        });
      });
    }
  }
});

Ratkaisimme alustamattoman ng-model-direktiivissä annetun muuttujan ongelman, asettamalla siinä tilanteessa number-muuttujan arvon nollaksi. Lisäsimme myös klikkauksen tapahtumankuuntelijaan number-arvon kasvatuksen. Huomaa, jotta arvon muokkaaminen välittyisi direktiivin ulkopuolelle, täytyy kutsua funktiota $apply, jolloin kerromme Angularille, että olemme tehneet muutoksia malliin ja ne muutokset pitäisi välittää näkymään. Yleensä $apply-funktiota ei tarvitse erikseen kutsua, koska Angular hoitaa sen puolestasi. Olemme tapahtumankuuntelijassa kuitenkin Angularin kontekstin ulkopuolella, joten meidän täytyy kutsua $apply-funktiota itse, jotta saamme välitettyä mallissa tehdyt muutokset näkymään.

Lisätään vielä direktiiviimme yksi toiminto, jonka avulla sen käyttäjä kertoo, kuinka paljon hän haluaa muuttaa ng-model-direktiivissä määriteltyä arvoa. Sen voisi määritellä elementissä näin:

Laskurin arvo on: {{value}}
<button counter ng-model="value" amount="-2">Kasvata</button>

Määritimme siis elementtiin amount-attribuutin, joka kertoo, että haluamme vähentää value-muuttujan arvoa kahdella jokaisen klikkauksen jälkeen. Joudumme vielä määrittämään amount-attribuutin direktiivimme eristettyyn näkyvyysalueeseen:

App.directive('counter', function(){
  return {
    scope: {
      number: '=ngModel',
      amount: '=amount'
    },
    link: function(scope, elem, attrs){
      if(typeof scope.number == 'undefined'){
        scope.number = 0;
      }

      $(elem).on('click', function(){
        scope.$apply(function(){
          scope.number+=parseInt(scope.amount);
        });
      });
    }
  }
});

Liitimme amount-muuttujan eristettyyn näkyvyysalueeseemme. Muutimme myös link-kentässä määriteltyä funktiota niin, että se kasvattaa (tai vähentää) number-muuttujan arvoa amount-muuttujan arvon verran jokaisen klikkauksen jälkeen. Muuttujan amount arvoksi olisi voinut asettaa näkymässä myös muuttujan. Se johtuu siitä, että lisäsimme sen direktiivimme näkyvyysalueeseen =-operaattorilla. Toinen mahdollinen operaattori olisi ollut @, mutta silloin näkymän amount-attribuutissa määriteltyä arvoa olisi käsitelty puhtaana arvona, ei muuttujana. Lopputulos on testattavissa Plunkerissa.

Tämä osio oli vasta pintaraapaisu direktiivien käyttöön. Lisää niistä voi lukea Angularin Developer Guidesta.

Slider-direktiivi (3p)

Tehtävänäsi on toteuttaa jQueryn slider-widgetti (katso yllä oleva jsFiddle) slider-nimisenä direktiivinä, joka käyttää hyväksi jQueryn tarjoamaa slider-funktiota, jonka avulla sliderin voi luoda esimerkiksi seuraavasti:

$("#slider").slider({
  value: 10,
  slide: function(event, ui){
    console.log('Liikutit slideria!')
  }
});

Lue lisää slider-funktion parametreista täältä.

Tarkoitus on, että kun käyttäjä muuttaa sliderin arvoa, myös siihen ng-model-direktiivin avulla sidotun muuttujan arvo muuttuu. Sliderin alkuarvon tulee olla sama, kuin siihen sidotun muuttujan arvo. Voit olettaa etta ng-model-direktiivissä sidottu muuttuja on kokonaisluku. Sliderin stepin (kuinka paljon sliderin arvo muuttuu liikutuksesta) voit päätää itse. Näkymässä esimerkiksi div-elementtiin sidottu slider-direktiivi näyttäisi tältä:

<div slider ng-model="number"></div>

Tehtäväpohjasta löytyy valmis pohja direktiiville tiedostosta app/directives/slider.js. Kun näkymässä index.html-upotetun number-muuttujan arvo muuttuu sliderin liikuttamisen perusteella ja sliderin alkuperäinen sijainti vastaa muuttujan alkuarvoa (joka on 10), tehtävä on valmis.

Viikko 5

Jatketaan keskustelua palvelimen kanssa: Firebase

Viime viikolla kehitimme upean muistilistasovelluksen (tehtävä TodoApp), johon pystyi lisäämään muistettavia asioita ja merkkaamaan niitä tehdyiksi. Tylsä puoli sovelluksessa oli se, ettei se tallettanut lisäämiämme muistutuksia mihinkään. Korjaamme sen puolen sovelluksestamme tällä viikolla ottamalla käyttöön Firebasen, jonka avulla voimme tallettaa ja muuttaa dataa reaaliajassa. Kuulostaa siistiltä!

Aloita rekisteröitymällä Firebaseen täältä. Kun olet rekisteröitynyt sinut ohjataan käyttäjäsi dashboardille, jossa ensimmäinen sovelluksesi on jo alustettu puolestasi nimellä "My first app". Olemme siis jo periaatteessa valmiita, katsotaan seuraavaksi hieman, miten saamme kytkettyä Firebasen Angular-sovellukseemme.

Firebase ja Angular

Firebasella on oma moduuli (jonka nimi on yllätys ja yllätys firebase), joka tarjoaa työkalut datamme muokkaamiseen ja hakemiseen. firebase-moduuli on sisällä jokaisessa tehtäväpohjassa, joten riittää vain, että injektoit sen omaan MyApp-moduuliisi seuraavasti:

var MyApp = angular.module('MyApp', ['firebase']);

Kun firebase moduuli on injektoitu omaan moduuliimme, pääsemme käyttämään sen tarjoamia palveluita. Seuraavaksi meidän täytyy toteuttaa omalle kontrollerillemme palvelu, joka hoitaa Firebasen kanssa keskustelmisen.

Palvelut (services)

Palvelut ovat Angularissa kontrollereihin ja toisiin palveluihin injektoitavia komponentteja, jotka, kuten jo nimestä voi päätellä, tarjoavat sen käyttäjälle jonkin palvelun. Palvelu voi olla esimerkiksi API tiedon hakuun palvelimelta tai joukko funktioita, jotka tekevät eri joukko-operaatioita:

var MyApp = angular.module('MyApp', []);

MyApp.service('Set', function(){
  this.intoSet = function(arr){
    var set = [];

    arr.forEach(function(item){
      if(set.indexOf(item) < 0){
        set.push(item);
      }
    });

    return set;
  }

  this.union = function(arrA, arrB){
    var setA = this.intoSet(arrA);
    var setB = this.intoSet(arrB);

    setB.forEach(function(item){
      if(setA.indexOf(item) < 0){
        setA.push(item);
      }
    });

    return setA;
  }

  this.intersection = function(arrA, arrB){
    var setA = this.intoSet(arrA);
    var setB = this.intoSet(arrB);

    var intersected = [];

    setA.forEach(function(item){
      if(setB.indexOf(item) >= 0){
        intersected.push(item);
      }
    });

    return intersected;
  }
});

Palvelun saa liitettyä moduulin täysin samaan tapaan kuin kontrollerinkin, kutsumme vain moduulillemme controller-funktion sijasta funktiota service. Funktio ottaa parametrikseen palvelun nimen merkkijonona ja funktion, joka palauttaa objektina palvelun tarjoamat funktiot. Huomaat varmasti, että palvelun rakenne muistuttaa hyvin paljon Module pattern-suunnittelumallia. Palveluiden luominen onkin hyvä tapa jakaa ohjelmaa pieniin ja helposti hallittaviin komponentteihin. Otetaan toteuttamme palvelu käyttöön kontrollerissa:

MyApp.controller('MyController', function($scope, Set) {
    $scope.union = function(){
       var arrA = $scope.arrA.split(',');
       var arrB = $scope.arrB.split(',');

       $scope.result = Set.union(arrA, arrB).toString();
    }

    $scope.intersection = function(){
       var arrA = $scope.arrA.split(',');
       var arrB = $scope.arrB.split(',');

       $scope.result = Set.intersection(arrA, arrB).toString();
    }
});

Voimme käyttää siis toteuttamaamme palvelua injektoimalla sen kontrolleriimme lisäämällä sille parametrin Set. Palvelun tarjoamat funktiot ovat sen jälkeen käytettävissä kontrollerissa. Tässä vielä lopullinen versio:

Firebase-palvelu

Voisimme periaatteessa upottaa kaiken Firebasen käyttöön liittyvän logiikan suoraan kontrolleriimme, mutta siitä tulisi erittäin sekavaa. Tehdään sen sijaan moduuliimme palvelu (service), joka hoitaa Firebasen kanssa keskustelemisen kontrollerimme puolesta, niin kontrolleristamme tulee selkeämpi ja se noudattaa paremmin jo mainittua "Single Responsibility"-periaatetta. Liitetään moduuliimme aluksi FirebaseService-niminen palvelu:

var MyApp = angular.module('MyApp', ['firebase']);

MyApp.service('FirebaseService', function($firebaseObject){
  // ...
});

Injektoin siis ensin firebase-moduulin omaan moduuliini ja sen jälkeen sen tarjoaman $firebaseObject-palvelun omaan FirebaseService-palveluuni.

Ennen kuin etenemme pidemmälle, lisätään sovellukseemme hieman dataa Firebasen kautta. Siirry ensin käyttäjäsi dashboardille ja klikkaa "My first app" alapuolelta painiketta "Manage app". Sivulle aukee sovelluksesi datasisältö, joka on tällä hetkellä tyhjä. Lisätään sovellukseen dataa, viemällä hiiri sovelluksemme datan osoitteen päälle (se on hassu nimi, kuten scorching-torch-2360), jolloin sen viereen ilmestyy vihreä "+"-painike, paina sitä. Kun olet painanut "+"-painiketta ilmestyy datan osoitteen alapuolelle tekstikentät name ja value. Kirjoita name-kenttään "message" ja value-kenttään "Hello World!" ja paina kenttien vierestä vihreää "+"-painiketta. Sovelluksessamme on nyt dataa, joka on objekti { message: 'Hello World!' }, jonka rakenteen määrittelimme name- ja value-kenttien kautta. Katsotaan seuraavaksi, miten voimme hakea tämän datan sovelluksestamme käsin.

Datan haku Firebasesta

Olen jo saanut valmiiksi FirebaseService-palveluni rungon. Lisään siihen seuraavaksi funktion, joka hakee datani Firebasesta:

MyApp.service('FirebaseService', function($firebaseObject){
  var firebaseRef = new Firebase('OMA_FIREBASE');
  var data = $firebaseObject(firebaseRef);

  this.fetchData = function(){
    return data;
  }
});

Käydään läpi, mitä FirebaseService-palvelussa oikein tapahtuu. Talletan aluksi firebaseRef-muuttujaan Firebase-olion, joka ottaa parametrikseen sovelluksemme datasäilön sijainnin (joudut vaihtamaan kohtaan OMA_FIREBASE oman datasi sijainnin, joka löytyy dashboardiltasi kuvan osoittamasta paikasta). Datasi sijainti on muotoa https://FIREBASE_KAYTTAJANI.firebaseio.com. Sen jälkeen lisään palveluuni funktion fetchData kontrollerin käyttöä varten. Funktiossa talletan referenssin dataani kutsumalla injektoimaani $firebaseObject-funktiota omalla Firebase-oliollani. Otetaan seuraavaksi palvelu käyttöön kontrollerissa:

MyApp.controller('MyController', function($scope, FirebaseService){
  $scope.data = FirebaseService.fetchData();
});

Riittää siis vain injektoida toteutettu FirebaseService-palvelu kontrolleriin ja olemme valmiita käyttämään sitä. Voimme näyttää Firebasesta hakemamme datan, joka oli objekti { message: 'Hello World!' } näkymässämme, aivan kuten muutkin muuttujat:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <h1>{{data.message}}</h1>
    </div>
  </body>
</html>

Näkymään ilmestyy otsikko "Hello World!", joten datan hakeminen Firebasesta on onnistunut.

Hello Firebase! (1p)

Rekisteröidy Firebaseen ja lisää sinne dataa, jonka sisältö on { message: 'Hello World!' } (message-kenttä, jonka arvo on "Hello World"). Muista, että voit lisätä sovellukseesi dataa suoraan Firebasesta siirtymällä dashboardilta sovelluksesi hallintaan klikkaamalla "Manage App".

Muokkaa tehtäväpohjaa siten, että app/services/firebase_service.js-tiedostossa sijaitseva FirebaseService-palvelu hakee lisäämäsi datan Firebasesta (vinkki: $asObject). Muokkaa sen jälkeen app/controllers/hello_controller.js-tiedossa sijaitsevaa HelloController-kontrolleria niin, että se käyttää FirebaseService-palvelua hakemaan Firebasesta viestin "Hello World!" ja esittää sen näkymässä esimerkiksi muuttujan message arvona. Muista injektoida FirebaseService kontrolleriin, muuten sen käyttäminen ei onnistu!

Datan lisääminen Firebaseen

Olemme onnistuneet hakemaan dataa Firebasesta, joten seuraava looginen askel on katsoa, miten voimme lisätä sinne dataa. Paltaan takaisin FirebaseService-palvelumme pariin ja lisätään siihen funktio addMessage, joka lisää parametrina saadun objektin Firebaseen:

MyApp.service('FirebaseService', function($firebaseArray){
  var firebaseRef = new Firebase('OMA_FIREBASE/messages');
  var messages = $firebaseArray(firebaseRef);

  this.addMessage = function(message){
    messages.$add(message);
  }
});

Tämä eroaa melko datan lisäämisestä, joten on hyvä käydä läpi, mitä oikein tapahtuu. Aluksi alustin uuden Firebase-olion melkein samaan tapaan kuin datan hakemisen kanssa, pientä yksityiskohtaa lukuunottamatta. Huomasit ehkä, että lisäsin Firebase-polkuni perään /messages. Tein sen siitä syystä, että haluan hakea ja tehdä muutoksia vain resurssiin messages. Sen jälkeen kutsun $firebaseObject-funktion sijaan $firebaseArray-funktiota alustamallani Firebase oliolla aivan. Teen tämän siksi, koska haluan käsitellä dataani taulukkona objektin sijaan. Se on melko loogista, koska haluan hakea ja muokata joukkoa viestejä.

Alustus on tehty, pureudutaan seuraavaksi funktioon addMessage. Sen toteutus on erittäin yksinkertainen, kutsun vain messages-resurssilleni funktiota $add, joka lisää parametrina saadun objektin taulukkoon muiden viestien sekaan. Ei siis sen kummempaa. Käytetään toteuttamaamme palvelua vielä kontrollerissamme:

MyApp.controller('MyController', function($scope, FirebaseService){
  $scope.newText = '';

  $scope.addMessage = function(){
    if($scope.newText != ''){
      FirebaseService.addMessage({
        text: $scope.newText
      });

      $scope.newText = '';
    }
  }
});

Nyt voimme sitoa näkymässä tekstikentän arvon muuttujaan newText ja sitoa painikkeen painalluksen funktion addMessage-kutsuun:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <textarea ng-model="newText"></textarea>
      <button ng-click="addMessage()">Lisää viesti</button>
    </div>
  </body>
</html>

Pystyn nyt lisäämään dataa Firebaseen, mutta en pääse tarkkailemaan sitä muualta, kuin Firebase käyttäjäni dashboardilta. Lisätään siis vielä FirebaseService-palveluun funktio getMessages, joka hakee kaikki viestit messages-resurssista, jotta voimme näyttää ne näkymässä:

MyApp.service('FirebaseService', function($firebaseArray){
  var firebaseRef = new Firebase('OMA_FIREBASE/messages');
  var messages = $firebaseArray(firebaseRef);

  this.addMessage = function(message){
    messages.$add(message);
  }

  this.getMessages = function(){
    return messages;
  }
});

Todella helppoa! Palautan siis vain getMessages-funktiossa messages-taulukon, joka sisältää kaikki sovellukseni viestit. Muokataan vielä hieman kontrolleriamme:

MyApp.controller('MyController', function($scope, FirebaseService){
  $scope.messages = FirebaseService.getMessages();
  $scope.newText = '';

  $scope.addMessage = function(){
    if($scope.newText != ''){
      FirebaseService.addMessage({
        text: $scope.newText
      });

      $scope.newText = '';
    }
  }
});

Huomaa, ettei addMessage-funktiossa uutta viestiä tarvitse lisätä erikseen messages-taulukkoon, sillä Firebase synkronoi taulukon sisällön lisäämisen yhteydessä puolestasi. Kätevää! Listataan messages-taulukon alkiot vielä näkymässä:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <ul>
        <li ng-repeat="message in messages">{{message.text}}</li>
      </ul>
      <textarea ng-model="newText"></textarea>
      <button ng-click="addMessage()">Lisää viesti</button>
    </div>
  </body>
</html>

Se on siinä! Olemme toteuttaneet pienen chatin, seuraavaksi saat hieman parannella sitä.

Chat (3p)

Parannellaan hieman yllä olevaa esimerkkiä lisäämällä chattiin käyttäjät. Vinkki: pidä käyttäjät ja viestit erillisinä resursseina Firebasessa, esimerkiksi näin:

var messagesFirebaseRef = new Firebase('OMA_FIREBASE/messages');
var messages = $firebaseArray(messagesFirebaseRef);

var usersFirebaseRef = new Firebase('OMA_FIREBASE/users');
var users = $firebaseArray(usersFirebaseRef);

Kun käyttäjä avaa sovelluksen, pyydä häntä valitsemaan itselleen käyttäjätunnus. Jos käyttäjätunnus löytyy ennestään, älä lisää sitä uudestaan Firebaseen (vinkki: hae kaikki käyttäjät Firebasesta ja katso, löytyykö sieltä käyttäjän antamaa käyttäjätunnusta), muuten lisää uusi käyttäjä (vinkki: $add). Kun käyttäjätunnus on valittu (vinkki: piilota chatti-näkymä kunnes käyttäjätunnuksella on arvo, esimerkiksi ng-show-direktiivin avulla), näytä näkymässä chatin viestit ja sen vieressä chatin käyttäjät. Kun käyttäjä lisää viestin chattiin, näytä viestin vieressä hänen käyttäjänimensä ja sen alapuolella, milloin viesti on lisätty (vinkki: Date). Lisää siis Firebaseen talletettavaan viestiin esimerkiksi kenttä username (käyttäjätunnukselle) ja added (viestin lisäämisen ajalle). Älä anna käyttäjän lisätä chattiin tyhjää viestiä.

Tehtäväpohjasta löytyy ChatController-kontrollerin pohja tiedostosta app/controllers/chat_controller.js ja Firebasen kanssa keskustelevan FirebaseService-palvelun pohja tiedostosta app/services/firebase_service.js. Näkymä löytyy tutusta index.html-tiedostosta.

Olemassaolevan datan muokkaaminen ja poistaminen Firebasessa

Katsotaan vielä pari höydyllistä Firebasen toimintoa ennen kuin siirrymme muiden aiheiden pariin. Tarkastellaan ensin, miten voimme muokata olemassaolevaa dataa. Käytetään esimerkkinä edellisessä osiossa toteuttamaamme pientä chatti-sovellusta. Lisätään FirebaseService-palveluun funktio editMessage, joka tallettaa parametreina saatuun viestiin tehdyt muutokset:

MyApp.service('FirebaseService', function($firebaseArray){
  var firebaseRef = new Firebase('OMA_FIREBASE/messages');
  var messages = $firebaseArray(firebaseRef);

  this.addMessage = function(message){
    messages.$add(message);
  }

  this.getMessages = function(){
    return messages;
  }

  this.editMessage = function(message){
    messages.$save(message);
  }
});

Funktiossa editMessage kutsumme messages-resurssille funktiota $save, joka päivittää parametrina saadun alkion Firebasessa. Tehdään seuraavaksi kontrolleriin saman niminen funktio, joka käyttää toteuttamaamme funktiota:

MyApp.controller('MyController', function($scope, FirebaseService){
  $scope.messages = FirebaseService.getMessages();
  $scope.newText = '';
  $scope.editText = ''

  $scope.addMessage = function(){
    if($scope.newText != ''){
      FirebaseService.addMessage({
        text: $scope.newText
      });

      $scope.newText = '';
    }
  }

  $scope.showEditForm = function(message){
    $scope.editText = message.text;
    message.editing = true;
  }

  $scope.editMessage = function(message){
    if($scope.editText != ''){
      delete message.editing

      message.text = $scope.editText;
      FirebaseService.editMessage(message);

      $scope.editText = '';
    }
  }
});

Lisäsin editMessage-funktion lisäksi funktion, jonka avulla näytän viestin muokkauslomakkeen. Funktio showEditForm näyttää parametrina saadun viestin muokkauslomakkeen asettamalla sen editing-kentän arvoksi true. Kun muokkaan viestiä editMessage-funktiossa, poistan siitä kentän editing ennen kuin muokkaan sitä, jotta sitä ei talletettaisi Firabaseen. Katsotaan vielä, miten näitä funktioita käytetään näkymässä:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <ul ng-repeat="message in messages">
        <li>
          {{message.text}}

          <button ng-click="showEditForm(message)" ng-hide="message.editing">Muokkaa<button>

          <p ng-show="message.editing">
            <textarea ng-model="editText"></textarea>
            <button ng-click="editMessage(message)">Lisää viesti</button>
          </p>
        </li>
      </ul>
      <textarea ng-model="newText"></textarea>
      <button ng-click="addMessage()">Lisää viesti</button>
    </div>
  </body>
</html>

Lisäsin jokaisen viestin alle sen muokkauslomakkeen, joka näytetään vain, jos sen editing-kentän arvo on true. Muokkauslomakkeen avaaminen onnistuu klikkaamalla "Muokkaa"-painiketta, jonka klikkaaminen kutsuu showEditForm-funktiota. Muokkauslomakkeesta "Lähetä"-painikkeen painallus taas kutsuu editMessage-funktiota, joka muokkaa viestin sisältöä painikkeen yllä olevan tekstikentän perusteella, joka on sidottu muuttujan editText arvoon.

Kun olemme päässeet vauhtiin, toteutetaan vielä viestin poistotoiminto. Palataan takaisin FirebaseService-palvelun pariin ja lisätään sinne funktio removeMessage, joka poistaa parametrina annetun pelin Firebasesta:

MyApp.service('FirebaseService', function($firebaseArray){
  var firebaseRef = new Firebase('OMA_FIREBASE/messages');
  var messages = $firebaseArray(firebaseRef);

  this.addMessage = function(message){
    messages.$add(message);
  }

  this.getMessages = function(){
    return messages;
  }

  this.editMessage = function(message){
    messages.$save(message);
  }

  this.removeMessage = function(message){
    messages.$remove(message);
  }
});

Saatoit jo melkein arvata, miten removeMessage-funktio toteutetaan. Kuten ennenkin, selviämme yhdellä rivillä koodia, tällä kertaa kutsumme messages-resurssille funktiota $remove, joka poistaa parametrina annetun alkion Firebasesta. Toteutetaan vielä kontrolleriin removeMessage-metodi:

MyApp.controller('MyController', function($scope, FirebaseService){
  $scope.messages = FirebaseService.getMessages();
  $scope.newText = '';
  $scope.editText = ''

  $scope.addMessage = function(){
    if($scope.newText != ''){
      FirebaseService.addMessage({
        text: $scope.newText
      });

      $scope.newText = '';
    }
  }

  $scope.showEditForm = function(message){
    $scope.editText = message.text;
    message.editing = true;
  }

  $scope.editMessage = function(message){
    if($scope.editText != ''){
      delete message.editing

      message.text = $scope.editText;
      FirebaseService.editMessage(message);

      $scope.editText = '';
    }
  }

  $scope.removeMessage = function(message){
    FirebaseService.removeMessage(message);
  }
});

Kontrollerissa ei tapahdu mitään kovin kummallista, removeMessage-funktiossa kutsutaan vain FirebaseService-palvelun tarjoamaa removeMessage-funktiota. Lisätään vielä näkymään panike, jonka painallus kutsuu toteuttamaamme funktiota:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <div ng-controller="MyController">
      <ul ng-repeat="message in messages">
        <li>
          {{message.text}}

          <button ng-click="removeMessage(message)">Poista<button>
          <button ng-click="showEditForm(message)" ng-hide="message.editing">Muokkaa<button>

          <p ng-show="message.editing">
            <textarea ng-model="editText"></textarea>
            <button ng-click="editMessage(message)">Lisää viesti</button>
          </p>
        </li>
      </ul>
      <textarea ng-model="newText"></textarea>
      <button ng-click="addMessage()">Lisää viesti</button>
    </div>
  </body>
</html>

Näkymässä "Poista"-painikkeen painallus kutsuu funktiota removeMessage. Huomaa, ettei poistamisen yhteydessä tarvitse poistaa alkiota erikseen messages-taulukossa, koska Firebase synkronoi taulukon sisällön automaattisesti.

TodoApp ja Firebase (2p)

Toteutimme viime viikolla muistilistan, jonka mallivastaus löytyy tehtäväpohjasta. Voit halutessasi korvata mallivastauksen omalla toteutuksellasi. Toteuta sovellukseen palvelu, joka keskustelee Firebasen kanssa niin, että muistilistan tehtäviä pystyy lisäämään (vinkki: $add), merkkaamaan tehdyiksi (vinkki: $save) ja poistamaan (vinkki: $remove) Firebasesta. Kaikki viime viikolla toteutetut toiminnot tulee siis nyt toteuttaa käyttämällä Firebasea. Valmiissa tehtäväpohjassa Firebasen kanssa keskustelevan FirebaseService-palvelun pohja löytyy tiedostosta app/directives/firebase_service.js, kontrolleri TodoController tiedostosta app/controllers/todo_controller.js ja näkymä tiedostosta index.html. Tee siis tarvittavat muutokset palveluun, kontrolleriin ja näkymään.

Testaaminen

Yksi Angularin käytön hyvä puoli on se, että se on helposti testattava, kunhan käytettävät työkalut on oikeat. Ensimmäiseksi tarvitaan työkalu, joka käytännössä ajaa testit. Tähän tehtävään käytetään usein Angularin kanssa Karmaa. Se on komentorivityökalu, jonka avulla voimme muodostaa tilapäisen web-palvelimen, joka lataa sovelluksesi lähdekoodit ja ajaa testisi ja kertoo, mitkä niistä läpäistiin ja mitkä ei. Karman lisäksi tarvitsemme testaamista varten kehitetyn sovelluskehyksen, jota käyttäen voimme toteuttaa sovelluksellemme testit. Sitä varten on kehitetty Jasmine, joka on "Behavior-driven"-sovelluskehitykseen (BDD) toteutettu sovelluskehys JavaScript-koodin testaamiseen.

Testaaminen Jasminella

Jasminella toteutettu testi alkaa describe-funktion kutsulla, joka ottaa ensimmäiseksi parametrikseen merkkijonon, joka on testijoukon nimi, tai otsikko ja toiseksi parametrikseen anonyymin funktion, joka sisältää itse testit:

describe('An example', function(){
  // ...
});

Eli määrittelemme testijoukon nimeltä "An example" kutsumalla describe-funktiota. Itse testit sijoitetaan toisena parametrina annetun anonyymin funktion sisään. Jokainen testi on yksi it-funktion kutsu, jonka ensimmäinen parametri on describe-funktiossa määritellyn testijoukon yhden testattavan toiminnon nimi ja toinen parametri on anonyymifunktio, joka sisältää itse testin.

describe('An example', function(){
  it('should work when trying to match true with true', function(){
    var isTrue = true;
    expect(isTrue).toBe(true)
  })
});

Testin sisällä määritämme, että haluamme tarkkailla isTrue-muuttujan arvoa kutsumalla expect-funktiota se parametrinaan. Sen jälkeen ketjutamme sen perään funktiokutsun, jonka avulla kohdistamme isTrue-muuttujan arvoon jonkin oletuksen. Tässä esimerkissä oletimme sen arvon olevan true kutsumalla toBe-funktiota. Koodi on niin selkeää, että sen voi oikeastaan lukea ääneen - "expect isTrue to be true", eli "oletetaan, että isTrue on true". Muuttujaan liittyviä oletuksia on lukuisia, tässä tärkeimpiä:

describe('An example', function(){
  it('should work when trying to match true with true', function(){
    var isTrue = true;
    expect(isTrue).toBe(true)
  });

  it('should work with negation', function(){
    var isFalse = false;
    // negaatiota, not, voi käyttää kaikkien oletuksien kanssa
    expect(isFalse).not.toBe(true);
  });

  it('should work when checking object equality', function(){
    var foo = {
      a: 12,
      b: 34
    };

    var bar = {
      a: 12,
      b: 34
    };

    // foo- ja bar-objektit ovat samat, jos niissä on samat kentät, joiden arvot ovat samat
    expect(foo).toEqual(bar);
  });

  it('should work when checking variable existence', function(){
    var foo = 'bar';
    // oletetaan, että muuttuja foo on määritelty
    expect(foo).toBeDefined();
  });

  it('should work when checking if item is in array', function(){
    var names = ['Elina', 'Kalle', 'Arto', 'Jorma', 'Matti']
    // oletetaan, että taulukossa names on alkio "Elina"
    expect(names).toContain('Elina');
  });

  it('should work when checking if the value is less or greater than another', function(){
    var age = 21;
    // oletetaan, että age on suurempi kuin 18
    expect(age).toBeGreaterThan(18);
    // oletetaan, että age on pienempi kuin 50
    expect(age).toBeLessThan(50);
  });
});

Tässä vain pieni osa mahdollisia oletuksia muuttujan arvolle. Kuten huomaat, oletuksien nimeäminen on niin selkeää, että tiedät heti, mitä muuttujalta oletetaan. Voit lukea lisää Jasminen oletuksista sen dokumentaatiosta. Katsotan seuraavaksi, miten pystymme testaamaan Angular-sovellustamme Jasminella.

Usein testeissä on toiston välttämäksi suorittaa jotain toimenpiteitä, kuten muuttujien määrittelemistä, ennen jokaista testiä, tai jokaisen jälkeen. Siihen voimme käyttää beforeEach ja afterEach-funktioita. Molemmat ottavat parametrikseen funktion, joka suoritetaan joko ennen jokaista it-kutsua tai sen jälkeen:

describe('Kalle', function(){
  var kalle;

  beforeEach(function(){
    kalle = {
      name: 'Kalle',
      friends: ['Arto', 'Elina', 'Henri'];
    };
  });

  it('should have name Kalle', function(){
    expect(kalle.name).toBe('Kalle');
  });

  it('should have three friends', function(){
    expect(kalle.friends.length).toBe(3);
  });
});

Nyt muuttuja kalle alustetaan objektilla jokaisen testin alussa.

Angular sovelluksen testaaminen

Testataan seuraavaksi yksinkertaista ystävälista sovellusta, jonka kautta käyttäjä voi lisätä ystäviä ystävälistalleen ja poistaa niitä. Sovelluksen toteutus on seuraava:

var FriendApp = angular.module('FriendApp', []);

FriendApp.controller('FriendListController', function($scope){
  $scope.friends = [];

  $scope.addFriend = function(){
    if($scope.newFriend != ''){
      $scope.friends.push($scope.newFriend);
      $scope.newFriend = '';
    }
  }

  $scope.removeFriend = function(index){
    if($scope.friends.length >= index){
      $scope.friends.splice(index, 1);
    }
  }
});

Haluamme testata FriendListController-kontrollerissa viittä eri asiaa:

Aloitetaan testaaminen kutsumalla Jasminen describe-funktiota:

describe('FriendListController', function(){
  var controller, scope;

  beforeEach(function(){
    module('FriendApp');

    inject(function($controller, $rootScope) {
      scope = $rootScope.$new();
      controller = $controller('FriendListController', {
        $scope: scope
      });
    });
  });

});

Jotta voimme testata kontrolleriamme, meidän täytyy ensin alustaa se ennen jokaista testiä, eli it-funktion kutsua. Kätevimmin se tapahtuu kutsumalla beforeEach-funktiota, jonka parametrina saatua funktiota kutsutaan jokaisen it-funktiokutsun alussa. Alustuksessa määrittelemme aluksi, mikä moduuli on testattavana kutsumalla module-funktiota, meidän tapauksessamme se on FriendApp. Seuraavaksi injektoimme testeihimme FriendListController-kontrollerimme ja injektoimme sen $scope-parametrin arvoksi globaalin scope-parametrin arvomme. Nyt pääsemme käsiksi kontrollerin näkyvyysalueeseen testeissämme scope-muuttujan kautta:

describe('FriendListController', function(){
  var controller, scope;

  beforeEach(function(){
    module('FriendApp');

    inject(function($controller, $rootScope) {
      scope = $rootScope.$new();
      controller = $controller('FriendListController', {
        $scope: scope
      });
    });
  });

  it('should be initialized with an empty friend list', function(){
    expect(scope.friends.length).toBe(0);
  });

  it('should be able to add a friend', function(){
    expect(scope.friends.length).toBe(0);
    scope.newFriend = 'Arto';
    scope.addFriend();
    expect(scope.friends.length).toBe(1);
  });

  it('should not be able to add a friend with an empty name', function(){
    expect(scope.friends.length).toBe(0);
    scope.newFriend = '';
    scope.addFriend();
    expect(scope.friends.length).toBe(0);
  });

  it('should be able to remove a friend', function(){
    scope.friends = ['Arto', 'Matti', 'Elina', 'Kalle'];
    expect(scope.friends.length).toBe(4);
    scope.removeFriend(0);
    expect(scope.friends.length).toBe(3);
    expect(scope.friends).not.toContain('Arto');
  });

  it('should not be able to remove a friend outside the array boundaries', function(){
    scope.friends = ['Kalle', 'Elina'];
    expect(scope.friends.length).toBe(2);
    scope.removeFriend(6);
    expect(scope.friends.length).toBe(2);
  });
});

FriendListController-kontrollerille on nyt kirjoitettu viisi eri testiä, joista jokainen on oma it-funktion kutsunsa. Testeissä ei tapahdu mitään kovin erikoista, kutsumme scope-muuttujan kautta kontrollerissamme määriteltyjä addFriend- ja removeFriend-funktiota ja varmistamme, etteivät ne tee mitään kummallista friends-taulukolle.

Testien suorittaminen

Tarvitset tässä vaiheessa NetBeansin versiota 8.0.1, tai uudempaa. Uusimman version voit asentaa täältä. Testien ajamiseen tarvitsemme jo mainitun Karman, joka taas tarvitsee toimiakseen Node.js:ssän. Lisäksi tarvitsemme Git:iä riippuvuuksien hallintaan. Alla on asennusohjeet eri alustoille. Huomaa, että kaksi ensimmäistä ohjetta olettaa, että sinulla on koneellasi pääkäyttäjän oikeudet. Kolmas ohje on laitoksen koneelle, jossa sinulla ei ole pääkäyttäjän oikeuksia.

Node.js ja Git Windowsille (pääkäyttäjän oikeuksilla)

Käy hakemassa Node.js-asennuspaketti sen kotisivuilta painamalla "Install"-painiketta. Kun asennuspaketti on ladattu, käynnistä se. Kun asennus on valmis avaa Noden komentorivi työkalu siirtymällä "Start", hakemalla ohjelmaa "Node.js command promt" ja käynnistämällä sen. Jos ohjelma löytyy, Node.js on asennettu onnistuneesti.

Jos koneeltasi puuttuu Git, asenna se seuraavaksi täältä painamalla "Download"-painiketta. Kun pääset asennusikkunaan, voit käyttää oletusasetuksia, mutta valitse "Run Git from the Windows Command Promt"-asetus, kun se tulee valittavaksi. Kun asennus on valmis, avaa "Node.js command promt", kuten edellisessä kohdassa ja suorita siinä komento git --version. Terminaaliin pitäisi ilmestyä versionumero, kuten git version 1.8.5.2.

Node.js ja Git OS X:lle ja Linuxille (pääkäyttäjän oikeuksilla)

Käy hakemassa Node.js-asennuspaketti sen kotisivuilta painamalla "Install"-painiketta. Kun asennuspaketti on ladattu, käynnistä se. Kun asennus on valmis, avaa terminaali ja suorita siinä komento node --version, jonka jälkeen terminaaliin pitäisi ilmestyä jokin versionumero, kuten v0.10.29. Jos versionumero ilmestyy terminaaliin, Node.js on asennettu onnistuneesti.

Jos koneeltasi puuttuu Git, asenna se seuraavaksi täältä painamalla "Download"-painiketta. Kun asennus on valmis siirry terminaaliin ja suorita siinä komento git --version. Terminaaliin pitäisi ilmestyä versionumero, kuten git version 1.8.5.2.

Node.js laitoksen koneille

Laitoksen koneilla Git:in pitäisi olla valmiina asennettuna, mutta Noden asennuksen kanssa ongelma on se, ettei sinulla ole pääkäyttäjän oikeuksia, joka tekee asioista hieman vaikeampaa. Noden asennus onnistuu kuitenkin suorittamalla terminaalissa seuraava komento:

curl https://raw.githubusercontent.com/creationix/nvm/v0.24.1/install.sh | bash

Komennon suorittamisen jälkeen käynnistä terminaali uudelleen ja suorita vielä seuraavat komennot:

nvm install 0.12
echo 'nvm use 0.12' >> ~/.bashrc

Kun komennot on suoritettu, suorita terminaalissa komento node --version, jonka jälkeen terminaaliin pitäisi ilmestyä jokin versionumero, kuten v0.10.29. Tämä tarkoittaa sitä, että Node on asennettu. Laitoksen koneilla Node.js komentojen ajaminen NetBeansin kautta ei kuitenkaan onnistu, mutta samat komennot voit ajaa terminaalissa NetBeans-projektien jureessa.

Testien ajaminen NetBeansissa

Kun Node ja Git on asennettua, olemma valmiita siirtymään NetBeansiin pariin. Käynnistä NetBeans ja avaa siinä tämän viikon tehtävä TodoAppTestaaminen. Jos NetBeans oli jo käynnissä, käynnistä se uudelleen, niin asennukset ovat varmasti tulleet voimaan. Kuten huomaat, projektin nimi on punainen, joten jotain on vialla. Ongelma ratkeaa painamalla hiiren oikeaa painiketta projektin päällä ja valitsemalla "Npm install". Klikkaaminen asentaa Karman ja muut tarvittavat riippuvuudet. Kun riippuvuudet on asennettu, klikkaa taas hiiren oikeaa painiketta projektisi nimen päällä ja valitse "Properties". Valitse avautuvasta ikkunasta oikealla sijaitsevasta valikosta "JavaScript Testing" ja valitse "Testing Provider"-valikosta "Karma". Alle ilmestyy tekstikentät "Karma" ja "Configuration", klikkaa molempien oikealta puolelta "Search"-painikkeita, niin tarvittavat tiedostot löytyvät automaattisesti. Voit nyt ajaa testit klikkaamalla hiiren oikeaa painiketta projektisi nimen päällä ja valitsemalla "Test". Testit eivät vielä mene läpi, joudutkin seuraavassa tehtävässä korjaamaan ne.

Testien ajaminen terminaalissa

Laitoksen koneilla Node.js-komentoja ei pysty ajamaan NetBeansin kautta, mutta pystyt ajamaan ne terminaalissa. Siirry terminaalissa TodoAppTestaaminen-projektin juureen. Saat selville, missä projektikansio sijaitsee klikkaamalla NetBeansissa projektin nimen päällä hiiren oikeaa painiketta ja valitsemalla "Properties". Kopio avautuneesta ikkunasta "Project Folder"-kentän sisältö ja siirry siihen terminaalissa seuraavasti:

cd PROJEKTIN_KANSION_POLKU

Korvaa vain kohtaan PROJEKTIN_KANSION_POLKU kopioimasi projektikansion sijainti. Kun olet siirtynyt projektikansioon, suorita siinä komento npm install, se asentaa tarvitsemasi riippuvuudet. Kun riippuvuudet on asennettu, voit ajaa testit projektikansion juuressa komennolla ./node_modules/karma/bin/karma start.

karma.conf.js

karma.conf.js on tiedosto, joka kertoo Karmalle mm. mitkä tiedostot ladataan selaimeen, kun testit ajetaan. Testeihin pitää ladata koko sovelluksen koodi, kaikki sen käyttämät kirjastot ja itse testit. Tiedoston sisältö voi olla esimerkiksi seuraava:

module.exports = function(config) {
  config.set({

    // annetaan tiedostojen aloituspolku (jos tyhjä, niin se on karma.conf.js tiedoston polku)
    basePath: '',


    // käytettävät sovelluskehykset
    frameworks: ['jasmine'],

    // tiedostot, jotka ladataan selaimeen, kun testit ajetaan
    files: [
        'bower_components/angular.min.js',
        'js/app.js',
        'js/controllers/*.js'
    ],


    // web-palvelimen portti, jossa testit ajetaan
    port: 9876,


    colors: true,

    config.LOG_INFO || config.LOG_DEBUG
    logLevel: config.LOG_INFO,


    // jos "true", niin Karma ajaa testit aina kun jokin "files"-kentässä määritelty tiedosto tallennetaan
    autoWatch: true,


    // testien ajamiseen käytettävä selain
    browsers: ['Chrome'],

    singleRun: false
  });
};

Yleensä karma.conf.js-tiedosto löytyy tehtäväpohjasta valmiina, mutta tulevaisuudessa tulee tehtäviä, jossa sinun täytyy määritellä se itse. Silloinkin yleensä karma.conf.js-tiedoston runko on valmiina, jolloin riittää vain määritellä files-kenttään testien ajamisen yhteydessä selaimeen ladattavat tiedostot.

Muistilistan testaaminen (2p)

Tehtäväpohjasta löytyy viime viikolla toteutetun muistilistasovelluksen mallivastaus, joka ei vielä käyttänyt Firebasea. Voit halutessasi korvata mallivastauksen omalla toteutuksellasi. Muistilistan testaamista varten on toteutettu runko tiedostossa app/test/todo_controller_test.js, mutta testit eivät vielä meni läpi. Sinun tehtäväsi on testata testeissä muistilistan eri toimintoja ja saada testit menemään läpi. Testattavat toiminnot kuvataan kunkin testin kohdalla, it-funktion kutsussa, toteuta siis kuvausta vastaava testi. Jos käytät tehtäväpohjan toteutusta, aloita tutustumalla TodoController-kontrolleriin tiedostossa app/controllers/todo_controller.js. Huom! Jos lisäät tehtäväpohjaan tiedostoja tai muokkaat niiden nimiä, muista päivättää karma.conf.js-tiedosto, jotta lisäämäsi/muokkaamasi tiedostot ladataan selaimeen testien ajamisen yhteydessä.

Reititys Angularissa

Isompi sovellus on usein jaettu useaan eri näkymään, jotka löytyvät eri poluista, kuten /elokuvat, /elokuvat/1 ja elokuvat/uusi. Perinteisesti sovelluksen polut määritetään palvelinpuolen sovelluksessa, mutta kasva trendi on toteuttaa ainakin osa sovelluksen reitityksestä selainpuolella, jolloin sovelluksessa uuteen polkuun siirtyminen ei rasita niin paljon palvelinta. Näitä sovelluksia kutsutaan nimellä "Single-page application".

Single-page sovellukset

Single-page sovelluksesissa reitityksen pääpaino on siirtynyt palvelinpuolelta selainpuolella. Mutta miksi, mitä etuja siitä on? Suurin etu on siinä, että kun käyttäjä siirtyy sovelluksessa toiseen polkuun, palvelimelta haetaan vain uuden näkymän esittämiseen vaadittava sisältö, kuten HTML-sivu tai JSON-dataa sen sijaan, että kaikki sivulla esiintyvät resurssit kuten tyyli- ja skripti-tiedostot sekä kuvat haettaisiin uudelleen. Suorituskyky etu on siis melko huomattava, jolloin näkymien välillä siirtyminen on nopeampaa. Selainpuolen reititys ei ole kuitenkaan kaikki maailman ongelmat ratkaiseva tekiä, siinä on huonojakin puolia. Selainpuolen reitit eivät välttämättä toimi kaikilla alustoilla (etenkin mobiililaitteilla) ja usein sivulla on vain yksi sisääntuloväylä, jonka kautta kaikki reitit käsitellään.

Reittien määrittäminen Angularissa

Angularissa reititykseen käytetään ngRoute-moduulia. Kuten moduulit yleensä, ennen kuin sitä pääsee käyttämään, se täytyy injektoida omaan moduuliisi:

var App = angular.module('MyApp', ['ngRoute']);

Se on siinä! Injektoin ngRoute-moduulin muiden moduulien tapaan lisäämällä sen nimen moduulin alustamiskutsun toisena parametrina olevaan taulukkoon. Itse reititys tapahtuu konfiguroimalla ngRoute-moduulin komponenttia $routeProvider. Se onnistuu seuraavasti:

var App = angular.module('MyApp', ['ngRoute']);

App.config(function($routeProvider){
  $routeProvider
    .when('/', {
      controller: 'HomeController',
      templateUrl: 'templates/home.html'
    })
    .when('/hello', {
      controller: 'HelloController',
      templateUrl: 'templates/hello.html'
    })
    .otherwise({
      redirectTo: '/'
    });
});

Lisäämme konfiguraatiossa $routeProvider-komponentin kautta sovellukseemme kaksi reittiä / ja /hello kutsumalla sen funktiota when. Kuten huomaat, funktiokutsuja voi ketjuttaa. Funktio when ottaa parametrikseen polun merkkijonona ja objektin, joka kertoo, mitä tulee tapahtua, kun käyttäjä siirtyy sovelluksessa ensimmäisenä parametrina annettuun polkuun. Objekti sisältää kentän controller, joka kertoo, mikä kontrolleri otetaan reitissä käyttöön ja kentän templateUrl, joka kertoo, mitä näkymätiedostoa käytetään. Ketjun viimeisessä funktiokutsussa kutsutaan funktiota otherwise, joka kertoo, mitä tehdään, jos käyttäjä siirtyy polkuun, jota ei ole määritelty when-funktioiden kutsuissa. Funktio otherwise ottaa parametrikseen objektin, jossa yleensä määritetään kenttä redirectTo, joka kertoo, mihin polkuun käyttäjä ohjataan, jos hän yrittää mennä polkuun, jota ei ole määritelty.

Kontrollerin määrittäminen ei ole pakollista, jos haluat esittää polussa käyttäjälle pelkästään staattisen näkymän. templaUrl-kentän sijaan voi määrittää kentän template, jonka arvo on tiedoston sijaan merkkijono, joka sisältää näkymän sisällön HTML-elementteineen. template-kentän määrittäminen templateUrl-kentän sijaan periaatteessa parantaa hieman sovelluksen suorituskykyä, koska näkymätiedostoa ei tarvitse hakea palvelimelta, mutta isojen näkymien esittäminen merkkijonona on todella sekavaa.

Liitetään moduulimme kontrollerit HomeController ja HelloController, joihin reiteissä viittaamme:

App.controller('HomeController', function($scope){
  $scope.message = 'Olet etusivulla!';
});

App.controller('HelloController', function($scope){
  $scope.message = 'Olet hello sivulla! Hello World!';
});

Tarvitsemme vielä reiteissä viitatut näkymätiedostot home.html ja hello.html. Tarvitsemme kuitenkin sitä ennen pohjatiedoston, johon näkymät upotetaan, joka on yleensä nimeltään index.html:

<html>
  <head>
  </head>
  <body ng-app="MyApp">
    <a href="#">Etusivu<a> | <a href="#/hello">Hello<a>
    <h1>Kallen Single-page sovellus</h1>
    <div ng-view></div>
  </body>
</html>

Pohjatiedostossa määrittelemme moduulimme ng-app-attribuutissa. Lisäksi pohjatiedostossa määritellään, mihin reiteissä määritellyt näkymätiedostot upotetaan. Se tapahtuu lisäämällä ng-view-attribuutti DOM-elementille, jonka sisään näkymätiedostojen sisältö halutaan lisätä.

Jos olit tarkkana, huomasit myös, että lisäsin pohjatiedostoon pienen navigaation, joka hyvä lisätä juuri pohjatiedostoon, koska navigaatio esiintyy joka sivulla. Navigaation linkit eroavat kuitenkin href-attribuutin arvon osalta hieman. On tärkeää, että linkin kohde on muotoa #/hello, eikä /hello. Ero on siinä, että #-alkuiset kohteet ovat nk. sivun sisäisiä linkkejä, eli niihin siirtyminen ei aiheuta pyyntöä palvelimelle, vaan kohteen käsittely jää selainpuolen sovelluksen tulkittavaksi. Tämä mahdollistaa selainpuolella tapahtuvan reitityksen.

Nyt, jos käyttäjä klikkaa linkkiä "Hello", selaimen osoiteriivin ilmestyy sivuston url:in jatkeeksi #/hello, eikä käyttäjä poistu sivulta, vaan selainpuolen sovellus (meidän Angular-sovelluksemme) päättää, mitä tehdään. Olemme määrittäneet $routeProvider-komponentissa, että polussa /hello otetaan käyttöön kontrolleri HelloController ja näytetään näkymä hello.html. Näkymän hello.html sisältö voisi olla vaikka seuraava:

<h2>Hello sivu</h2>
{{message}}

Huomaa, ettei näkymätiedostossa tarvitse määrittää html- ja body-tageja, koska tiedoston sisältö upotetaan pohjatiedoston div-elementin sisään, jolla on ng-view-attribuutti.

Tässä vielä tähän astinen toteutuksemme (huomaa, etten voi jsFiddlessä määrittää näkymätiedoston polkua, joten joudun laittamaan näkymän sisällön suoraan reitin määritykseen):

PerusMOOC reiteillä (1p)

Tehtäväpohjan mukana tulee viikolla 1 toteutetun PerusMOOC-tehtävän runko ja sivupohjat sivuille "PerusMOOC", "Materiaali" ja "Oma etenemiseni". Muokkaa tehtäväpohjaa niin, että etusivun sisältönä (polussa #/) näytetään "PerusMOOC"-sivupohja, polussa #/materiaali "Materiaali"-sivupohja ja polussa #/oma-etenemiseni "Oma etenemiseni"-sivupohja. Muista injektoida ngroute-moduuli omaan moduulisi, jotta pääset määrittämään sovelluksesi reitit konfiguroimalla $routeProvider-komponenttia. Tee $routeProvider-komponenttiin liittyvät konfiguraatiot tiedostossa app/app.js, jossa itse moduuli sijaitsee. Tarvittavat kontrollerit voit luoda app/controllers kansioon. Muista linkittää luomasi js-tiedostot index.html-näkymässä script-tagien avulla!. Kun reitit on toteutettu, muokkaa vielä navigaatiopalkin linkkejä niin, että ne osoittavat oikeisiin paikkoihin. Näkymätiedostot etusivu.html, materiaali.html ja etenemiseni.html löytyvät kansiosta app/views. Muista, että templateUrl-kentässä määritellyn näkymätiedostopolun juuri on projektisi src-kansio.

Polkujen parametrit

Sovelluksen polun kautta halutaan usein välittää tietoa. Jos meillä on esimerkiksi sovellus, joka esittää käyttäjän profiilisivun, haluamme, että profiilisivulle pääse esimerkiksi polun #/kayttaja/KAYTTAJANIMI-kautta, jossa KAYTTAJANIMI on käyttäjän nimi. Kun käyttäjä tällöin siirtyy sovelluksessa esimerkiksi polkuun #/kayttaja/kalle, voimme hakea tietokannasta (esim. Firebasesta) käyttäjän "kalle" tiedot ja esittää ne profiilisivulla.

Angularissa voimme helposti upottaa polkuihin nk. polkuparametreja, joiden sisältö vaihtelee käyttäjän antaman polun perusteella. Katsotaan, miten voisimme upottaa käyttäjän nimen polkuun #/kayttaja/KAYTTAJANIMI:

var App = angular.module('MyApp', ['ngRoute']);

App.config(function($routeProvider){
  $routeProvider
    .when('/kayttaja/:username', {
      controller: 'UserController',
      templateUrl: 'templates/profile.html'
    });
});

Määrittelemme sovelluksemme reitit tavalliseen tapaan konfiguroimalla ngRoute-moduulin $routeProvider-komponenttia. Tällä kertaa funktiossa when määritelty polku näyttää tosin hieman erinlaiselta, sillä siihen on upotettu parametri username. Parametrin upotus polkuun tapahtuu yksinkertaisesti syntaksilla :parametrinNimi, eli : ja nimi parametrille, jonka kautta voimme hakea sen arvon. Nyt polussa #/kayttaja/arto parametrin username arvo on "arto". Katsotaan, miten voimme käyttää polkumme username parametria määrittelemällä kontrolleri UserController:

App.controller('UserController', function($scope, $routeParams){
  var users = {
    'arto': {
      name: 'Arto',
      friends: ['Kalle', 'Matti']
    },
    'kalle': {
      name: 'Kalle',
      friends: ['Elina', 'Arto', 'Matti']
    },
    'elina': {
      name: 'Elina',
      friends: ['Kalle']
    },
    'matti': {
      name: 'Matti',
      friends: ['Arto', 'Kalle']
    }
  }

  if(users[$routeParams.username.toLowerCase()]){
    $scope.user = users[$routeParams.username.toLowerCase()];
  }else{
    $scope.user = null;
  }
});

Huomasit varmaan, että kontrolleriin on injektoitu $routeParams-muuttuja. Se on objekti, joka sisältää kenttinä kaikki nykyiseen polkuun liittyvät parametrit. username-parametrin arvo on siis $routeParams.username. Voimme käyttää polkuparametrin arvoa, esimerkiksiä määrittämään, minkä käyttäjän profiili esitetään näkymässä, kuten yllä on tehty. Toteutaan vielä näkymä profile.html, jotta saamme homman pakettiin:

<h2>Käyttäjän {{user.name}} profiili</h2>
Kaverit:
<ul ng-repeat="friend in user.friends">
  <li><a ng-href="#/kayttaja/{{friend}}">{{friend}}</a></li>
</ul>

Huomaa, että linkeissä kannattaa käyttää ng-href-attribuuttia perinteisen href-attribuutin sijaan, muuten voi käydä niin, että linkin href-attribuutti asetetaan ennen kuin muuttujat ovat latautuneet. Tällöin esimerkiksi linkin polku voisi olla #/kayttaja/{{friend}} polun #/kayttaja/elina sijaan.

Tässä vielä lopputulos:

Polkujen käsittely kontrollereissa

Sovelluksen polkuja pystyy käsittelemään monin tavoin kontrollereissa. Tärkein palvelu polkujen käsittelyyn on $location-palvelu. Yksi sen tärkeimmistä toiminnoista on käyttäjän ohjaaminen toiseen polkuun käyttäen path-funktiota:

App.controller('UserController', function($scope, $routeParams, $location){
  var users = {
    'arto': {
      name: 'Arto',
      friends: ['Kalle', 'Matti']
    },
    'kalle': {
      name: 'Kalle',
      friends: ['Elina', 'Arto', 'Matti']
    },
    'elina': {
      name: 'Elina',
      friends: ['Kalle']
    },
    'matti': {
      name: 'Matti',
      friends: ['Arto', 'Kalle']
    }
  }

  if(users[$routeParams.username.toLowerCase()]){
    $scope.user = users[$routeParams.username.toLowerCase()];
  }else{
    $location.path('/kayttajat/kalle');
  }
});

Lisäsin siis kontrollerin if-ehdon else-haaraan ohjauksen polkuun #/kayttaja/kalle (huomaa, ettei polun eteen tarvitse laittaa #-merkkiä!). Nyt jos käyttäjää ei löydy, ohjataan käyttäjä polkuun #/kayttaja/kalle, eli käyttäjän "Kalle" profiilisivulle. Tapa muistuttavaa hyvin paljon palvelinohjelmissa tehtävää uudelleenohjausta. $location-palvelua käytetään myös hyvin paljon selaimen osoitepalkin sisällön tarkkailuun. Esimerkiksi, jos haluamme koko tähän hetkisen url:in (esim. http://cs.helsinki.fi/courses), voimme kutsua absUrl-funktiota ja jos haluamme vain pyynnön osan (esim. /courses), voimme kutsua url-funktiota.

Kuin kissat ja koirat (2p)

Tehtäväpohjassa on määritelty palvelut Cat (tiedostossa app/services/cat.js) ja Dog (tiedostossa app/services/dog.js), jotka molemmat tarjoavat funktion all, joka paluttaa taulukon kissoja, tai koiria, riippuen kumman palvelun funktiota kutsutaan. Toteuta kontrollerit ListController (pohja tiedostossa app/controllers/list_controller.js), joka listaa etusivulla (polussa #/) kaikki kissat sekä koirat ja CatController (pohja tiedostossa app/controllers/cat_controller.js), joka näyttää polussa #/kissat/ROTU polkuparametrin ROTU-arvonana (vinkki: $routeParams) annetun kissarodun tiedot seuraavasti:

Eksoottinen lyhytkarva

Eksoottisen lyhytkarvan turkki on tiheä ja samettisen pehmeä, luonne koiramaisen seurallinen. Rakenne on roteva ja litteänaamainen kuten persialaisella. Rotumääritelmä on turkin pituutta lukuun ottamatta yhtenevä persialaisen rotumääritelmän kanssa.

Sivun otsikko on siis kissarodun nimi ja sen alla on sen kuvaus. Toteuta myös kontrolleri DogController (pohja tiedostossa app/controllers/dog_controller.js), joka näyttää polussa #/koirat/ROTU polkuparametrin ROTU-arvonana annetun koirarodun tiedot samaan tapaan kuin kissojen kanssa. $routeProvider-komponenttiin liittyvät konfiguraatiot kannattaa tehdä tiedostossa app/app.js ja näkymätiedostot voi sijoittaa vaikkapa kansioon app/views.

Jos käyttäjä yrittää mennä kissa- tai koirarodun sivulle, jota ei löydy, ohjaa hänet takaisin etusivulle käyttäen $location-palvelua.

Kun tehtävä on tehty, voit vaikappa katsoa upen koko perheen elokuvan Kuin kissat ja koirat.

Kehitystyökalut

JavaScriptiin on toteutettu parin viime vuoden aikana tolkuttoman monta eri kehitystyökaluja ja trendit niiden käytön kanssa vaihtelevat melkein kuukausittain. Käymme seuraavaksi läpi vain pari tärkeintä työkalua, jotka ovat luultavasti joidenkin hipsterien mielestä jo todella vanhanaikaisia.

Järkevää riippuvuuksien hallintaa: Bower

JavaScript sovellukset ja kirjastot ovat nykyään yhä enemmän riippuvaisia toisista kirjastoista, jotka ovat edelleen riippuvaisia toisista kirjastoista. Riippuvuuksien hallinta oli ennen hankalaa, sillä kirjastojen dokumentaatiossa piti erikseen mainita, mitä riippuvuuksia niissä on ja ohjelmoijan piti itse etsiä riippuvuuksien lähdekoodi ja linkittää ne sovellukseen. Nykyään riippuvuuksien hallinta on onneksi todella helppoa, kiitos Bowerin.

Bower etsii ja asentaa tarvitsemasi kirjastot puolestasi. Lisäksi se pitää kirjaa sovelluksesi riippuvuuksista bower.json-tiedostossa, jotta muut voivat tarvittaessa asentaa sovelluksesi käyttämät riippuvuudet, kun he haluavat käyttää sovellustasi. Aloitetaan hieman tarkempi tutustuminen siihen ja katsotaan, miten voimme käyttää sitä NetBeansissa. Bower vaatii toimiakseen Gitin ja Noden. Jos asensit molemmat viime viikolla testaamisen yhteydessä, olet jo valmis. Jos ne on vielä asentamatta lue asentamisohjeet kohdasta Testien suorittaminen.

Kirjastojen asentaminen

Avataan edellisen tehtävän KuinKissatJaKoirat-projekti NetBeansissa ja katsotaan, mitä riippuvuuksia sillä on avaamalla bower.json-tiedosto kansiosta Important Files. Sen sisältö on seuraava:

{
  "name": "weso",
  "version": "0.0.0",
  "authors": [],
  "license": "MIT",
  "dependencies": {
    "angular": "~1.3.13",
    "angular-route": "~1.3.13"
  }
}

Tiedostoston alku koostuu sovelluksemme perustiedoista, kuten sen nimestä, versiosta ja lisenssistä. Sen jälkeen kerrotaan, mitä riippuvuuksia sovelluksessamme on, ne ovat angular ja reititykseen käyttämämme angular-route. Numerosarja riippuvuuden nimen jälkeen on sen versionumero. Asennetaan seuraavaksi pari riippuvuutta, vaikkapa jQuery ja Mustache. Jokaisella kirjastolla on Bowerin rekistereissä oma nimensä, mutta en ole aivan varma, millä nimellä jQuery ja Mustache löytyvät. Voin tarkastaa asian helposti täältä. Ilmeisesti jQuery löytyy nimellä jquery ja Mustache nimellä mustache (yllätys, yllätys!). Määritellään bower.json-tiedostoon kenttä dependencies ja määritellään siihen, että haluamme asentaa sovellukseemme viimeisimmät versiot jquery- ja mustache-kirjastoista:

{
  "name": "weso",
  "version": "0.0.0",
  "authors": [],
  "license": "MIT"
  "dependencies": {
    "angular": "~1.3.13",
    "angular-route": "~1.3.13"
    "jquery": "latest",
    "mustache": "latest"
  }
}

Lisäämällä dependencies-kentän sisältävään objektiin kentät jquery ja mustache, joiden arvo on "latest", saamme asennettua niiden viimeisimmät versiot Bowerin rekistereistä. Riippuvuuksien määrittely on nyt valmis, seuraava vaihe on niiden asentaminen. Se on NetBeansissa helppoa, painetaan vain projektimme nimen päällä hiiren oikeaa painiketta ja valitaan "Npm install", joka asentaa Bowerin ja sen jälkeen valitsemalla "Bower install", joka asentaa asettamamme riippuvuudet jquery ja mustache. Jos käytät laitoksen konetta, suorita terminaalissa NetBeans-projektin juuressa ensin komento npm install ja sen jälkeen komento ./node_modules/bower/bin/bower install. Kommennon suorittamisen jälkeen bower_components-kansioon ilmestyy kansiot mustache ja jquery, joista löytyy tarvitsemamme riippuvuudet, joita voimme nyt käyttää sovelluksessamme.

Väsymätön työnsankari: Grunt

Ohjelmoijalla on sovellusta kehittäessä usein monta pientä tehävää, jotka pitää toistaa tiuhaan tahtiin. Tehtävät voivat olla joko, skripti- tai tyylitiedostojen yhdistämistä ja pakkaamista, testien automaattista suorittamista, tai lähdekoodin auttomaattista siirtämistä versionhallinta. Kaikki näistä tehtävistä ovat melko työläitä, etenkin kun ne pitää suorittaa manuaalisesti tiuhaan tahtiin. Ratkaisu ongelmaan on JavaScriptin ikioma tehtäviensuorittaja, Grunt, joka suorittaa määrittämiäsi tehtäviä puolestasi aina, kun haluat.

Töihin siitä, Grunt!

Grunttiin on toteutettu monta pluginia, joiden avulla sille voi määritellä eri tehtäviä eli "taskeja". Taskien määrittely tapahtuu projektin juuressa sijaitsevassa Gruntfile.js-tiedostossa, jossa pluginit ladataan ja niiden tarjoamat taskit konfiguroidaan tarpeidesi mukaisiksi. Tässä esimerkki Gruntfile.js-tiedostosta, jossa käytetään grunt-contrib-uglify-pluginia minimoimaan kasa JavaScript-tiedostoja yhdeksi kompaktiksi tiedostoksi, josta ne kaikki löytyvät pakatusssa muodossa:

module.exports = function(grunt) {

  // Konfiguroidaan taskit
  grunt.initConfig({
    // uglify-plugini
    uglify: {
      build: {
        src: ['app/app.js', 'app/services/*.js', 'app/controllers/*.js'],
        dest: 'app/app.min.js'
      }
    }
  });

  // Ladataan plugini "grunt-contrib-uglify", joka tarjoaa taskin "uglify"
  grunt.loadNpmTasks('grunt-contrib-uglify');

  // Asetetaan suoritettava oletustaski, joka on tässä tapauksessa "uglify"
  grunt.registerTask('default', ['uglify']);
};

Annamme initConfig-funktiolla parametriksi objektin, jossa määrittelemme taskiemme konfiguraatiot. grunt-contrib-uglify-plugin tarjoaa taskin uglify, jonka avulla pystymme minimoimaan JavaScript-tiedostoja. Se on kätevää, koska sivunlataus nopeutuu huomattavasti, jos script-tageja on sivulla monen sijaan yksi. uglify-taskin konfiguroinnissa kerromme, että haluamme minimoida tiedoston app/app.js ja kaikki js-tiedostot kansioista app/services ja app/controllers tiedostoon app/app.min.js. Konfiguroinnin jälkeen lataamme pluginit käyttöömme ja asetamme gruntin oletus taskit. Tässä tapauksessa, kun Grunt aloittaa työn projektimme parissa komennolla grunt, suoritetaan vain taski uglify.

Voimme suorittaa samaan aikaan monta taskia. Otetaan vielä käyttöön grunt-contrib-jshint-plugini, joka tarjoaa taskin jshint, joka validoi JavaScript-koodia ja ilmoittaa meille löytämänsä syntaksivirheet:

module.exports = function(grunt) {

  // Konfiguroidaan taskit
  grunt.initConfig({
    // uglify-plugini
    uglify: {
      build: {
        src: ['app/app.js', 'app/services/*.js', 'app/controllers/*.js'],
        dest: 'app/app.min.js'
      }
    },
    jshint: {
      src: ['app/**/**.js']
    }
  });

  // Ladataan plugini "grunt-contrib-uglify", joka tarjoaa taskin "uglify"
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-jshint');

  // Asetetaan suoritettavat oletustaskit, jotka ovat "jshint" ja "uglify"
  grunt.registerTask('default', ['jshint', 'uglify']);

  // Asetetaan "hint"-ryhmässä suoritettavat taski
  grunt.registerTask('hint', ['jshint']);
};

Otimme siis käyttöömme taskin jshint, joka etsii syntaksivirheitä kaikista app-kansion alakansioista sijaitsevista js-tiedostoista. Määritimme myös, että jshint-taski suoritetaan oletustaskina ja lisäksi, että sen voi suorittaa ilman erikseen komennolla grunt hint.

Seuraavassa tehtävässä pääset käyttämään Grunttia. Jos et asentanut testaamisen yhteydessä Node.js:ssää ja Git:iä, tee se nyt. Ohjeet löytyvät osiosta Testien suorittaminen. Kun asennus on tehty, avaa NetBeans ja siinä tämän viikon tehtävistä tehtävä GruntToihin. Klikkaa projektin nimen päällä hiiren oikeaa painiketta ja valitse "Npm install", se asentaa tarvitsemasi riippuvuudet, mukaan lukien Gruntin. Sen jälkeen paina taas projektisi nimen päällä hiiren oikeaa painiketta ja valitse "Properties" ja siirry avautuvasta ikkunasta "Grunt"-kategoriaan. Voit valita, milloin Gruntin taskit suoritetaan valitsemalla jonkun kolmesta vaihtoehdosta ja syöttämällä sen viereiseen tekstikenttään, minkä ryhmän taskit suoritetaan (esimerkiksi "default"). Jos käytät laitoksen konetta, voit suorittaa Gruntin taskit terminaalissa NetBeansin-projektin juuressa suorittamalla ensin komennon npm install ja sen jälkeen komennon node -e "require('grunt').cli();".

Laitetaan Grunt töihin (2p)

Arto on toteuttanut eeppisen kokoisen sovelluksen, mutta hänellä on sen kanssa pari pientä ongelmaa. Ensinnäkin, Matti valitti, että Arton sovellus lataa palvelimelta aivan turhan monta js-tiedostoa ja antoikin vinkiksi, että Gruntilla JavaScript tiedostot voi yhdistää yhdeksi kompaktiksi js-tiedostoksi käyttäen grunt-contrib-uglify-pluginia. Sama ongelmalla Artolla on css-tiedostojen kanssa. Siihen soveltuu Matin mukaan grunt-contrib-cssmin-plugini. Lisäksi Artolla on koodissaan syntaksivirhe, jota hän ei millään löydä. Hän tietää, että sen löytämiseen voi käyttää grunt-contrib-jshint, mutta hän tarvitsee apua sen konfigurointiin. Voitko auttaa Artoa?

Tehtäväpohjan mukana Important Files-kansiosta löytyy pohja Gruntfile.js-tiedostolle, johon sinun täytyy tehdä tarvittavat muutokset. Aloita suorittamalla komento npm install joko NetBeansissa tai terminaalissa projektisi juuressa, joka asentaa Gruntin. Kun Grunt on asennettu, klikkaa projektisi nimen päällä hiiren oikeaa painiketta ja valitse "Properties". Siirry avautuvasta ikkunasta vasemmalta kohtaan "Grunt" ja valitse checkboxi "Build Project" ja kirjoita sen vieressä olevaan tekstikenttään "default" (ilman lainausmerkkejä). Nyt Grunt ajaa projektin buildaamisen yhteydessä (build-painikkeen klikkaamisen yhteydessä) kaikki taskit ryhmässä default, jotka määritellään Gruntfile.js-tiedostossa. Laitoksen koneella voit ajaa Gruntin suorittamalla terminaalissa NetBeans-projektisi juuressa komennon node -e "require('grunt').cli();". Toteuta Gruntfile.js-tiedosto, niin, että Grunt suorittaa taskit uglify, cssmin ja jshint seuraavasti:

  • jshint-taski etsii syntaksivirheitä kaikista app/controllers kansiosta sijaitsevista js-tiedostoista. Löydä siis Arton tekemät syntaksivirheet ja korjaa ne niin, että jshint on tyytyväinen.
  • uglify-taski yhdistää tiedoston app/app.js sekä kaikki js-tiedostot kansioista app/controllers, app/services ja app/directives tiedostoon app/app.min.js.
  • cssmin-taski yhdistää kaikki css-tiedostot kansiosta css/common tiedostoon css/app.min.css
    • Muistathan, että kaikki tiedostot sijaitsevat src-kansiossa, joten muista lisätä se Gruntfile.js-tiedostossa määrittelemiesi polkujen eteen, kuten src/app/controllers/*.js.

Viikko 6

Suurempi Angular-sovellus: Elokuvakirjasto

Tällä viikolla keskitymme melko paljon hieman suuremman Angular-sovelluksen toteuttamiseen, jossa käytämme edellisillä viikoilla oppimiamme tekniikoita. Toteuttamamme sovellus on lopullisessa muodossaan elokuvakirjasto, joka jättää IMDb:n varjoonsa (ainakin melkein). Tarkoituksena on, että käyttäjä voi lisätä sovellukseen elokuvia, katsella, sekä muokata ja poistaa niitä. Käytämme sovelluksessamme viime viikolla tututuksi tullutta Firebasea, jotta lisätyt elokuvat pysyvät sovelluksessamme. Aiomme myös julkaista sovelluksemme, jolloin muut pääset näkemään ja käyttämään sitä. Puhutaan siitä seuraavaksi.

Sovellus muiden nähtäville: Heroku

Heroku on suosittu, lukuisilla eri alustoilla toteutettujen sovelluksien hostaamiseen tarkoitettu pilvipalvelu. Tulemme tällä viikolla toteuttamaan sovelluksen, jonka lisäämme sinne muiden nähtäväksi. Aloita rekisteröitymällä Herokuun täältä. Asenna rekisteröitymisen jälkeen koneellesi Heroku toolbelt, joka sisältää kaikki Herokun käyttöön tarvittavat työkalut. Jos käytät laitoksen konetta, sinulle ei sudo-oikeuksia, mutta voit asentaa Heroku toolbeltin alla olevan ohjeen avulla.

Heroku toolbeltin asentaminen laitoksen koneille

  1. Poista pajan koneeen kotihakemistosta tiedosto .netrc
  2. luo fs-kotihakemistoon samanniminen tyhjä tiedosto. fs-kotihakemistosi on polulla /home/tktl-csfs/fs/home/omakayttajatunnus tai /home/tktl-csfs/fs2/home/omakayttajatunnus. Voit luoda tyhjän tiedoston esim. komennolla touch .netrc.
  3. luo symbolinen linkki suorittamalla seuraava komento pajakoneen kotihakemistosta ln -s /home/tktl-csfs/fs2/home/omakayttajatunnus/.netrc. fs tai fs2 riippuen kummasta hakemistosta kotihakemistosi löytyy.
  4. Varmista että olet kotihakemistossasi komennolla cd $HOME
  5. Lataa ja pura heroku client komennolla wget -qO- https://s3.amazonaws.com/assets.heroku.com/heroku-client/heroku-client.tgz | tar xz
  6. Lisää heroku client PATH:iin komennolla echo 'export PATH="$HOME/heroku-client/bin:$PATH"' >> ~/.bashrc
  7. Käynnistä terminaali uudelleen
  8. Tarkista että heroku on asennettu oikein suorittamalla heroku --version jonka pitäisi tulostaa jotain heroku-toolbelt/3.22.1 (x86_64-linux) ruby/2.2.0 tapaista.

Asennusohjeet on otettu "Web palvelinohjelmointi Ruby on Rails"-kurssin materiaalista.

Uuden sovelluksen julkaiseminen Herokussa

Käydään seuraavaksi hakemassa Herokussa julkaistavan sovelluksellemme pohja tästä GitHub reposta. Jos et ole vielä rekisteröitynyt GitHubiin, tee se nyt täältä. Kun olet kirjautunut sitään GitHubiin saat kopioitua repon omien repojesi joukkoon klikkaamalla sivun oikeasta yläkulmasta "Fork"-painiketta. Forkkauksen jälkeen sinut ohjataan forkatun reposi sivulle. Kopioi sieltä sivun oikeassa alalaidassa sijaitsevan "HTTPS clone URL"-kentän sisältö, joka on seuraavanlainen https://github.com/KAYTTAJATUNNUS/Elokuvakirjasto.git. Kun kentän sisältö on kopioitu, siirry terminaalissa hakemistoon, johon haluat kloonata reposi ja suorita komento git clone REPON_URL (käytä kopioimaasi kentän sisältöä kohdassa REPON_URL). Kun kloonaus on tehty, siirry kloonattuun kansioon komennolla cd Elokuvakirjasto ja suorita sen juuressa komento heroku create --buildpack https://github.com/heroku/heroku-buildpack-php. Komennon suorittamisen jälkeen terminaaliin pitäisi ilmestyä teksti Git remote heroku added. Siiretään seuraavaksi sovelluksemme pohja Herokuun suorittamalla komento git push heroku master. Komennon suorittaminen luultavasti pyytää Heroku-käyttäjäsi tietoja. Kun kaikki on valmista, voit avata sovelluksesi selaimessa suorittamalla komennon heroku open. Löydät uuden sovelluksesi myös oman Heroku-käyttäjäsi sivulta.

Muutosten julkaiseminen Herokussa

Herokussa julkaisemamme sovelluksen pohja on seuraavanlainen:

test/

web/
  app/
    app.js
    index.html
  .htaccess
  index.php
.bowerrc
bower.json
package.json
karma.conf.js
composer.json
composer.lock
Procfile
README.md

Tiedostoja on jonkin verran, mutta ainoat, joita sinun tulee muuttaa löytyvät kansiosta app ja mahdollisesti bower_components, jos satut tarvitsemaan muita riippuvuuksia, kun ne, jotka on mainittu bower.json-tiedostossa. app-kansiossa sijaitsevassa index.html-tiedostossa on pohja sovelluksesi etusivulle, eli sivulle, jonka näit, kun siirryit ensimmäistä kertaa selaimessa sovellukseesi. Tee harjoituksen vuoksi tiedostoon pieni muutos, muuta vaikka sivulla olevan otsikon sisältöä. Voit avata Elokuvakirjasto-kansion NetBeansiin valitsemalla siinä File > New Project > HTML5 > HTML5 Application with Existing Sources ja valitsemalla kansioksi Elokuvakirjasto-kansion. Asenna sen jälkeen tarvittavat riippuvuudet klikkaamalla oikeaa hireen painiketta projektin nimen päällä ja valitsemalla ensin "Npm install" ja sen jälkeen "Bower install". Riippuvuuksien mukana tulee mm. Karma, jonka avulla voit testata sovellustasi. Lisäksi bower_components-kansioon ilmestyy läjä kirjastoja, joista on sinulle luultavasti hyötyä. Jos tarvitset muita kirjastoja, lisää vain tarvittavat riippuvuudet bower.json-tiedostoon ja valitse "Bower install". Jos käytät laitoksen koneita, suorita terminaalissa Elokuvakirjasto-kansion juuressa komennot npm install ja ./node_modules/bower/bin/bower install.

Kun haluat ajaa sovelluksesi selaimessasi, klikkaa hiiren oikeaa painiketta projektin nimen päälle ja valitse "Properties", avautuvast ikkunasta "Run" ja valitse "Start File" kenttään tiedosto web/index.html.

Kun jokin muutos on tehty, siirry terminaalissa hakemistoon Elokuvakirjasto ja suorita komennot git add, git commit -m "Pieni muutos index.html-tiedostoon" ja git push heroku master. Komennon suorittamisen jälkeen Heroku julkaisee tekemäsi muutokset. Kun Heroku on valmis, siirry sovellukseesi selaimella (joko komennolla heroku open tai menemällä sovellukseen suoraan selaimella) ja varmista, että muutoksesi on tullut voimaan.

Jos sinulla on vaikeuksia sovelluksen kehittämisessä NetBeansissa, lue tämä ohje.

Look Mama, I'm in Heroku! (2p)

Noudata yllä olevia ohjeita ja julkaise Elokuvakirjasto-projektin pohja Herokussa. Harjoittele lisäksi hieman muutosten julkaisemista tekemällä jokin pieni muutos sovellukseesi (esimerkiksi etusivun otsikon vaihtaminen) ja julkaise muutettu sovellus. Kun olet saanut sovelluksesi pohjan Herokuun ja osaat julkaista muutoksia, voit palauttaa tehtävän lisäämällä index.html-tiedostoon linkin Herokussa sijaitsevaan sovellukseesi.

Lisää Angularia: sisäkkäiset kontrollerit

Usein Angular-sovelluksessa halutaan jakaa malli kahden kontrollerin kesken. Tällöin on kätevää muodostaa sovellukseen kontrollerihierarkioita, jossa kontrollerin sisällä määritellyt kontrollerit pääsevät käsiksi sen näkyvyysalueeseen. Määrittelemällä sisäkkäisiä kontrollereja pystymme vähentämään yhden kontrollerin vastuulla olevia tehtäviä ja toteuttamaan kompakteja kontrollereita, joilla on vain yksi tehtävä. Tässä käytännön esimerkki, miten pystymme siitimään viikon neljä esimerkissä toteutettua ystävien listaa sisäkkäisillä kontrollereilla:

<div ng-app="FriendApp">
  <div ng-controller="FriendController">
    <ul ng-repeat="friend in person.friends">
      <li>{{friend}} <button ng-click="removeFriend($index)">Poista</button></li>
    </ul>
    <div ng-controller="AddFriendController">
      <input type="text" ng-model="newFriend">
      <button ng-click="addFriend()">Lisää ystävä</button>
    </div>
  </div>
</div>

Nyt FriendController-kontrollerin sisään on määritelty kontrolleri AddFriendController, jonka tehtävä on yksittäisen ystävän lisääminen friends-taulukkoon. Katsotaan, miten se tapahtuu kontrollerissa:

FriendApp.controller('AddFriendController', function($scope){
  $scope.addFriend = function(){
    $scope.$parent.friends.push($scope.newFriend);
    $scope.newFriend = '';
  }
});

Vanhemman näkyvyysalueeseen pääse siis $parent-muuttujan kautta. Tosin tässä tilanteessa AddFriendController-kontrollerin näkyvyysalueeseen ei ole määritelty muuttujaa friends, niin vanhemman näkyvyysalueen muuttujaan voi viitata ilman $parent-muuttujaa. Kontrollereja voi olla sisäkkäin vaikka kuinka monessa tasossa ja $parent-muuttuja pystyy ketjuttamaan niin, että kontrollerihierarkiassa sisemmässä kontrollerissa pääsee aina ulomman kontrollerin näkyvyysalueeseen.

Lisää testaamista: Injektoitavien komponenttien käyttäminen testeissä

Palataan tässä vaiheessa hetkeksi takaisin testaamiseen. Toteutimme viime viikolla Firebase-osion yhteydessä sovellukseemme FirebaseService-palvelun, joka keskusteli Firebasen kanssa. Koska Firebase oli avainasemassa sovelluksessamme, koko sovelluksemme nojautui hyvin vahvasti FirebaseService-palveluun. Jos miettii sovelluksen testaamista, sitä käyttävän kontrollerin testaaminen kuulostaa hyvin hankalta mm. siitä syystä, että FirebaseService-palvelu käyttää tietokantaa, jonka sisältö on testeissä arvaamaton. Miten voisimme siis testata kontrolleriamme, johon on injektoitu FirebaseService-palvelu?

Selviämme testaamisesta paljon helpommalla, jos vain oletamme, että FirebaseService-toimii, kuten pitääkin (ideaalitilanteessa se varmistettaisiin vielä testeillä) ja luomme sille nk. "mockin" (suomeksi "väärennös"), jonka injektoimme testeissä kontrolleriimme. Tällöin voimme testata sovellustamme komponentti kerrallaan ja varmistaa, että jokainen komponentti toimii sekä yksin, että yhdessä muiden komponenttien kanssa. Komponentin mockkaaminen ei ole sen kummempaa, kuin määrittämällä sen tarjoamien funktioiden paluuarvot etukäteen. Tällöin tiedämme testeissämme, mitä arvoja olettaa testeissä. Tässä esimerkki mockista, jonka FirebaseService-palvelulle voisi luoda:

describe('MyController', function(){
  var controller, scope;

  // FireBaseService-palvelun mockki
  var FirebaseServiceMock;

  beforeEach(function(){
    module('MyApp');

    FirebaseServiceMock = (function(){
      // Nämä viestit esittävät Firebasessa olevia viestejä
      var messages = [
        {
          text: 'Hi girls!'
        },
        {
          text: 'Mikä boogie?'
        },
        {
          text: 'Angular on parasta!'
        }
      ];

      return {
        addMessage: function(message){
          messages.push(message);
        },

        getMessages: function(){
          return messages;
        },

        editMessage: function(message){
          // Etsitään muokattava viesti mockin taulukosti
          messageToEdit = messages.find(function(m){ return m.text = message.text });
          if(messageToEdit){
            // Muokataan viestiä
            messageToEdit.text = message.text;
          }
        },

        removeMessage: function(message){
          // Valitaan kaikki viestit, paitsi poistettava viesti
          messages = messages.filter(function(m){ m.text != message.text });
        }
      }

    })();

    inject(function($controller, $rootScope) {
      scope = $rootScope.$new();
      controller = $controller('MyController', {
        $scope: scope,
        FirebaseService: FirebaseServiceMock
      });
    });
  });

  it('should be able to get all the messages', function(){
    expect(scope.messages.length).toBe(3);
    expect(scope.messages[0].text).toBe('Hi girls!');
  });

  it('should be able to add a message', function(){
    scope.newText = 'I am back!';
    scope.addMessage();
    expect(scope.messages.length).toBe(4);
    expect(scope.messages[3].text).toBe('I am back!');
  });

  it('should be able to edit a message', function(){
    var message = scope.messages[0];
    message.editText = 'Hi boys!';
    scope.editMessage(message);
    expect(scope.messages[0].text).toBe('Hi boys!');
  });

  it('should be able to remove a message', function(){
    var message = scope.messages[0];
    scope.removeMessage(message);
    expect(scope.messages.length).toBe(2);
  });
});

Toteutamme siis kaikki FirebaseService-palvelun tarjoamat funktiot FirebaseServiceMock-mockkiimme, jossa palautamme funktiot objektina. Nyt tiedämme, mitä dataa FirebaseService-palvelu palauttaa kutsuttaessa ja miten se muokkaa sitä, joten voimme keskittyä pelkän kontrollerin testaamiseen. Mockatussa palvelussa tarjoamme täysin samat palvelut kuin oikeassa palvelussamme, mutta sen sijaan, että viestimme olisivat oikeasti tietokannassa, ne ovat pelkästään taulukossa. Tätä kontrolleri ei kuitenkaan tiedä, koska sen käyttämä rajapinta pysyy täysin samana. Nyt testeissä kontrolleriin injektoidaan FirebaseService-palvelun sijaan testissä määrittämäämme mockki FirebaseServiceMock, kuten $controller-funktion kutsusta näkee.

Funktiokutsujen vakoilu

Testeissä on usein hyvä varmistaa, että jotain funktiota kutsutaan, kun suoritetaan tietyt toiminnot. Esimerkiksi, kun käyttäjä lisää viestin, pitäisi kontrollerissa kutsua FirebaseService-palvelun funktiota addMessage. Jasmine testeissä voikin asettaa "vakoojia", jotka tarkkailevat tietyn objektin funktiota. Voimme esimerkiksi asettaa edellisessä esimerkissä käytettyyn FirebaseServiceMock-mockkiin vakoojat, jotka tarkkailevat sen funktiota addMessage, getMessages, editMessage ja removeMessage. Tehdään se beforeEach-funktiokutsun yhteydesessä, jotta vakoojat asetetaan funktioihin jokaisen testin yhteydessä:

describe('MyController', function(){
  var controller, scope;

  // FireBaseService-palvelun mockki
  var FirebaseServiceMock;

  beforeEach(function(){
    module('MyApp');

    FirebaseServiceMock = // ...

    // Laitetaan vakoilijat tositoimeen
    spyOn(FirebaseServiceMock, 'addMessage').and.callThrough();
    spyOn(FirebaseServiceMock, 'getMessages').and.callThrough();
    spyOn(FirebaseServiceMock, 'editMessage').and.callThrough();
    spyOn(FirebaseServiceMock, 'removeMessage').and.callThrough();

    inject(function($controller, $rootScope) {
      scope = $rootScope.$new();
      controller = $controller('MyController', {
        $scope: scope,
        FirebaseService: FirebaseServiceMock
      });
    });
  });

  it('should initially call getMessages-function', function(){
    // Kutsutaan toHaveBeenCalled-funktiota, jolloin oletamme, että getMessages funktiota on kutsuttu
    expect(FirebaseServiceMock.getMessages).toHaveBeenCalled();
  });

  it('should call addMessage-function when adding a message', function(){
    scope.newText = 'Viestiä pukkaa!';
    scope.addMessage();
    expect(FirebaseServiceMock.addMessage).toHaveBeenCalled();
  });
});

Lisäsimme funktioille vakoilijat kutsumalla spyOn-funktiota, joka ottaa ensimmäiseksi parametrikseen objektin, jonka funktiota halutaan vakoilla ja toiseksi parametriksi itse tarkkailtavan funktion nimen. Ketjutamme funktiokutsun perään vielä kutsun and.callTrough(). Kun vakoilija on asetettu, voimme testeissä olettaa, että jotain funktiota, kuten FirebaseService-palvelun addMessage-funktiota, on kutsuttu toHaveBeenCalled-oletuksen avulla. Usein halutaan myös varmistaa, että jotain funktiota ei kutsuta, se onnistuu not.toHaveBeenCalled-oletuksella.

Lisää Firebasea: yksittäisen objektin haku

Firebase lisää jokaiselle lisätylle objektille yksilöllisen avaimen, joka erottaa sen muista taulukon objekteista. Avain on satunnainen merkkijono, joka löytyy jokaisesta objektista kentästä $id. Avainta voi käyttää hyväkseen vaikkapa sovelluksen reiteissä, jos halutaan toteuttaa objektikohtaisia näkymiä, kuten esittelysivuja.

Yksittäisen objektin saa haettua Firebasesta käyttämällä $getRecord-funktiota:

MyApp.service('FirebaseService', function($firebaseArray){
  var firebaseRef = new Firebase('OMA_FIREBASE/data');
  var dataAsArray = $firebaseArray(firebaseRef);

  this.getObject = function(key, done){
    dataAsArray.$loaded(function(){
      done(dataAsArray.$getRecord(key));
    });
  }
});

$getRecord-funktion kutsussa on vaaran paikka, koska se olettaa, että Firebasesta haettu taulukko dataAsArray on kutsumisen hetkellä ladattu. getObject-funktiossa täytyy varmistua, että dataAsArray-taulukko on ladattu ennen kuin haemme sieltä objektin. Se onnistuu käyttämällä $loaded-funktiota, joka ottaa parametrikseen funktion, jota kutsutaan, kun dataAsArray-taulukko on haettu Firebasesta. Olemme välittäneen getObject-funktiolla avaimen lisäksi toisen parametrin, joka on oma callback-funktiomme, jonka voimme antaa kontrollerissa:

MyApp.controller('MyController', function($scope, $routeParams, FirebaseService){
  FirebaseService.getObject($routeParams.key, function(data){
    $scope.data = data;
  });
});

Tässä kontrollerissa haettavan objektin avain otetaan polkuparametrista, jolloin käyttäjän siirtyessä esimerkiksi polkuun #/data/-JifGkiMM_GDi6fiZbyk, haetaan Firebasesta objekti avaimella -JifGkiMM_GDi6fiZbyk.

Jos sinulla on vaikeuksia sovelluksen kehittämisessä NetBeansissa, lue tämä ohje.

Kaikki näistä tehtävistä palautetaan Herokuun, joten tehtäpohjien palautuksessa riittää laittaa linkki Heroku-sovellukseesi tehtäväpohjien index.html-tiedostoon. Tehtävien tekoon kannattaa kuitenkin käyttää forkkaamasi Elokuvakirjasto-repon pohjaa, jotta voit helposti julkaista tekemäsi muutokset Herokuun. Pystyt avaamaan sen NetBeansissa valitsemalla File > New Project > HTML5 > HTML5 Application with Existing Sources ja valitsemalla kansioksi Elokuvakirjasto-kansion. Muista asentaa riippuvuudet klikkaamalla hiiren oikeaa painiketta projektin nimen päällä ja valitsemalla "Npm install" ja "Bower install". Jos käytät laitoksen konetta, suorita terminaalissa projektisi juuressa komennot npm install ja ./node_modules/bower/bin/bower install, jotka tekevät täysin saman asian.

Elokuvien listaaminen ja lisääminen (3p)

Toteutaan sovellukseemme ensimmäisenä toiminnot elokuvien listaamiselle ja lisäämiselle. Voit aloittaa toteuttamalla palvelun, joka hakee Firebasesta kaikki elokuvat ja lisää sinne elokuvan annetuilla tiedoilla. Muistin virkistämiseksi, datan haku Firebasesta toimi näin:

var firebaseRef = new Firebase('OMA_FIREBASE/movies');
var movies = $firebaseArray(firebaseRef);

this.getMovies = function(){
  return movies;
}

Ja lisääminen puolestaan näin:

this.addMovies = function(movie){
  movies.$add(data);
}

Muista myös injektoida, firebase-moduuli omaan moduuliisi, sekä $firebaseArray palveluusi:

var MyApp = angular.module('MyApp', ['firebase']);

MyApp.service('FirebaseService', function($firebaseArray){
  // ...
});

Käyttämällämme AngularFire-kirjastolla on hyvä ohje Firebasen käyttöön Angularissa, johon kannattaa ehdottomasti tutustua.

Kun palvelu on toteutettu, toteuta kaksi kontrolleria, jotka käyttävät toteuttamaasi palvelua: kontrolleri elokuvien listaamiselle ja elokuvan lisäämiselle. Kontrollereista pitäisi löytyä ainakin toiminnot Firebasesta löytyvien elokuvien listataamiselle, elokuvan lisäämäämiseksi Firebaseen sen nimellä, ohjaajalla, julkaisuvuodella ja kuvauksella. Jos joku näistä kentistä on tyhjä, ei elokuvaa tule lisätä. Näytä käyttäjälle virhetilanteissa virheilmoitukset (vinkki: lomakkeet ja niiden validointi). Kun elokuva on lisätty, tulee käyttäjä ohjata polkuun #/movies (vinkki: $location). Muista injektoida toteuttamasi palvelu kontrollereihisi, jotta pääset käyttämään sitä niissä!

Kun kontrollerit on toteutettu, toteuta sovellukseesi reititys, jossa näkymä elokuvien listaamiselle löytyy sekä polusta #/movies, että polusta #/ (etusivu) ja näkymä elokuvan lisäämiselle löytyy polusta #/moviews/new. Näkymien ei tarvitse olla nättejä, riittää että elokuvien listassa löytyy elokuvat niiden nimillä ja elokuvien lisäyksessä elokuvan pystyy lisäämään sen nimellä, ohjaajalla, julkaisuvuodella ja kuvauksella sopivan lomakkeen kautta.

Elokuvien listaamiselle ja lisäämiselle löytyy pari testiä test-kansiota. Elokuvien listaamisen testit löytyvät kansiosta test/movie_list_test.js ja elokuvien lisäämisen testit kansiosta test/add_movie_test.js. Testaa mollemmissa teisteissä kuvattuja toimintoja. Muista mockata Firebasen kanssa keskuteleva palvelusi niin, että tiedät, mitä dataa kontrollerien pitäisi saada. Testaa lisäksi, että Firebasea käyttävän palvelun funktiota kutsutaan kontrollereissa lisäämällä funktioihin vakoilijat spyOn-funktiolla ja lisäämällä toHaveBeenCalled-oletukset funktioihin, joita pitäisi kutsua. Muista muokata tarvittaessa Elokuvakirjasto-kansion juuressa sijaitsevaa karma.conf.js-tiedostoa, niin että testattavat tiedostot ladataan selaimeen, joka onnistui lisäämällä niiden polut tiedostossa määriteltyyn files-taulukkoon.

Elokuvien esittäminen, muokkaaminen ja poistaminen (3p)

Toteuta seuraavaksi Firebase käyttävään palveluusi toiminnot elokuvan esittämiselle muokkaamisella, poistamiselle. Muokkaukseen voit käyttää funktiota $save ja poistamiseen funktiota $remove. Elokuvan esittäminen kannattaa toteuttaa niin, että haet Firebasea käyttävästä palvelussa elokuvan avaimella, jolla se on talletettu käyttäen $getRecord-funktiota. Muistathan, että yksittäisin objektin haku Firebasesta onnistui seuraavasti:

var firebaseRef = new Firebase('OMA_FIREBASE/movies');
var movies = $firebaseArray(firebaseRef);

this.getMovie = function(key, done){
  movies.$loaded(function(){
    done(movies.$getRecord(key));
  });
}

Muista varoa vaarallista asynkronisuusmörköä! Odota, että data on haettu Firebasesta ennen kuin yrität hakea sieltä objektia käyttämällä $loaded-funktiota, jonka sisällä kutsut parametrina saatua callback-funktiota haetulla objektilla.

Kun Firebasea käyttävät toiminnot on toteutettu palveluun, luo kontrollerit elokuvan esittämiselle ja muokkaamiselle. Kuten elokuvan lisäyslomakkeen kanssa, muista myös validoida muokkauslomakkeen kentät ja näytä käyttäjälle virhetilanteissa virheilmoitukset (vinkki: lomakkeet ja niiden validointi). Lisää myös viime tehtävässä toteutettuun elokuvat listaavaan kontrolleriin toiminto elokuvan poistamiselle, jotta käyttäjä voi poistaa elokuvan esimerkiksi painamalla "Poista"-painiketta elokuvan nimen vieressä.

Lisää kontrollereillesi myös reitit. Elokuvan esittelysivun tulee löytyä polusta #/movies/KEY, jossa parametri KEY on Firebaseen talletetun elokuvan avain. Muistathan, että yksittäisen objektin voi hakea Firebasesta käyttämällä $getRecod-funktiota seuraavasti:

Muokaa elokuvat listaavaa näkymää niin, että elokuvien nimet ovat linkkejä, jotka johtavat niiden esittelysivuille esimerkiksi seuraavasti:

<ul>
  <li ng-repeat="movie in movies">
    <a ng-href="#/movies/{{movie.$id}}">{{movie.name}}</a>
  </li>
</ul>

Jokaisella Firebasesta haetulla elokuvalla on $id-kenttä, jossa on sen avain, jonka avulla sen voi hakea käyttämällä $getRecord-funktiota. Elokuvan esittelysivulta tulee olla otsikkona elokuvan nimi ja sen alapuolella sen ohjaaja, julkaisuvuosi ja kuvaus haluamassasi muodossa.

Lisää myös muokkaussivulle polku #/movies/KEY/edit, jossa KEY-parametri on muokattavan elokuvan avain. Täytä muokkauslomakkeessa kentät automaattisesti muokattavan elokuvan tiedoilla.

Elokuvan esittelylle, muokkaukselle ja poistolle löytyy muutama testi test-kansion tiedostoista edit_movie_test.js ja show_movie_test.js. Joudut molemmissa luomaan mockin sekä käytämällesi Firebase-palvelulle, että $routeParams-muuttujalle, jonka avulla määrität elokuvalla kuvitteellisen avaimen polun parametreihin. RouteParamsMock-voisi toimia esimerkiksi seuraavasti:

RouteParamsMock = (function(){
  return {
    key: 'abc123'
  }
})();

Nyt Firebase-palvelun mockkiin voi toteuttaa elokuvan haun sen avaimelle esimerkiksi seuraavasti:

FirebaseServiceMock = (function(){
  return {
    getMovie: function(key, done){
      if(key == 'abc123'){
        done({
          name: 'Joku leffa',
          director: 'Kalle Ilves',
          release: 2015,
          description: 'Mahtava leffa!'
        });
      }else{
        done(null);
      }
    },
    // ...
  }
});

Testaa lisäksi, että Firebasea käyttävän palvelusta kutsutaan oikeita funktioita lisäämällä niihin vakoojat spyOn-funktion avulla ja lisäämällä toHaveBeenCalled-oletukset funktioihin, joita pitäisi kutsua.

Muista muokata tarvittaessa Elokuvakirjasto-kansion juuressa sijaitsevaa karma.conf.js-tiedostoa, niin että testattavat tiedostot ladataan selaimeen, joka onnistui lisäämällä niiden polut tiedostossa määriteltyyn files-taulukkoon.

Elokuvien haku: OMDb API (2p)

Toteutaan lopuksi sovellukseemme elokuvien haku käyttäen The Open Movie Databasen API:a. Sen kautta voimme hakea elokuvia eri parametrien perusteella. Voimme esimerkiksi hakea kaikki elokuvat, joiden nimestä löytyy sana "lord" osoitteesta http://www.omdbapi.com/?s=lord, jolloin saamme vastauksena JSON-muotoisen objektin haun tuottamista tuloksista. Vastaus voi olla esimerkiksi tämä:

{"Search":[{"Title":"The Lord of the Rings: The Fellowship of the Ring","Year":"2001","imdbID":"tt0120737","Type":"movie"},{"Title":"The Lord of the Rings: The Return of the King","Year":"2003","imdbID":"tt0167260","Type":"movie"},{"Title":"The Lord of the Rings: The Two Towers","Year":"2002","imdbID":"tt0167261","Type":"movie"},{"Title":"Lord of War","Year":"2005","imdbID":"tt0399295","Type":"movie"},{"Title":"The Lord of the Rings","Year":"1978","imdbID":"tt0077869","Type":"movie"},{"Title":"Lord of the Flies","Year":"1990","imdbID":"tt0100054","Type":"movie"},{"Title":"Greystoke: The Legend of Tarzan, Lord of the Apes","Year":"1984","imdbID":"tt0087365","Type":"movie"},{"Title":"Lord of the Flies","Year":"1963","imdbID":"tt0057261","Type":"movie"},{"Title":"Lord of Illusions","Year":"1995","imdbID":"tt0113690","Type":"movie"},{"Title":"Something the Lord Made","Year":"2004","imdbID":"tt0386792","Type":"movie"}]}

Vastauksena saamme siis objektin, jonka Search-kentästä löytyy taulukko haun tuloksista, joista jokainen on objekti, jossa on elokuvan nimi (Title), julkaisuvuosi (Year), id sen esittelysivulle IMDb:ssä (imdbID).

Toteuta sovellukseesi palvelu, jonka kautta elokuvaa voi hakea sen nimellä ja julkaisuvuodella OMDb API:n kautta. Voit tehdä Angularissa AJAX-kutsuja käyttämällä $http-palvelua. Tässä tapauksessa kyseessä on GET-pyyntö, joten voimme käyttää palvelun tarjoamaa get-funktiota seuraavasti:

MyApp.service('APIService', function($http){
  this.findMovie = function(name){
    return $http.get('http://www.omdbapi.com', { params: { s: name } });
  }
});

get-funktio ottaa siis parametreikseen pyynnön osoitteen ja objektin, joka sisältää kyselyyn liittyviä konfiguraatioita. Tässä kyselyssä lisäämme kyselyyn s-parametrin, jonka arvo on findMovie-funktion parametrina saama elokuvan nimi. Huomaa, että palautamme funktiossa koko kyselyn. Voimme käyttää palveluun toteutettua funktiota kontrollerissa seuraavasti:

MyApp.controller('MyController', function($scope, APIService){
  APIService.findMovie('lord').success(function(movies){
    $scope.movies = movies;
  });
});

findMovie-funktion kutsu siis palauttaa suoritettan kyselyn. Koska kysely on asynkroninen, täytyy sille antaa funktio, jota kutsutaan, kun kyselyyn on vastattu. Se onnistuu kutsumalla palautetulle kyselylle success-funktiota, jonka parametrina saatua funktiota kutsutaan, kun kyselyyn on vastattu parametreina palvelimelta saatu vastaus.

Angular tekee AJAX-pyynnöissä ikävän tempun ja lisää pyyntöihin otsakkeen X-Requested-With, jonka seurauksena, emme voi tehdä AJAX-pyyntöjä oman sovelluksemme ulkopuolelle. Pystymme kuitenkin poistamaan otsakkeen konfiguroimalla hieman moduuliamme:

var MyApp = angular.module('MyApp', []);

MyApp.config(['$httpProvider', function($httpProvider) {
  delete $httpProvider.defaults.headers.common["X-Requested-With"]
}]);

Riittää siis konfiguroida $httpProvider-komponenttia, niin, että X-Requested-With-otsake poistetaan pyynnön oletusotsakkeista. Nyt pyynnöt toimivat, kuten pitääkin!

Toteuta elokuvat listaavaan kontrolleriin elokuvien haku niin, että käyttäjä voi näkymässä hakea elokuvaa sen nimellä ja julkaisuvuodella täyttämällä hakulomakkeen, jossa on kaksi tekstikenttää (nimelle ja julkaisuvuodelle). Jos haku tuottaa tuloksia, listaa ne hakulomakkeen alapuolelle niin, että elokuvan nimi on linkki sen IMDb-sivulle. Elokuvan IMDb-sivut ovat muotoa http://http://www.imdb.com/title/imdbID, jossa imdbID-arvon saat hakukyselystä OMDb API:in. Jos haku ei tuota tuloksia, tulee hakulomakkeen alla olla otsikko "Hakusanalla ei löytynyt elokuvia". Jos haku tuottaa tuloksia näytä hakulomakkeen alla otsikko "Haulla löytyi X elokuvaa", jossa X on löytyneiden elokuvien määrä. Muista selkeä suomen kieli, eli jos elokuvia löytyi monta, käytä sanaa "elokuvaa" ja jos niitä löytyi vain yksi käytä sanaa "elokuva" (vinkki: ng-pluralize). Onnistuneella haualla listaa otsikon alle elokuvan nimi linkkinä sen sivulle IMDb:ssä ja sen vieressä sen julkaisuvuosi.

Turvallisuus ennen kaikkea: authentikaatio Firebasen avulla

Tällä hetkellä Elokuvakirjasto-sovelluksessamme ei ole minkäänlaista estettä sille, ettei kuka pystyisi lisäämään, muokkaamaan ja poistamaan elokuvia mielensä mukaan. Se ei ole kovin hyvä asia, haluammekin sovellukseemme authentikaation, jonka avulla vain sovellukseen rekisteröityneet käyttäjät pääsevät tekemään muutoksia sovelluksemme elokuviin.

Firebasessa on monta tapaa toteuttaa sovellukseen authentikaatio, kuten Facebookin tai Twitterin kautta. Me kuitenkin toteutamme perinteisen kirjautumisen sähköpostiosoitteella ja salasanalla. Aloitetaan luomalla palvelu AuthenticationService, jonka kautta voimme lisätä Firebaseen käyttäjän ja kirjata käyttäjän sisään sovellukseemme:

MyApp.service('AuthenticationService', function($firebaseAuth){
  var firebaseRef = new Firebase('OMA_FIREBASE');
  var firebaseAuth = $firebaseAuth(firebaseRef);

  this.logUserIn = function(email, password){
    return firebaseAuth.$authWithPassword({
      email: email,
      password: password
    });
  }

  this.createUser = function(email, password){
    return firebaseAuth.$createUser({
      email: email,
      password: password
    });
  }
});

Authentikaatio Firebasen kautta onnistuu sen tarjoaman $firebaseAuth-palvelun avulla, jonka olemme injektoineet omaan palveluumme. Talletimme authentikaatio-objektin, jonka luimme kutsumalla $firebaseAuth-palvelua viitteellä omaan Firebaseemme, muuttujaan firebaseAuth. Palvelussamme on kaksi funktiota, logUserIn, jonka avulla käyttäjä voi kirjautua sisään sähköpostiosoitteella ja salasanalla käyttäen $authWithPassword-funktiota, sekä funktio createUser, jonka avulla käyttäjä voi luoda uuden käyttäjätilin sähköpostiosoitteella ja salasanalla käyttäen $createUser-funktiota. Molemmissa tapauksissa palautamme funktiokutsut, jotka ovat asynkronisia. Joudumme siis seuraavaksi kontrollerissa määrittelemään, mitä tehdään, kun asynkronin funktiokutsu on suoritettu ja mitä tapahtuu, jos jokin menee pieleen.

Authentikaation hoitavan palvelun toteuttaminen ei tosin vielä riitä, koska emme ole missään vaiheessa määrittäneet, että vain kirjautuneet käyttäjät saavat tehdä muokkauksia. Siirrytään seuraavaksi sovelluksesi hallintasivulle Firebasessa ja valitaan oikeasta laidasta "Security & Rules". Tällä hetkellä "FIREBASE RULES"-kentän sisältö on suurin piirtein seuraava:

{
    "rules": {
        ".read": true,
        ".write": true
    }
}

Kentän arvo on JSON-muotoinen objekti, jossa rules-kentässä määritellään joko kaikkiin resursseihin, tai tiettyihin resursseihin kohdistuvat säännöt. Kuten varmasti jo arvasit, tämän hetkisten sääntöjen perusteella kaikilla on luku- ja kirjoitusoikeudet jokaiseen resurssiin. Se ei ole kovin hyvä asia. Muokataan elokuva-resurssia niin, että vain kirjautuneilla käyttäjillä on kirjoitusoikeus:

{
    "rules": {
      "movies": {
          ".read": true,
          ".write": "auth !== null && auth.provider === 'password'"
      }
    }
}

Lisäsimme siis rules-objektiin resurssin movies, jonka sisällä määräsimme kirjoitusoikeudelle ehdon auth !== null && auth.provider === 'password'. Selvällä suomen kielellä ehto tarkoittaa sitä, että käyttäjän on pitänyt authentikoitua ja lisäksi käyttäjän on pitänyt authentikoitua salasanan kanssa ennen kuin hänellä on kirjoitusoikeus resurssiin movies. Meidän täytyy vielä erikseen sallia authentikaatio salasanalla sovelluksemme asetuksista. Se onnistuu klikkaamalla Firebasesovelluksen hallintasivun vasemmasta laidasta "Login & Auth" ja valitsemalla sieltä checkboxi "Enable Email & Password Authentication". Jos skrollaat sivua hieman alaspäin näet otsikon "Registered Users" alla kaikki sovelluksesi käyttäjät (joita tuskin vielä on) ja pystyt lisäämään, muokkaamaan ja poistamaan niitä.

Lisätään seuraavaksi sovellukseemme kontrolleri, jonka kautta käyttäjä voi kirjautua sisään tai luoda uuden käyttäjätilin:

MyApp.controller('UserController', function($scope, $location, AuthenticationService){

  $scope.logIn = function(){
    AuthenticationService.logUserIn($scope.email, $scope.password)
    .then(function(){
      $location.path('/movies');
    })
    .catch(function(){
      $scope.message = 'Väärä sähköpostiosoite tai salasana!'
    });
  }

  $scope.register = function(){
    AuthenticationService.createUser($scope.newEmail, $scope.newPassword)
    .then(function(){
      AuthenticationService.logUserIn($scope.newEmail, $sopce.newPassword)
      .then(function(){
        $location.path('/movies');
      });
    })
    .catch(function(){
      $scope.message = 'Tapahtui virhe! Yritä uudestaan';
    });
  }
});

Kontrollereissa kutsutaan AuthenticationService-palvelun funktioita, jotka molemmat palauttavat asynkronisen funktiokutsun. Funktiokutsujen perään voi ketjuttaa then-funktion kutsun, jonka parametrina määriteltyä funktiota kutsutaan, kun Firebasesta saadaan pyynnölle myöntävä vastaus. Jos jotain menee pieleen, kutsutaan catch-funktion parametrina annettua funktiota. Nyt kun käyttäjä kirjautuu sisään oikealla sähköpostiosoitteella ja salasanalla, hänet ohjataan sivulle, josta löytyy lista elokuvista. Jos sähköpostiosoite tai salasana on virheellinen, voi kirjautumislomaakkeessa näyttää virheilmoituksen. Käyttäjä voi myös rekisteröityä, jonka jälkeen hän kirjautuu sisään automaattisesti (kuten näet register-funktion ensimmäisestä then-funktion kutsusta).

Nyt authentikaatio toimii, kuten pitääkin, eli kirjautumaton käyttäjä ei pysty lisäämään, muokkaamaan tai poistamaan elokuvia. Sovelluksessa on tosin yksi pieni ongelma, sillä kirjautumaton käyttäjä pääsee vielä lisäys- ja muokkaussivuille sekä näkee listaussivulla poistopainikkeen. Ongelma on kuitenkin helppo ratkaista, lisätään ensin palveluumme funktio checkLoggedIn, joka tarkastaa, onko käyttäjä kirjaunut sisään käyttämällä $waitForAuth-funktiota:

MyApp.service('AuthenticationService', function($firebaseAuth){
  var firebaseRef = new Firebase('OMA_FIREBASE');
  var firebaseAuth = $firebaseAuth(firebaseRef);

  // ...

  this.checkLoggedIn = function(){
    return firebaseAuth.$waitForAuth();
  }
});

Se on siinä! Voimme seuraavaksi käyttää palveluumme toteutettua funktiota sovelluksemme reittin määrittelemisessä:

MyApp.config(function($routeProvider){
  // Muut reitit...
  .when('/movies', {
    controller: // ...
    templateUrl: // ...,
    resolve: {
      currentAuth: function(AuthenticationService) {
        return AuthenticationService.checkLoggedIn();
      };
    }
  })
  .when('/movies/new', {
    controller: // ...
    templateUrl: // ...,
    resolve: {
      currentAuth: function(AuthenticationService) {
        return AuthenticationService.checkLoggedIn();
      };
    }
  })
  .when('/movies/:key/edit', {
    controller: // ...
    templateUrl: // ...,
    resolve: {
      currentAuth: function(AuthenticationService) {
        return AuthenticationService.checkLoggedIn();
      };
    }
  })
  // ...
});

Reittien määrittelyyn on nyt ilmestynyt mystinen resolve-kenttä. Se kertoo, mitä tulee tehdä ennen kuin reittiin siirrytään. resolve-kenttään voi määritellä vaikka kuinka monta "lupausta" (promise) tahansa, jotka kaikki toteutetaan ennen kuin reitissä määritelty kontrolleri ottaa ohjat käsisäänsä. Kun kaikki lupaukset on toteutettu, niiden palautusarvot injektoidaan reitissä määriteltyyn kontrolleriin.Tässä tilanteessa haluamme tarkastaa, onko käyttäjä kirjautunut sisään kutsumalla funktiota $waitForAuth. Kun Firebase antaa tiedon käyttäjän kirjautumisesta, se lisätään currentAuth-kenttään, joka injektoidaan reitin kontrolleriin seuraavasti:

MyApp.controller('MyController', function($scope, currentAuth, $location){
  if(!currentAuth){
    $location.path('/login');
  }
  // ...
});

Kuten huomaat, resolve-kentässä määritelty currentAuth-kenttä on injektoitu kontrolleriin ja se sisältää tiedon kirjautuneesta käyttäjästä. Jos currentAuth arvo on null tarkoittaa se sitä, että käyttäjä ei ole kirjautunut sisään, jolloin hänet tulee ohjata toiseen polkuun, kuten vaikkapa kirjautumissivulle.

Uloskirjautuminen

Käyttäjällä pitäisi olla vielä mahdollisuus kirjautua ulos. Lisätään AuthenticationService-palveluun vielä funktio logUserOut, joka käyttää funktiota $unauth, joka poistaa tiedon käyttäjän kirjautumisesta Firebasesta:

MyApp.service('AuthenticationService', function($firebaseAuth){
  var firebaseRef = new Firebase('OMA_FIREBASE');
  var firebaseAuth = $firebaseAuth(firebaseRef);

  // ...

  this.logUserOut = function(){
    firebaseAuth.$unauth();
  };

  this.getUserLoggedIn = function(){
    return firebaseAuth.$getAuth();
  }
});

Se on siinä! Toteutin palveluun myös funktion getUserLoggedIn, joka palauttaa tiedot kirjautuneesta käyttäjästä käyttämällä $getAuth-funktiota. $getAuth-funktio palauttaa null, jos käyttäjä ei ole kirjautunut sisään, muuten se palauttaa kirjautuneen käyttäjän tiedot. Seuraavaksi meidän täytyy lisätä kontrolleriin funktio, joka kutsuu palveluun toteuttamaamme funktiota. Olisi hyvä, jos funktio olisi käytössä jokaisessa kontrollerissa ja näkyvyysalueessa. Siksi onkin järkevää lisätä se koko sovelluksemme laajuiseen näkyvyysalueeseen, joka on nimeltään $rootScope. Voimme lisätä uloskirjautumiseen käytettävän funktion siihen seuraavasti:

MyApp.run(function(AuthenticationService, $rootScope){
  $rootScope.logOut = function(){
    AuthenticationService.logUserOut();
  };

  $rootScope.userLoggedIn = AuthenticationService.getUserLoggedIn();
});

run-funktion kutsutta suoritetaan moduulissa jokin funktio, aivan kuten jo reitityksen yhteydessä käyttämämme config-funktio. Ero funktioiden välillä on vain siinä, että config-funktio suoritetaan moduulin alustuksen yhteydessä ennen run-funktiota. Funktiokutsussa suoritamme funktion, jossa asetamme $rootScope-objektiin funktion logOut, joka käyttää palveluamme kirjatakseen käyttäjän ulos. Lisäksi lisäsin muuttujan userLoggedIn, jonka avulla näkymässä voi tarkastaa, onko käyttäjä kirjautunut sisään ja jos, niin millä tiedoilla. Määrittelemämme funktio ja muuttuja on nyt käytössä kaikissa näkymissä, joten voimme lisätä uloskirjautumista varten pohjatiedostoon (tiedostoon, jossa on määritelty ng-view-direktiivi) esimerkiksi seuraavan painikkeen:

<button ng-click="logOut()" ng-if="userLoggedIn">Kirjaudu ulos</button>

Painikkeen ei siis tarvitse olla minkään kontrollerin näkyvyysalueessa, koska $rootScope-objektiin määritetyt kentät ovat näkyvissä koko sovelluksen näkyvyysalueessa, eli kaikkialla ng-app-direktiivin sisällä. Kuten huomaat käytin myös userLoggedIn-muuttujaa hyväkseni näkymässä, koska olisi hölmöä näyttää uloskirjautumispainike, jos käyttäjä ei ole kirjautunut sisään.

Elokuvakirjaston authentikaatio (2p)

Toteuta seuraavaksi Elokuvakirjasto-sovellukseesi authentikaatio. Aloita toteuttamalla palvelu, jonka kautta käyttäjä voi luoda uuden käyttäjätilin sähköpostiosoitteella ja salasanalla (vinkki: $createUser-funktio), kirjautua sisään käyttäjätilin tiedoilla (vinkki: $authWithPassword-funktio) ja kirjautua ulos (vinkki: $unauth-funktio). Lisää myös funktio, joka palauttaa tiedot kirjautuneesta käyttäjästä (vinkki: $getAuth-funktio). Muista injektoida palveluun $firebaseAuth-palvelu, joka tarjoaa authentikaatioon tarkoitetut funktiot. Lisää lisäksi Firebaseen sääntö, joka estää kirjoittamisen elokuvaresurssiin ilman kirjautumista. Elokuvaresurssin lukemisen voit sallia halutessasi kaikille käyttäjille. Muista myös aktivoida authentikaatio salasanalla sovelluksesi hallintasivulta kohdasta "Login & Auth".

Kun palvelu on toteutettu, toteuta kontrolleri, jonka kautta käyttäjä voi luoda uuden käyttäjätilin sähköpostiosoitteella ja salasanalla. Jos kirjautuminen epäonnistuu, näytä käyttäjälle näkymässä viesti "Väärä käyttäjätunnus tai salasana!". Jos kirjautuminen onnistuu, ohjaa käyttäjä elokuvat listaavalle sivulle $location-palvelun avulla.

Toteuta samaan kontrolleriin toiminto, jonka avulla käyttä voi luoda uuden käyttäjätilin sähköpostiosoitteella ja salasanalla. Jos käyttäjätili luodaan onnistuunesti, kirjaa käyttäjä sisään ja ohjaa hänet elokuvat listaavalle sivulle. Jos rekisteröityminen epäonnistuu, näytä käyttäjälle viesti "Tapahtui virhe! Yritä uudestaan". Lisää kontrollerille vielä reitti #/login ja toteuta näkymä, josta löytyy kirjautumis- ja rekisteröitymislomake. Lisää myös koko sovelluksen näkyvyysalueeseen funktio, jonka kautta käyttäjä voi kirjautua ulos käyttämällä toteuttamaasi palvelua ja muuttuja, joka palauttaa kirjautuneen käyttäjän tiedot (vinkki: suorita moduulissasi run-funktion avulla funktio, joka lisää $rootScope:en sopivan funktion ja muuttujan).

Kun kontrolleri on valmis, viimeistellään authentikaatio vielä niin, ettei käyttäjä pääse elokuvan lisäys- ja muokkaussivulle ilman kirjautumista. Jos käyttäjä yrittää päästä sivuille ilman kirjautumista, ohjaa hänet polkuun #/login. Piilota lisäksi elokuvien listasta elokuvan poistamiseen tarkoitettu painike, jos käyttäjä ei ole kirjautunut sisään. Se onnistuu esimerkiksi tallentamalla tieto käyttäjän kirjautumisesta muuttujaan kontrollerin näkyvyysalueessa ja lisäämällä näkymään sopiva ng-show-direktiivi. Älä näytä myöskään kirjautumispainiketta, jos käyttäjä on kirjautunut sisään. Voit sijoittaa uloskirjautumispainikkeen haluamaasi paikkaan index.html-tiedostossa.

Kuten muutkin Elokuvakirjasto-sovelluksen tehtävät, toteuta tämäkin tehtävä Heroku-sovellukseesi ja kun olet valmis, voit palauttaa tehtävän lisäämällä tehtäväpohjan index.html-tiedostoon linkin sovellukseesi herokussa.

Twitter Bootstrap

Twitter Bootstrap on Mark Otton and Jacob Thorntonin Twitterillä kehittämä front-end sovelluskehys, joka on saavuttanut todella suuren suosin viime vuosin aikana. Bootstrap tarjoaa laajan skaalan HTML- ja CSS-pohjaisia suunnittelupohjia selkeiden käyttöliittymien toteuttamiseen. Siinä on myös vahvasti esillä nk. "Responsive Design"-periaate, jonka perusteella jokaisen käyttöliittymän tulisi toimia laitteesta ja näytön koosta riippumatta. Puhumme responsiivisestä web-suunnittelusta lisää myöhemmin, käydään ensin läpi Boostrapin tärkeimpiä ominaisuuksia.

Käyttöliittymään kimalletta Bootstrapin avulla

Suunnitestaan sivulle http://getbootstrap.com, jossa Bootstrap sovelluskehyksen kotisivut lymyävät. Seuraavassa tehtävässä aiomme hieman kohentaa Elokuvakirjaston ulkoasua käyttämällä siinä Bootstrapin komponentteja, joten on aika ottaa Bootstrap käyttöön. Aloitetaan lataamalla Bootstrapin lähdekoodit täältä. Kun lähdekoodit on ladattu, pura zip-paketti ja siirrä se Elokuvakirjasto-kansion juuressa sijaitsevaan web-kansioon. Sen jälkeen meidän täytyy lisätä seuraavanlainen link-tagi web/app-kansiossa sijaitsevan index.html-tiedoston head-tagiin:

<head>
  <!-- ... -->
  <link rel="stylesheet" type="text/css" href="bootstrap-3.3.2-dist/css/bootstrap.min.css">
  <!-- ... -->
</head>

link-tagin href-attribuutissa on siis vain määritelty polku Bootstrapin css-tiedostoon. Jos lataamallasi kansiolla on eri nimi, muuta tarvittaessa tiedoston polkua. Bootstrap tarjoaa, myös komponentteja, jotka vaativat siihen liittyvät JavaScript-tiedostot ja jQueryn. Jos haluat käyttää niitä, lisää vielä index.html-tiedoston body-tagin loppuun seuraavat script-tagit:

  <!-- ... -->
  <!-- jQuery -->
  <script src="http://code.jquery.com/jquery-1.11.2.min.js"></script>
  <!-- Bootstrap -->
  <script src="bootstrap-3.3.2-dist/js/bootstrap.min.js"></script>
</body>

Avaa sen jälkeen tiedosto index.html selaimessa esimerkiksi NetBeansin kautta ja näet jo, että ulkoasussa on tapahtunut pieniä muutoksia. Tällä hetkellä pääasiassa fontti ja linkkien väri on muuttunut, mutta seuraavaksi teemme suurempia muutoksia.

Kiillota kilpesi (2p)

Lisää Elokuvakirjasto-sovellukseesi Bootstrapin lähdekoodit, kuten yllä olevassa ohjeessa on tehty. Tutustu sen jälkeen Bootstrapin dokumentaatioon lukemalla sieltä ainakin seuraavat sivut:

Kun sivut on luettu, ala ehostaa Elokuvarkijasto-sovelluksesi ulkoasua, niin että näkymät ovat lopulta tämän näköiset:

Elokuvien lista

Elokuvan lisäyssivu

Elokuvan esittelysivu

Elokuvan muokkaussivu

Kirjautumis- ja rekisteröitymissivu

Responsiivinen web-suunnittelu

Viime osiossa mainitsimme lyhyesti "Responsive Design"-periaatteen, jonka perusteella jokaisen käyttöliittymän tulisi toimia laitteesta ja näytön koosta riippumatta. Verkkosovelluksia käytetään yhä useammin pieninäyttöisillä sovelluksilla, jolloin on välttämätöntä, että sovelluksen käyttöliittymä osaa mukautua pienempään näyttöön.

Esimerkikki huonosta responsiviisesta suunnittelusta on ikävä kyllä Tietojenkäsittelytieteen laitoksen kotisivu. Jos kavennat selaimesi ikkunaa, ulkoasu ei sopeudu mitenkään pienenevään ikkunaan, jolloin pienillä näytöillä ulkoasu on aivan liian leveä.

Esimerkki hyvästä responsiivisesta suunittelusta on taas Herokun kotisivu. Tässä tapauksessa, jos kavennat selaimesi ikkunaa, huomaat että ulkoasu alkaa asettua erilailla. Kun ikkuna kapenee leveä navigaatio muuttuu painikkeeksi oikeaan yläkulmaan ja sisältö muuttuu rivittäisestä sarakkaiseksi tilan säästämiseksi.

Responsiivisen käyttöliittymän toteuttaminen saattaa kuulostaa vaikealta, mutta tulemme pian huomaamaan, ettei se ole sitä. Etenkin kun käytössämme on Bootstrap.

Media-kyselyt

Työskentelimme kurssin alkuosassa aika paljon tyylien kanssa. Toteuttamamme käyttöliittymät eivät kuitenkaan hirveästi reagoineet näytön leveyden muuttumiseen. Otetaan hyvä esimerkkinä viikolla 1 toteutettu "PerusMOOC"-sivusto, jossa navigaatio on melko leveä kapeammille näytöille, kuten mobiililaitteiden näytöille. Lisäksi fonttien koot olivat melko suuret. Sivuston tyylitiedosto muistutti tätä:

body{
    background-color: rgb(233, 229, 217);
    font-size: 20px;
    font-family: Arial, sans-serif;
}

h1{
    color: rgb(73, 69, 69);
}

#navigation{
    list-style-type: none;
    margin: 0px;
    padding: 0px;
    overflow: hidden;
    background-color: rgb(73, 69, 69);
    border-radius: 10px;
    padding: 0px 40px;
    margin-bottom: 40px;
}

#navigation li{
    float: left;
}

#navigation li a{
    display: inline-block;
    padding: 15px 30px;
    font-size: 1.1em;
    color: rgb(233, 229, 217);
    text-decoration: none;
}

#navigation li a:hover{
    color: rgb(73, 69, 69);
    background-color: rgb(233, 229, 217);
}

Pienemmällä näytöllä voisi olla järkevämpää esittää linkit allekkain. Mutta miten voisimme määrittää tyylejä sen mukaan, kuinka leveä käyttäjän näyttö on?

CSS:ssä on käytössä mediakyselyt, joiden avulla voimme määrittää tyylejä erilaisille laitteille. Käytännössä tämä tarkoittaa sitä, että voimme muodostaa ehtolauseiden kaltaisia rakenteita, joissa voimme määrittää esimerkiksi, että kapeammilla kuin 500 pikselin kokoisilla näytöillä otsikoiden koko on 1.5em. Yleensä suurin osa mediakyselyistä kohdistuu juuri käyttäjän näytön leveyteen, niitä voi kohdistaa myös mm. näytön resoluutioon.

Mediakyselyt kirjoitetaan tyylitiedostoihin ympäröimällä tyylejä @media-lohkolla. Lohko määrittely alkaa usein mediatyypin määrittelemisellä, joka voi olla esimerkiksi "screen" (tietokoneiden, tablettien, puhelimien, ym. näytöt), "print" (tulostimet), tai "all" (kaikki mediatyypit). Yleensä mediatyyppiä ei kuitenkaan erikseen määritellä, jolloin mediakysely kohdistuu kaikkiin mediatyyppeihin. Mediatyyppiä seuraa luettelo mediakyselyyn kohtistuvia ehtoja, jotka erototellaan toisistaan and/or-operaattoreilla aivan kuten if-lauseissa. Otetaan tähän väliin muutama käytännön esimerkki:

h1{
  font-size: 2em;
}

// Kun näytön leveys on korkeintaan 500 pikseliä, h1-elementtien fontin koko on 1.5em
@media screen and (max-width: 500px){
  h1{
    font-size: 1.5em;
  }
}

// Kun näytön leveys on vähintään 500 pikseliä, h1-elementtien taustaväri on vihreä
@media scree and (min-width: 500px){
  h1{
    background-color: green;
  }
}

// Kun näytön leveys on välillä 1000px-1500px, h1-elementtien fontin väri on punainen
@media screen and (min-width: 1000px) and (max-width: 1500px){
  h1{
    color: red;
  }
}

Yllä olevassa esimerkissä lisäsimme tyylitiedostoon kolme mediakyselyä. Ensimmäisessä kyselyssä kohdistamme kyselyn, joka muuttaa h1-elementtien fontin kooksi 1.5em, kaikkiin laitteisiin, joiden mediatyyppi on "screen" (tietokoneiden, tablettien, puhelimien tai vastaava näyttö) ja joiden näytön leveys on korkeintaan 500 pikseliä. Toisessa mediakyselyssä taas kohdistimme kyselyn, joka muuttaa h1-elementtien taustavärin vihreäksi, kaikissa laittaissa, joiden mediatyyppi on "screen" ja joiden näytön leveys on vähintään 500 pikseliä. Lopuksi määritämme vielä mediakyselyn, joka kohdistuu laitteisiin, joiden näytön leveys on välillä 1000-1500 pikseliä.

Tehdään vielä PerusMOOC-sivusta hieman responsiivisempi tekemällä media-kyselyllä muutoksia sivun tyyleihin, kun käyttäjän näytön koko on pienempi kuin 700 pikseliä. Sivulla on kapeilla näytöillä tällä hetkellä pari ongelmaa. Ensinäkin navigaation linkit eivät rivity kauniisti, vain siirtyvät yksi kerrallaan alemmas. Korjataan ongelma lisäämällä media-kyselyssä navigaation li-elementtien display-ominaisuudeksi block, jolloin ne vievät rivillä kaiken tilan ja asemmalla float-ominaisuuden arvoksi initial, jolloin ne eivät "kellu". Tehdään sama li-elementtien sisäisille a-elementeille ja vähennetään tyhjää tilaa navigaation reunoilla asettamalla navigaation padding-ominaisuuden arvoksi 0px. Toinen ongelma on, että fontit ovat melko isoja kapeilla näytöillä, joten pienennetään niitä. Responsiivinen versio olisi siis tämä:

body{
    background-color: rgb(233, 229, 217);
    font-size: 20px;
    font-family: Arial, sans-serif;
}

h1{
    color: rgb(73, 69, 69);
}

#navigation{
    list-style-type: none;
    margin: 0px;
    padding: 0px;
    overflow: hidden;
    background-color: rgb(73, 69, 69);
    border-radius: 10px;
    padding: 0px 40px;
    margin-bottom: 40px;
}

#navigation li{
    float: left;
}

#navigation li a{
    display: inline-block;
    padding: 15px 30px;
    font-size: 1.1em;
    color: rgb(233, 229, 217);
    text-decoration: none;
}

#navigation li a:hover{
    color: rgb(73, 69, 69);
    background-color: rgb(233, 229, 217);
}

@media screen and (max-width: 700px){
    h1{
        font-size: 1.4em;
    }

    #navigation{
        padding: 0px;
    }

    #navigation li{
        display: block;
        float: initial;
    }

    #navigation li a{
        display: block !important;
        font-size: 0.9em !important;
    }
}

Katso vielä havainnollistava jsFiddle (kavenna "Result"-ikkunaa, niin huomaat, kuinka media-kysely vaikuttaa tyyleihin).

Bootstrapin grid-järjestelmä

Käyttämämme Bootstrap sisältää responsiivisten sivujen toteutusta varten käytettävän grid-järjestelmän. Sen avulla voimme jakaa sivumme nk. "gridiin", joka koostuu erikokoisista riveistä ja sarakkeista. Hienoa grid-järjestelmässä on se, että näytön koon pienentyessä grid mukautuu pienenevään tilaan rivittämällä sarakkeita, tekemällä gridistä korkean leveän sijaan. Saattaa kuulostaa monimutkaiselta, mutta grid-järjestelmän avulla responsiivisten sivujen toteuttaminen on lasten leikkiä.

Oman ulkoasun muovaaminen gridiksi onnistuu jakamalla se riveihin, joiden sisällä on sarakkeita, joihin itse sisältö tulee. Jokainen rivi koostuu maksimissaan 12 sarakkeesta ja yksi sarake voi olla leveydeltään useamman sarakkeen pituinen. Jos riville yrittää laittaa yli 12 saraketta, siirtyvät yli menneet sarakkeet automaattisesti uudelle riville. Rivit tulee sijoittaa elementin sisään, jossa on joko luokka container (kiinteä leveys), tai container-fluid (koko vanhemman leveys). Riviin tulee lisätä luokka row ja sen sisäisiin sarakkeisiin luokka col-LEVEYS-*. Sarakkeen luokan LEVEYS-muuttuja kertoo, kuinka leveä rivin yksi sarake on. Sen arvo voi olla joko xs, sm, md ja lg, joista md on useimmin käytetty. Tarkemmat kuvaukset sarakkeisiin liittyvistä vaihtoehdoista löytyy tästä taulukosta. Sarakkeen perässä oleva *-muuttuja kertoo, kuinka monen sarakkeen levyinen kyseinen sarake on. Sen arvo on välillä 1-12. Otetaan tähän väliin muutama esimerkki.

Esimerkki: Kolmijakoinen ulkoasu

Toteutetaan grid ehkä yleisimmästä ulkoasumallista, kolmijakoisesta ulkoasusta. Jaetaan siis ulkoasu kolmeen osaan: keskelle yksi leveä sarake ja sen vieressä vasemalla ja oikealla kapeammat sarakkeet:

<div class="container-fluid">
  <div class="row">
    <div class="col-md-3">
      Vasemmanpuoleinen sisältö
    </div>
    <div class="col-md-6">
      Keskimmäinen sisältö
    </div>
    <div class="col-md-3">
      Oikeanpuoleinen sisältö
    </div>
  </div>
</div>

Nyt keskimmäinen sarake kattaa koko rivistä kuuden sarakkeen tilan ja sen viereiset sarakkeet molemmat kolmen sarakkeen tilan. Yhteensä rivin koko on siis 3+6+3=12 saraketta, kuten kuuluukin. Kapealla näytöllä sarakkeet siirtyvät allekkain, niin että vasemmanpuolein sarake on ylimmäisenä ja sen alapuolella on keskimmäinen ja oikeanpuolinen sarake. Tässä vielä jsFiddle esimerkistä (muuta "Result"-laatikon kokoa, niin näet, kuinka grid muuttaa muotoaan).

Esimerkki: rivi sarakkeen sisällä

Rivejä pystyy myös laittamaan sarakkeiden sisään, jolloin ulkoasuun voi luoda monimutkaisempia rakenteita. Tässä esimerkki, jossa edellisen esimerkin keskimmäiseen sarakkeeseen lisätään rivi, jossa on kolme saraketta:

<div class="container-fluid">
  <div class="row">
    <div class="col-md-3">
      Vasemmanpuoleinen sisältö
    </div>
    <div class="col-md-6">
      Keskimmäinen sisältö
      <div class="row">
        <div class="col-md-4">
          Keskimmäisen sisällön alasisältö 1.
        </div>
        <div class="col-md-4">
          Keskimmäisen sisällön alasisältö 2.
        </div>
        <div class="col-md-4">
          Keskimmäisen sisällön alasisältö 3.
        </div>
      </div>
    </div>
    <div class="col-md-3">
      Oikeanpuoleinen sisältö
    </div>
  </div>
</div>

Nyt keskimmäiseen sarakkeeseen on lisätty rivi, joka jaetaan kolmeen sarakkeeseen. Tässä lopputulos jsFidlessä (muuta "Result"-laatikon kokoa, niin näet, kuinka grid muuttaa muotoaan).

Esimerkki: erillään olevat sarakkeet

Sarakkeisiin saa myös lisättyä poikkeamaa, jonka avulla sitä saa siirrettyä rivillä tietyn sarakemäärän verran oikealla. Se onnistuu käyttämällä sarakkeessa luokkaa col-md-offset-*, jossa * kertoo, kuinka monta saraketta sarake siirtyy alkuperäisestä paikastaan. Tässä esimerkki, jossa sarakkeita on erillään kolmella rivillä:

<div class="row">
  <div class="col-md-4">Tämä sarake ei siirry</div>
  <div class="col-md-4 col-md-offset-4">Tämä sarake siirtyy 4 saraketta</div>
</div>
<div class="row">
  <div class="col-md-3 col-md-offset-3">Tämä sarake siirtyy 3 saraketta</div>
  <div class="col-md-3 col-md-offset-3">Tämä sarake siirtyy 3 saraketta</div>
</div>
<div class="row">
  <div class="col-md-6 col-md-offset-3">Tämä sarake siirtyy 3 saraketta</div>
</div>

Ensimmäisellä rivillä ensimmäinen sarake ei siirry, mutta seuraavaan sarakkeeseen on lisätty luokka col-md-offset-4, joten se siirtyy alkuperäisestä paikastaan neljä saraketta oikealla. Nyt sarakkeiden väliin jaa neljän sarakkeen leveyinen tila. Toisella rivillä molemmat sarakkeet siirtyvät kolmen sarakkeen verran oikealla, koska molempiin on lisätty luokka col-md-offset-3. Samoin käyttäytyy kolmannen rivin ainoa sarake. Tässä vielä jsFiddle, joka havainnollistaa yllä olevaa esimerkkiä (muuta "Result"-laatikon kokoa, niin näet, kuinka grid muuttaa muotoaan). Lisää Bootstrapin grid-järjestelmästä voit lukea halutessasi täältä.

Ulkoasu responsiiviseksi (2p)

Tehtäväpohjan index.html-tiedostosta löytyy sivupohja joka ei ole lainkaan responsiivinen. Tämän tehtävän aikana me aiomme tosin muuttaa asian täysin. Aloitetaan lisäämällä tyyleihin media-kyselyita, jonka avulla voimme mukauttaa ulkoasua kapenevaan näyttöön. Toteuta css/site.css-tiedostoon media-kyselyt ja tyyleihin kohdistuvat muutokset, jotka tekevät seuraavat asiat:

  • Muuta id:llä main-container varustettua section-elementtiä niin, että sen leveys ei ole kiinteät 1300 pikseliä, vaan sen leveys on maksimissaan 1300 pikseliä ja kapeammilla näytöillä se on koko näytön leveyinen (vinkki: et välttämättä tarvitse media-kyselyä, jos käytät max-width-ominaisuutta)
  • Kun näytön leveys on alle 1000 pikseliä, piilota hakulomake navigaatiopalkista (vinkki: display-ominaisuus). Hakulomake sijaitsee section-elementissä, jonka id on search-form.
  • Kun näytön leveys on alle 800 pikseliä, muuta lilan laatikon fontin kooksi 1em. Lila laatikko muodostuu section-elementistä, jonka id on jumbo-container
  • Kun näytön leveys on alle 800 pikseliä, piilota navigaatiopalkki, joka muodostuu ul-elementistä, jonka id on navigation ja näytä pudotusvalikko, joka muodostuu div-elementistä, jonka id on dropdown-navigation (vinkki: display-ominaisuus)

Muista sijoittaa media-kyselyt tyylitiedoston loppuun tai käytä ominaisuuksia määrittelemisessä !important-päätettä, muuten ne peittyvät muissa valitsimissa määriteltyjen ominaisuuksien alle.

Tämän jälkeen ulkoasu on sisältöä vaille responsiivinen. Laitetaan seuraavaksi sisältö gridiin käyttämällä Bootstrapin tarjoamaa grid-järjestelmää. Toteuta grid seuraavanlaiseksi:

  • Vasemmanpuoleinen ja oikeanpuoleinen sisältö muodostavat rivin, jossa on kaksi saraketta. Vasemmanpuoleisen sarakkeen leveys on kahdeksan saraketta ja oikeanpuoleisen sarakkeen leveys on neljä saraketta.
  • Vasemmanpuoleisen sarakkeen sisällä on rivi, jossa on kolme saraketta. Kaikkien sarakkeiden leveys on kolme saraketta.

Kun sisältö on gridissä, on ulkoasu täysin responsiivinen. Totea se vielä itse kaventamalla selaimesi ikkunaa. Mahtavaa!

Selkeyttä ja rakennetta tyylitiedostoihin: Less

CSS-kielellä saa aikaan hienoja ulkoasuja, mutta siinä on huomattavia puutteita, jotka ilmenevät etenkin kuin tyylien määrä kasvaa. Ongelmaan on kehitetty viime vuosina monia laajennuksia, jotka lisäävät CSS-kieleen lukuisia toimintoja, kuten muuttujia, funktiota ja sisäkkäisiä valitsimia. Kaikki laajennukset toimivat käynnössä samalla tavalla, niillä on oma syntaksinsa, mutta lopulta kaikki niistä käännetään omalla kääntäjällään CSS-kielelle. Suosituinpia CSS-kielen laajennuksia ovat Less ja Sass, joiden syntaksi muistuttaa hyvin paljon toisiaan ja tarjoat käytännössä samat toiminnot. Keskitymme kuitenkin pelkästään Lessiin, jolla ollut viime vuosina paljon nostetta.

Lessin ominaisuudet

Kuten jo mainittiin, Less lisää CSS-kieleen useita ominaisuuksia, jotka tekevät tyylitiedoista selkeämpiä ja ylläpidettävämpiä. Tutustutaan seuraavaksi sen tärkeimpiin ominaisuuksiin.

Muuttujat

Lessin ehkä paras ominaisuus on muuttujien käyttö. Muuttujiin voi tallentaa oikeastaan mitä vain, värejä, taustakuvien polkuja, fontin kokoja ja jopa valitsimia. Muuttujat määritellään @-merkillä seuraavasti:

@grey: rgb(100,100,100);
@fontSize: 1.1em;
@imagesPath: "../images";
@backgroundImage: "@{imagesPath}/background.png";
@selector: box-container;

.@{selector}{
  color: @grey;
  font-size: @fontinKoko;
  background-image:url(@imagesPath);
}

Määritimme siis muuttujat @grey, @fontSize, @imagesPath, @backgroundImage ja @selector. Kuten huomaat, muuttujan pystyy upottamaan merkkijonoon käyttämällä "@{muuttujanNimi}"-syntaksia. Lisäksi samankaltaisesti muuttujan arvoa voi käyttää valitsimena, kuten esimerkissä määrittelin muuttujan @selector avulla valitsimen .box-container. Kun yllä oleva tyylitiedosto käännetään CSS-kielellä on lopputulos tämä:

.box-container{
  color: rgb(100,100,100);
  font-size: 1.1em;
  background-image:url("../images/background.png");
}

Saattaa vaikuttaa aluksi siltä, että muuttujien määrittäminen vain monimutkaistaa tyylien määrittelyä, mutta niiden uudelleenkäytettävyydestä on valtava hyöty. Voimme myös lisätä muuttujaan taulukon, josta saamme tietyssä indeksissä olevan muuttujan arvon käyttämällä extract-funktiota tähän tapaan:

@font: bold 1.1em sans-serif;

h1{
  font-family: extract(@font, 3);
}

Määrittelimme siis taulukon @font, jossa on kolme alkiota: bold, 1.1em ja sans-serif. Nyt h1-elementin font-family-ominaisuuden arvoksi tulee sans-serif. Huomaa, että taulukon ensimmäisen alkion indeksi ei ole nolla, vaan yksi. Lisäksi taulukon alkioita ei eroteta pilkulla, kuten normaalisti, vaan välilyönillä. Yllä oleva tyylitiedosto kääntyy CSS-kielelle seuraavasti:

h1{
  font-family: sans-serif;
}

Eli h1-elementtien fontiksi tulee @font-taulukon kolmas alkio, eli sans-serif.

Kuten esimerkiksi JavaScriptissä, myös Lessissä muuttujilla on oma näkyvyysalue, joita voi muodostaa loogisesti valitsimien sisään:

// määritellään muuttuja @color globaaliin näkyvyysalueeseen
@color: red;

#selector{
  // määritellään muuttuja @color valitsimen näkyvyysalueeseen
  @color: blue;
  color: @color;
}

Määritämme siis valitsimen sisäisessä näkyvyysalueessa muuttujan @color, jolloin globaalissa näkyvyysalueessa määritelty muuttuja @color peittyy. CSS-kielelle käännetty tyylitiedosto näyttää siis tältä:

#selector{
  color: blue;
}

Lopuksi on muuttujista vielä hyvä tietää se, ettei niitä tarvitse Lessissä määritellä ennen kuin niitä voi käyttää. Tätä kutsutaan muuttujien "laiskaksi lataamiseksi" (lazy loading):

#selector{
  color: @color;
}
// määritellään muuttuja vasta sen käytön jälkeen
@color: red;

Yllä oleva tyylitiedosto ei siis aiheuta ongelmia Lessin kääntäjälle, vaan se kääntyy CSS-kielelle, kuten pitääkin:

#selector{
  color: red;
}

Ei sis ole väliä määritelläänkö muuttuja @color ennen vain jälkeen sen käyttöä. Voit lukea halutessasi lisää muuttujista täältä.

Miksaaminen

Miksaaminen (mixin) tarkoittaa toisessa valitsimessa määriteltyjen ominaisuuksien liittämistä toiseen valitsemeen. Se on kätevää, jos haluamme laajentaa valitsimessa toisen valitsimen ominaisuuksia. Jos määrittelemme esimerkiksi luokan button ominaisuudet ulkoasun painikkeille, jota voimme laajentaa eri värisiä painikkeita sen kautta seuraavasti:

// tavallisen painikkeen ominaisuudet
.button{
  padding: 10px 15px;
  border: 1px solid black;
  border-radius: 3px;
  color: white;
}

// laajennetaan tavallista painiketta lisäämällä musta taustaväri
.button-black{
  // liitetään valitsimeen tavallisen painikkeen ominaisuudet
  .button;
  background-color: black;
}

.button-blue{
  .button;
  background-color: blue;
}

Määrittelimme siis kaikkia painikkeita koskevat ominaisuudet valitsimessa .button. Sen jälkeen laajensimme sen ominaisuuksia valitsimessa .button-black, joka lisää tavalliseen painikkeeseen mustan taustavärin ja valitsimessa .button-blue, joka lisää siihen sinisen taustavärin lisäämällä molempiin valitsimen .button. Kun tyylitiedosto käännetään CSS-kielelle on lopputulos tämä:

.button{
  padding: 10px 15px;
  border: 1px solid black;
  border-radius: 3px;
  color: white;
}

.button-black{
  padding: 10px 15px;
  border: 1px solid black;
  border-radius: 3px;
  color: white;
  background-color: black;
}

.button-blue{
  padding: 10px 15px;
  border: 1px solid black;
  border-radius: 3px;
  color: white;
  background-color: blue;
}

Kuten huomaat, säästyimme todella suurelta määrältä toistoa käyttämällä miksaamista. Valitsimen ominaisuuksia voi laajentaa vaikka kuinka monen valitsimen ominaisuuksilla. Voit halutessasi lukea lisää miksaamisesta täältä.

Sisäkkäiset valitsimet

CSS-kielessä ei itsessään ole mahdollista määrittää valitsimia toisten sisään, minkä vuoksi tyylitiedostoihin syntyy hyvin nopeasti tämän kaltaisia valitsinhirviöitä:

#main-container{
  padding: 20px;
  background-color: white;
  border: 1px solid black;
  border-radius: 10px;
}

#main-container nav{
  background-color: grey;
}

#main-container nav ul{
  list-style-type: none;
  padding: 0px;
  margin: 0px;
}

#main-container nav ul li{
  float: left;
}

#main-container nav ul li a{
  display: inline-block;
  padding: 10px 15px;
}

#main-container nav ul li a:hover{
  background-color: black;
  color: white;
}

Tämä on melko yleinen näky tyylitiedostoissa. Ongelmana on, että valitsimissa on hirveästi toistoa ja lopulta niistä tulee niin pitkä, ettei kukaan enää tiedä mihin ne liittyvät. Lessissä hierarkiset valitsimet pystyy määrittelemään sisäkkäin, joka on huomattavasti selkeämpää, eikä siinä synny toistoa. Tässä edellisen esimerkki Lessin sisäkkäisillä valitsimilla:

#main-container{
  padding: 20px;
  background-color: white;
  border: 1px solid black;
  border-radius: 10px;

  nav{
    background-color: grey;

    ul{
      list-style-type: none;
      padding: 0px;
      margin: 0px;

      li{
        float: left;

        a{
          display: inline-block;
          padding: 10px 15px;

          &:hover{
            background-color: black;
            color: white;
          }
        }
      }
    }
  }
}

Valitsimet eivät ole enää peräkkäin, vaan sisäkkäin, jolloin niiden välinen hierarkia näkyy huomattavasti selvemmin. Valitsimen pseudoluokkiin, kuten :hover ja :focus pystyy viittamaan &-syntaksilla.

Funktiot

Lessissä pystyy määrittelemään yksinkertaisia funktiota, joita voi kutsua eri parametreilla. Funktiot ovat yksi Lessin parhaista ominaisuuksista, koska niiden avulla pystyy luomaan todella paljon uudelleenkäytettäviä tyylejä. Esimerkiksi varjon lisäämiseksi elementtiin täytyy kutsua kolmea eri selaimen ominaisuutta seuraavasti:

.container-with-shadow{
  -webkit-box-shadow: 0px 0px 1px black;
     -moz-box-shadow: 0px 0px 1px black;
          box-shadow: 0px 0px 1px black;
}

Tämä on todella työlästä, etenkin kun varjon haluaa liittää moneen elementtiin hieman eri arvoilla. Varjon lisäämisestä kannattaakin tehdä funktio, jota kutsutaan eri parametreilla:

.box-shadow(@x: 0, @y: 0, @blur: 1px, @color: #000) {
  -webkit-box-shadow: @arguments;
     -moz-box-shadow: @arguments;
          box-shadow: @arguments;
}

Funktion voi määritellä millä tahansa valitsimella, joka ottaa parametreja sulkujen sisään. Parametreilla voi määrittää oletusarvot ja niitä voi käyttää funktion sisältä joko yksittäin, tai kaikkia kerrallaan käyttämällä @arguments-muuttujaa, jotka sisältävät kaikki funktion parametrit. Jos funktiokutsussa ei määritellä kaikkia parametreja, käytetään niiden tilalla siinä määriteltyjä oletusarvoja. Tässä esimerkki .box-shadow-funktion käytöstä:

.container-with-shadow{
  .box-shadow(0px, 0px, 1px);
}

.container-with-red-shadow{
  .box-shadow(0px, 0px, 1px, red);
}

Kutsuimme määrittämäämme .box-shadow-funktiota siis kahdesti hieman eri parametreilla. Ensimmäisessä kutsussa määrittelimme vain kolme ensimmäistä parametria, jolloin varjon väriksi asetetaan funktiossa määritelty oletusarvo #000 (musta). Toisessa kutsussa taas määrittelimme kaikki parametrit. Funktioissa pystyy myös määrittelemään laskutoimituksia ja lisäämään kutsuttavan valitsimen näkyvyysalueeseen uusia muuttujia, joita voi ajatella paluuarvoina:

.average(@x, @y) {
  @average: ((@x + @y) / 2);
}

.box{
  .average(16px, 50px);
  padding: @average;
}

Kun tämä käännetetään CSS-kielelle saadaan seuraava lopputulos:

.box{
  padding: 33px;
}

Todella kätevää! Lessissä on määritelty läjäpäin valmiita funktiota, joiden käyttö selkeyttää tyylitiedostoja ja säästää rutkasti aikaa. Tärkeimpiä funktioita ovat matemaattiset ja värien manipulointiin liittyvät funktiot. Tässä esimerkki matemaattisista funktioista:

// ceil-funktio pyöristää desimaaliluvun ylöspäin
ceil(2.2); // 3
// floor-funktio pyöristää desimaaliluvun alaspäin
floor(2.8); // 2
// round-funktio pyöristää desimaaliluvun
round(2.5); // 3
// sqrt-funktio laskee arvon juuren
sqrt(25px); // 5px
// min-funktio palauttaa arvoista pienimmän
min(2px, 10px, 16px, 102px); // 2px
// max-funktio palauttaa arvoista suurimman
min(2px, 10px, 16px, 102px); // 102px

Lisää matemaattisia funktioita löytyy täältä. Lisäksi käytössä on suuri joukko värien manipulointiin käytettäviä funktioita. Niiden parametreina voi käyttää joko rgb-, heksadesimaali- tai rgba-arvoja (red, green, blue, alpha. Väri, jolla on läpinäkyvyysarvo välillä 0-1). Tässä hieman esimerkkejä:

// lighten-funktio vaalentaa väriä annetun prosenttimäärän verran
lighten(red, 10%); // #ff3333
// darken-funktio tummentaa väriä annetun prosenttimäärän verran
darken(red, 10%); // #cc0000
// fadein-funktio vähentää värin läpinäkyvyyttä annetun prosenttimäärän verran
fadein(rgba(255,0,0,0.5), 20%); // rgba(255, 0, 0, 0.7)
// fadeout-funktio lisää värin läpinäkyvyyttä annetun prosenttimäärän
fadeout(rgba(255,0,0,0.5), 20%); // rgba(255, 0, 0, 0.3)

Lisää värien manipulointiin liittyviä funktioita löydät täältä. Lessistä löytyy myös monia muita valmiita funktioita, joista kaikki löytyy täältä.

Less-tiedostojen kääntäminen NetBeansissa

Avaa NetBeansissa tämän viikon tehtävä LessIsMore, klikkaa hiiren oikeaa painiketta sen nimen päällä ja valitse "Npm install", joka asentaa Less-kääntäjän. Jos käytät laitoksen konetta suorita terminaalissa projektin juuressa komento npm install. Kun kääntäjä on asennettu paina taas hiiren oikeaa painiketta projektin nimen päällä ja valitse "Properties". Valitse avautuneen ikkunan vasemmasta valikosta "CSS Preprocessors" ja sieltä "Less". Valitse checkboxi "Compile Less Files on Save" ja paina "Configure Executables"-painiketta. Klikkaa avautuneesta ikkunasta "Less path"-tekstikentän vierestä "Browse..."-painiketta ja valitse projektin juuresta tiedosto node_modules/less/bin/lessc. Paina sen jälkeen "Ok"-painiketta. Less-tiedostot käännetään nyt tallenuksen yhteydessä.

Less is more (2p)

Tehtäväpohjan kansiossa less on tyylitiedosto site.less, joka kaipaa kipeästi refaktorointia. Tyylitiedoston ilmeisiä ongelmia ovat pitkät valitsimet ja toistuvuus, joihin aiomme seuraavaksi puuttua. Refaktoroi tiedosto site.less-tiedosto seuraavasti:

  • Toteuta kaikki valitsimet sisäkkäisinä niin, ettei site.less-tiedosta löydy yhtään monen peräkkäisen valitsimen ryhmää (lue tarvittaessa sisäkkäisistä valitsimista)
  • Määrittele väri #5BB12F muuttujassa @green, väri #008BBA muuttujassa @blue ja väri #DC403B muuttujassa @red. Korvaa sen jälkeen värien esiintyjät muuttujilla niin, ettei tyylitiedostossa enää löydy värien heksadesimaali esityksiä
  • Toteuta funktio .btn(@color), joka määrittää painikkeen annetulla värillä. Käytä funktion pohjana valitsimissa .btn-blue, .btn-green ja .btn-red toistuvia ominaisuuksia ja aseta sen taustaväri parametrin @color-mukaan. Lisää funktioon :hover-pseudovalitsin, jossa painikkeen taustaväri muuttuu 20% alkuperäistä taustaväriä (parametrin @color arvoa) tummemmaksi (vinkki: darken-funktio). Käytä toutettamaasi funktiota valitsimissa .btn-red, .btn-blue ja .btn-green, joissa kutsut .btn-funktiota edellisessä kohdassa määrittämilläsi muuttujien @red, @blue ja @green arvoilla
  • Toteuta funktio .gradient(@start, @end), joka lisää elementtiin gradientin-taustan. Gradientti tausta määritellään eri selainten ominaisuuksilla seuraavasti:

    background: -webkit-linear-gradient(red, blue); /* Safarille */
    background: -o-linear-gradient(red, blue); /* Operalle */
    background: -moz-linear-gradient(red, blue); /* Firefoxille */
    background: linear-gradient(red, blue); /* Standardi */
    

    Tämä esimerkki luo gradientin, joka alkaa sinisenä ja päättyy punaiseen. Käytä omassa funktiossasi alkuvärinä parametrin @start-arvoa ja loppuvärinä parametrin @end arvoa. Kun funktio on toteutettu, kutsu sitä .btn-funktiossa niin, että tausvärin asettamisen sijaan painikkeeseen liitetään gradientti-tausta, joka alkaa @color-parametrina annetusta väristä ja päättyy siitä 15% vaaleampaan väriin (vinkki: lighten). Vaihda myös :hover-pseudovalitsinta niin, että se käyttää .gradient-funktiota @color-parametria 20% tummemmalle värillä (vinkki: alkuväri on darken(@color, 20%) ja loppuväri 15% vaaleampi, eli darken(@color, 5%)).

  • Määrittele taulukko xs, sm, md, lg, xl muuttujaan @sizes ja poista tiedostosta valitsimet .btn-xs, .btn-sm, .btn-md, btn-lg ja .btn-xl. Tutustu sen jälkeen toistorakenteisiin ja toteuta funktio .btn-sizes(@counter: 1, @fontSize: 0.6em), joka generoi valitsimet eri kokoisille painikkeille niin, että .btn-xs-painikkeen fontin koko on 0.6em ja fontin koko kasvaa aina 0.2em painikkeen kasvaessa, kunnes .btn-xl-painikkeen fontin koko on 1.4em. Käytä funktion @counter-parametria pitämään kirjaa siitä, kuinka mones kierros rekursiossa on käynnissä ja lopeta rekursio, kun @counter-parametrin arvo saavuttaa arvon length(@sizes), eli taulukon @sizes pituuden. Kasvata rekursiossa @fontSize-parametrin arvoa aina 0.2em:llä. Muista, että saat alkion taulukon indeksissä käyttämällä extract-funktiota, mistä on hyötyä generoidessa valitsimien nimiä. Valmiin funktion kutsuminen generoi seuraavat tiedostoon css/site.css seuraavat valitsimet:

    .btn-xs{
      font-size: 0.6em;
    }
    .btn-sm{
      font-size: 0.8em;
    }
    .btn-md{
      font-size: 1em;
    }
    .btn-lg{
      font-size: 1.2em;
    }
    .btn-xl{
      font-size: 1.4em;
    }
    

Viikko 7

Olemme tähän mennessä tehneet selainpuolen sovelluksissamme AJAX-kyselyitä moniin lähteisiin ottammatta kovinkaan paljon kantaa siihen, mitä palvelimella kulissien takana tapahtuu.

Viimeisen viikon kunniaksi otamme mukaan hieman palvelinpuolen ohjelmointia, mutta pysymme silti kurssin aikana tutuksi tulleessa ohjelmointikielessä - JavaScriptissä. Lisää palvelinohjelmointikokemusta saat kursseilta Web-palvelinohjelmointi: Java sekä Web-palvelinohjelmointi: Ruby on Rails.

Palvelinohjelmointia JavaScriptillä - Node.js ja Express

Olemme edeltävillä viikoilla jo tehneet pienen pintaraapaisun Node.js:ään npm:n yhteydessä, mutta vielä ei ole välttämättä täysin selvää, mistä Node.js:ssä. Node.js on Googlen kehittämän V8 JavaScript moottorin päälle rakennettu alusta nopeiden ja skaalautuvien web-sovellusten toteuttamiseen.

Node.js-palvelimen toimii tapahtumapohjaisesti, jossa jokaista tapahtumaa käsittelee yksi säie. Jokainen palvelimelle tekemämme kysely, esimerkiksi spoilausten haku, on yksi tapahtuma. Palvelin alkaa käsitellä tapahtumaa (esimerkiksi pyyntöämme) niin, että se ei jää odottamaan, että tapahtuman käsittely on valmis, vaan rekisteröi funktion, jota kutsutaan, kun tapahtuman käsittely on valmis ja ottaa heti toisen tapahtuman (esimerkiksi toisen pyynnön) käsiteltäväksi. Tätä kutsutaan ei-blokkaavaksi I/O-malliksi.

Tekniset löpinät sikseen, miten tämä käytännössä toimii? Node.js-palvelimen toteuttaminen olemassaolevia kirjastoja käyttäen on erittäin suoraviivaista, tässä pieni esimerkki porttia 3000 kuuntelevasta web-palvelimesta:

var http = require('http');

http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
}).listen(3000, '127.0.0.1');

console.log('Palvelin odottaa pyyntöäsi osoitteessa http://127.0.0.1:3000');

Lisää tämä koodi esimerkiksi työpöydällesi tiedostoon palvelin.js ja suorita terminaalissa työpöydälläsi komento node palvelin.js. Nyt terminaaliin pitäisi ilmestyä viesti Palvelin odottaa pyyntöäsi osoitteessa http://127.0.0.1:3000. Parasta tehdä, kuten käsketään ja siirtyä selaimessa osoitteeseen http://127.0.0.1:3000. Kuten jo varmasti arvasit, sivulle ilmestyy teksti "Hello World".

Hetkinen, toteutimme siis yksinkertaisen web-palvelimen käynnistävän sovelluksen näin pienellä määrällä koodia? Kuulostaa hullulta, mutta näin on. Vaikkakin edellinen toteutus oli suoraviivainen, on suuremman sovelluksen kehittäminen tältä pohjalta hieman työlästä. Tutustutaan sen vuoksi yhteen Node.js:än suosituimmista web-sovelluskehyksistä, Express:iin

Express - nopea ja minimalistinen web-sovelluskehys Node.js:lle

Tällä viikolla toteutamme pienen keskustelualuesovelluksen Express-sovelluskehyksellä, ja tutun selainpuolen totetutuksen lisäksi mukana on hieman palvelinohjelmointia. Ennen kuin ryhdymme tositoimiin, puhutaan hieman, mihin palvelinpuolen sovelluksen toiminta perustuu.

Kuten jo edellisillä viikoilla on kerrottu, selaimen ja palvelimen välinen kommunikointi perustuu HTTP-protokollaan, jonka avulla palvelin vastaanottaa pyyntöjä selaimelta ja lähettää vastauksen. Olemme tähän mennessä tottuneet samaan palvelimelta GET-pyyntöihimme pääasiassa JSON-muotoisia vastauksia, jonka otsakkeissa on (ainakin toivottavasti) ollut statuskoodi 200, jonka perusteella selain tietää, että pyyntö on kunnossa. GET-pyyntöjen lisäksi olemme tehneet palvelimelle POST-pyyntöjä, joihin olemme tyypillisesti liittäneet jonkinlaista dataa, esimerkiksi JQuerySpoilaajanBackend tehtävässä spoilauksen JSON-muodossa.

Pyynnöt kohdistuvat aina johonkin palvelimella sijaitsevaan polkuun. Kun esimerkiksi tehdään GET-pyyntö osoitteeseen http://bad.herokuapp.com/app/spoilers, täytyy osoitteessa http://bad.herokuapp.com olevan palvelinohjelmiston päättää, miten pyyntö polkuun app/spoilers käsitellään. Käytännössä kyseisessä sovelluksessa haetaan kaikki spoilaukset tietokannasta ja lähetetään ne käyttäjälle JSON-muotoon renderöitynä vastauksena.

Express tarjoaa erään tavan toteuttaa palvelinrajapinnan, jonka kautta selainpuolen sovellus voi erilaisten HTTP-pyyntöjen avulla tehdä muutoksia tietokantaamme. Tämä tapahtuu yksinkertaisesti määrittelemällä sovellukseen polkuja, joihin voi määrittää eri HTTP-pyyntöjä. Voimme esimerkiksi määrittää, että GET-pyyntöön sovelluksemme polkuun /hello lähettää selaimelle vastauksena tekstin "Hello World":

var express = require('express');
var app = express();

app.get('/hello', function(req, res) {
  res.send('Hello world');
});

Esimerkissä ladataan express-moduuli require-funktion avulla, joka on Node.js:än tapa ladata moduuleja. Sen jälkeen voimme get-funktiokutsun avulla määrittää, että GET-pyyntö parametrina annettuun polkuun aiheuttaa toisena parametrina annettun funktion kutsumisen. Toisena parametrina annettu funktio tarvitsee kaksi parametria, joista req sisältää pyyntöön liittyvät tiedot, ja res-parametria käytetään vastauksen muodostamiseen. Tässä esimerkissä selaimelle annetaan vastauksena pelkkää testiä send-funktion avulla. Olisimme yhtä hyvin voineet antaa selaimelle vastauksena pelkän statuskoodin 200 OK kutsulla res.send(200), tai JSON-muotoisen objektin kutsulla res.json({ message: 'Hello world' }).

Aloitetaan seuraavaksi pienen Express-sovelluksen toteuttaminen. Tätä varten sinulle on toteutettu pieni pohja, josta löytyy kaikki valmiina kaikki tarvitsemasi riippuvuudet. Pohja löytyy tästä GitHub repositoriosta. Saat repon omien repojesi joukkoon, kuten viime viikon Elokuvakirjasto-repon, klikkaamalla sivun oikeasta ylälaidasta "Fork"-painiketta, kunhan olet ensin kirjautunut Githubiin. Painikkeen painamisen jälkeen siirryt forkatun repon sivulle, josta voit kopioida "HTTPS clone URL"-kentän sisällön ja suorittaa terminaalissa haluamassasi kansiossa komennon git clone https://github.com/KAYTTAJATUNNUS/Foorumi.git, jossa git clone komennon jälkeinen URL on kopioimasi "HTTPS clone URL"-kentän sisältö. Kun repositorio on kloonattu koneellesi, siirry siihen terminaalissa komennolla cd Foorumi ja suorita komento npm install.

Saatat törmätä npm install-komennon suorituksen yhteydessä tähän virheeseen:

npm ERR! sqlite3@2.1.19 install: `node build.js`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the sqlite3@2.1.19 install script.
npm ERR! This is most likely a problem with the sqlite3 package,
npm ERR! not with npm itself.

Se johtuu siitä, että Node.js-versiosi on vanhempi kuin 0.12, eikä sqlite3-kirjaston asentaminen onnistu. Tämä ongelma saattaa ilmetä, jos suoritit komennon laitoksen koneilla. Ratkaise ongelma suorittamalla terminaalissa komento nvm install 0.12. Kun uudempi versio on asennettu, editoi ~/.bashrc-tiedostoa niin, että käytössä on aina versio 0.12. Se onnistuu suorittamalla terminaalissa komento vim ~/.bashrc, painamalla "a"-näppäintä, jolloin pääset editoimaan tiedostoa ja korvaamalla rivi nvm use 0.X rivillä nvm use 0.12. Paina sen jälkeen "esc"-näppäintä, syötä terminaaliin wq ja paina enter-näppäintä. Käynnistä terminaali uudelleen ja varmista, että käytettävä version on 0.12 suorittamalla komento nvm current. Nyt npm install-komennon suorittamisen kanssa ei pitäisi olla ongelmia.

Komennon suorittaminen asentaa läjän riippuvuuksia, kuten Express:in. Kun riippuvuudet on asennettu, suorita samaisessa kansiossa komento node bin/www, joka käynnistää palvelimen. Siirry sen jälkeen selaimella polkuun http://localhost:3000 ja saavut hieman keskeneräisen keskustelualueen etusivulle. Ryhdytään seuraavaksi hommiin.

Yksinkertaisen API:n toteuttaminen

Seuraavaksi toteutamme keskustelualue-sovellukseemme palvelinpuolen API:n, jota selainpuolen sovelluksemme voi käyttää. API (Application programming interface) tarkoittaa ohjelmointirajapintaa, jonka kautta eri ohjelmoivat voivat kommunikoida keskenään. Meidän tapauksessamme haluamme toteuttaa rajapinnan, joka käsittelee selaimen lähettämiä HTTP GET- ja POST-pyyntöjä.

Sovelluksen mallit

Tarkastellaan aluksi hieman sovelluksemme malleja, eli minkälaisia olioita haluamme tallentaa ja miten ne liittyvät toisiinsa. Keskustelualue koostuu neljästä mallista:

Tässä vielä sama UML-kaaviona:

Jos et täysin ymmärrä kaaviota, ei hätää, niitä käydään tarkemmin kurssilla Ohjelmistotekniikan menetelmät.

Kurkistetaan seuraavaksi, miltä tietosisältö näyttää sovelluksessamme. Avaa Foorumi-kansion models-kansiossa sijaitseva index.js-tiedosto. Voit avata koko Foorumi-kansion NetBeansissa valitsemalla siinä File > New Project > HTML5 > HTML5 Application with Existing Sources ja valitsemalla kansioksi Foorumi-kansion. Tiedoston sisällä on alustettu juuri kuvaillut mallit muodossa, jota sovelluksemme ymmärtää. Kuten huomaat, jokaisessa mallissa on kuvailtujen attribuuttien lisäksi kokonaislukuarvoinen id-attribuutti, jonka avulla mallin oliot erotetaan toisistaan tietokannassa. Jos meillä on esimerkiksi kaksi viestiä, meidän täytyy jotenkin pystyä erottamaan ne toisistaan. Sisällön vertaaminen kuulostaa hankalalta ja lisäksi silloin meillä ei voisi olla kahta viestiä, joilla on sama sisältö. Mallien oliot erotetaankin toisistaan yksilöllisellä kokonaislukuarvolla, joka generoituu automaattisesti. Mallien sisällön määrittelemisen lisäksi, niiden väliset relaatiot on määritelty. Esimerkiksi relaatio "Viestillä on monta vastausta" kuvaillaan sovelluksellemme rivillä Message.hasMany(Reply) ja relaatio "Vastaukseen liittyy käyttäjä" rivillä Reply.belongsTo(User). Käytämme sovelluksemme datan hakuun ja manipulointiin Sequelize-kirjastoa, josta puhumme lisää myöhemmin.

Sequelize - datan lisääminen ja hakeminen

Olet luultavasti alkanut miettimään, että miten sovelluksemme data talletetaan siten, ettei se katoa, kun sammutamme tietokoneemme. Tähän tarkoitukseen meillä on käytössä tietokanta, jossa data pysyy tallessa tietokoneemme kovalevyllä, kuten tiedostosi. Käytössämme on yksinkertainen SQLite tietokanta, joka tallentaa kaiken sovelluksemme datan yhteen, database.sqlite tiedostoon, joka sijaitsee sovelluksemme kansiossa db. Tämä ratkaisu ei ole kovin hyvä sovelluksiin, joissa on suuret määrät dataa, mutta sovelluksen kehittämiseen pienellä määrällä dataa se on käypä ratkaisu.

Jotta datasta olisi jotain hyötyä, tulee sitä pystyä hakemaan ja manipuloimaan. Tähän tarkoitukseen käytämme jo mainittua Sequelize-kirjastoa, joka on nk. ORM (Object-relational mapping)-kirjasto, jonka avulla voimme käyttää tietokantaamme sovelluksessamme helppokäyttöisen rajapinnan kautta ilman tietoa käytössä olevasta tietokantateknologiasta. Hienoa tässä on se, että voimme vaihtaa tietokantamme SQLite-tietokannasta mihin tahansa Sequelize:n tukemaan tietokantaan, kuten PostgreSQLään ilman suurempia muutoksia sovelluksessamme. Ilman ORM:n käyttöä tämä ei onnistuisi, sillä eri tietokantamoottorien hakukielten syntaksi -- vaikka ne ovat tyypillisesti SQL-pohjaisia -- vaihtelee usein paljon.

Otetaan muutama käytännön esimerkki, jotta saamme paremman kuvan, mistä oikein tässä mallissa on kyse. Kuvitellaan, että käytössäme on kirja-malli, jolla on nimi (name), julkaisuvuosi (year) ja julkaisija (publisher). Malli kuvaillaan Sequelize:lle seuraavasti:

var Book = sequelize.define('Book', {
  id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true },
  name: Sequelize.STRING,
  year: Sequelize.INTEGER,
  publisher: Sequelize.STRING
});

name-attribuutti on tyypiltään merkkijono (string), year-attribuutti kokonaisluku (integer) ja publisher-attribuutti merkkijono (string). Lisäämme myös kirjalle id-attribuutin, jotta kirjat voi erottaa toisistaan. Voimme lisätä tietokantaamme uuden kirjan kutsumalla create-funktiota seuraavasti:

Book.create({
  name: 'Fahrenheit 451',
  year: 1966,
  publisher: 'Kirjayhtymä'
}).then(function(book){
  console.log(book.name + ' on lisätty tietokantaan onnistuneesti!');
});

create-funktio ottaa parametrikseen lisättävän objektin. Huomaa, että id-attribuuttia ei tarvitse asettaa, koska se generoituu automaattisesti. Kaikki Sequelize:n tietokantaan suorittamat operaatiot ovat asynkronisia, joten operaation päätteeksi tulee kutsua then-funktiota, jonka parametrina saatua funktiota kutsutaan, kun operaatio on suoritettu. Katsotaan vielä, miten voimme hakea dataa tietokannasta:

// Haetaan kaikki tietokannasta löytyvät kirjat
Book.findAll().then(function(books){
  console.log(books);
});

// Haetaan kirjat, jotka on julkaistu vuonna 1966
Book.findAll({ where: { year: 1966 } }).then(function(books){
  console.log(books);
});

// Haetaan kirja tietyllä id-attribuutilla. Tässä esimerkissä id:llä 1
Book.findOne(1).then(function(book){
  console.log(book);
});

// Haetaan kirja, jonka nimi on "Fahrenheit 451"
Book.findOne({ where: { name: 'Fahrenheit 451' } }).then(function(book){
  console.log(book);
});

Tietokannasta voidaan hakea tietyn mallin objekteja eri funktioiden avulla. Tässä esimerkissä käytämme funktioita findAll, joka palauttaa taulukon mallin objekteja ja findOne, joka palauttaa yksittäisen objektin. Funktioista molemmat ovat asynkronisia, kuten muutkin tietokannan manipulointiin ja hakuun liittyvät funktiot. Molempiin funktioista voi antaa parametrina objektin, joka kertoo, minkälaisia attribuutteja haettavilla objekteilla tulee olla. findOne-funktion kohdalla parametrin ei tarvitse olla objekti, vaan se voi olla kokonaisluku, joka kertoo, mikä on haettavan objektin id-attribuutin arvo. Jokaisella objektilla on yksilöllinen id-attribuutin arvo, joten tietyllä id:llä löytyy aina yksi objekti (olettaen, että jollakin objektilla on parametrina annettu kokonaisluku id-attribuuttinaan). Molempiin funktioihin voi määrittää where objektin, joka määrittää ehtoja haettujen objektien attribuuteille. Voimme esimerkiksi hakea vain kirjat, joilla on tietty julkaisuvuosi.

Selaimen tekemien pyyntöjen käsittely

GET-pyyntöjen käsittely

Kuten, jo kävimme lyhyesti läpi, API:n toteuttaminen perustee siihen, että määrittelemme palvelinpuolen sovelluksessamme mitä tehdään, kun selain lähettää tietynlaisen pyynnön tiettyyn polkuun. Siirrytään Foorumi-kansiossa sijaitsevan routes-kansion topics.js-tiedostoon. Tiedosto sisältää valmiiksi määritellyt polut GET- ja POST-pyyntöjä varten. Kaikki polut alkavat polulla /topics, joten tiedostosta määritelty polku / viittaa polkuun /topics. Näin on määritelty Foorumi-kansion tiedostossa app.js kohdassa:

var topics = require('./routes/topics');
// ...
app.use('/topics', topics);

Tämä on tehty vain siitä syystä, että on kätevä jaotella sovelluksen polut eri tiedostoihin, koska niitä kertyy niin paljon. Yksittäisen reitin määrittäminen onnistuu kutsumalla, joko get- tai post-funktiota riippuen, kummanlaiseen pyyntöön halutaan vastata. Tarkastellaan topics.js-tiedoston ensimmäistä reittiä hieman tarkemmin:

router.get('/', function(req, res, next) {
    // Hae kaikki aihealueet tässä (Vinkki: findAll)
    res.send(200);
});

Kun selain tekee GET-pyynnön sovelluksemme polkuun /topics reitissä määriteltyä funktiota. Tällä hetkellä funktio ei tee muuta, kuin lähettää vastauksena statuskoodin 200 kutsumalla res-parametrin send-funktiota. Voisimme lähettää JSON-muotoisen taulukon kutsumalla json-funktiota seuraavasti:

router.get('/', function(req, res, next) {
    // Hae kaikki aihealueet tässä (Vinkki: findAll)
    res.json(['a', 'b', 'c']);
});

Reitin polkuun voi myös määritellä parametreja, jolloin voimme käsitellä pyynnöt sovelluksemme polkuihin /topics/1 ja /topics/99 yhden reitin määrittelemisellä:

router.get('/:id', function(req, res, next) {
  // Hae viesti tällä id:llä ja siihen liittyvät vastaukset tässä (Vinkki: findOne ja sopiva include)
  var messageId = req.params.id;
  res.send(200);
});

Olemme määritelleet polkuun parametrin id syntaksilla :id. Saamme polkuparametrin pyynnön parametreista id-kentästä (req.params.id.

POST-pyyntöjen käsittely

POST-pyynnön käsittely ei juurikaan eroa GET-pyynnön käsittelystä. Ainoa eroavaisuus on, että POST-pyynnön yhteydessä saamme selaimelta usein enemmän dataa. Tämä data voi olla esimerkiksi chattiin lisättävä viesti JSON-muotoisena. Selaimen lähettämään dataan pääsee käsiksi req-parametrin body-kentän kautta. topics.js-tiedostosta löytyy valmis runko reitille aihealueen lisäämiseksi:

router.post('/', function(req, res, next) {
  // Lisää tämä aihealue
  var topicToAdd = req.body;
  res.send(200);
});

Postman - API:n testaaminen

Postman on Chrome-selaimen plugini, jonka avulla voit helposti testata toteuttamaasi API:a. Sen kautta on helppo lähettää HTTP GET- ja POST-pyyntöjä haluamaasi osoitteeseen ja tarkastella palvelimen lähettämiä vastauksia. Asenna Postman ja siirry sen jälkeen Chrome-selaimella osoitteeseen chrome://apps/ ja klikkaa avautuvalta sivulta Postman-ikonia.

Kokeillaan hieman Postman-pluginia viikon 2 Chat-Chat tehtävän kanssa. Viestin haku onnistuu tehtävänannon mukaan seuraavasti: "Viestit saa haettua HTTP GET-pyynnöllä osoitteesta http://bad.herokuapp.com/app/messages. Jos viestien hakeminen onnistuu, palvelin palauttaa statuskoodin 200, muuten statuskoodi on jokin muu". Haetaan viestit Postmanilla lisäämällä "Enter request URL here"-kenttään osoite http://bad.herokuapp.com/app/messages ja valitsemalla viereisestä pudotusvalikosta "GET". Paina sen jälkeen sinistä "Send"-painiketta, jonka jälkeen palvelimelle lähetään GET-pyyntö. Painikkeen alapuolella sijaitsevaan "Body"-tabiin pitäisi ilmestyä palvelimelta saatu JSON-muotoinen vastaus ja "Headers"-tabissa palvelimelta saadun vastauksen otsakkeet. Älä murehdi, jos vastauksena tulee tyhjä taulukko ([]), se vain tarkoittaa, että chatissa ei ole viestejä.

Katsotaan vielä, miten POST-pyynnön lähettäminen onnistuu. Tehtävänannon mukaan chattiin täytyy kirjautua, ennen kuin sinne voi lähettää viestejä. Se onnistuu seuraavasti: "Kun käyttäjä kirjoittaa käyttäjätunnuksen ja painaa Login-nappia, selainsovellus lähettää palvelimelle JSON-merkkijonon, joka on muotoa { "nickname": nick }, missä nick on käyttäjän kirjoittama käyttäjätunnus. Kirjautumispyyntö tehdään HTTP POST-pyyntönä osoitteeseen http://bad.herokuapp.com/app/auth. Jos kirjautuminen onnistuu, palvelin palauttaa statuskoodin 200, muuten statuskoodi on jokin muu". Aloitetaan, kuten GET-pyynnön kanssa, lisätään "Enter request URL here"-kenttään osoite http://bad.herokuapp.com/app/auth, mutta valitaan viereisesti pudotusvalikosta "GET" sijaan "POST". Lisätään kyselyyn yksi otsake, jotta palvelin tietää, että olemme lähettämässä JSON-muotoista dataa. Se onnistuu klikkaamalla "Headers"-painiketta ja lisäämällä "Header"-kenttään "Content-Type" ja "Value"-kenttään "application/json". Valitse sen jälkeen alapuolelta "raw"-tabi ja sen viereisestä pudotusvalikosta "JSON". Tämän jälkeen voimme lisätä alapuolella sijaitsevaan kenttään JSON-muotoisen objektin, joka sisältää nimimerkkisi. Lisään kenttään esimerkiksi { "nickname": "foo" } ja klikkaa "send"-painiketta. Palvelimelta pitäisi tulla vastauksena JSON-objekti ja statuskoodi 200.

API:n toteuttaminen: Aihealueet (2p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät githubista saatavaan Foorumi-pohjaan. Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Toteuta routes/topics.js-tiedosta määritellyt reitit routes.get('/', ...), routes.get('/:id', ...) ja routes.post('/', ...) niin, että aihealueita pystyy hakemaan ja lisäämään. Hae reiteistä ensimmäisessä kaikki aihealueet (vinkki: findAll) ja palauta ne JSON-muotoisina. Toisessa reitissä hae aihealue id:llä, joka on määritelty muuttujassa topicId (vinkki: findOne) ja palauta se JSON-muotoisena. Kolmannessa reitissä tallenna muuttujaan topicToAdd talletettu objekti (vinkki: create). Kun aihealue on lisätty, lähetä selaimelle vastauksena lisätty aihealue JSON-muotoisena. Älä välitä neljännestä reitistä, sitä ei tarvitse vielä toteuttaa.

Pääset käyttämään Sequelizen operaatioita topics.js-tiedostossa määritellyn Models-muuttujan kautta. Voit esimerkiksi hakea kaikki aihealueet seuraavasti:

Models.Topic.findAll().then(function(topics){
  console.log(topics)
});

Muista, että operaatiot ovat asynkronisia, joten lähetä selameille vastaus vasta then-funktion parametrina annetussa funktiossa! Muista myös käynnistää palvelin uudelleen, kun teet muutoksia. Voit sulkea palvelimen näppäilemällä Ctrl+c ja suorittamalla sen jälkeen node bin/www!

Toteuta yksi API:n toiminto kerrallaan ja testaa niistä jokaista Postmanin avulla lisäämällä ensin aihealue POST pyynnön osoitteeseen http://localhost:3000/topics, joka sisältää lisättävän aihealuun JSON-muotoisena (katso, miltä aihealue näyttää models/index.js-tiedostosta). Testaa sen jälkeen GET-pyyntöjä osoitteisiin http://localhost:3000/topics ja http://localhost:3000/topics/ID, jossa ID on tietokannassa olevan aihealueen id-attribuutin arvo.

API:n käyttäminen selainpuolen sovelluksessa

Toteutamme sovelluksemme selainpuolen tutulla ja turvallisella Angularilla. Jos kurkistat Foorumi-kansiossa sijaitsevaan public-kansioon, löydät sovellukseen valmiiksi asennetut riippuvuudet bower_components-kansiosta ja Angular-sovelluksen rungon app-kansiosta. Kaikki tarvitsemasi riippuvuudet pitäisi olla asennettuna, mutta voit vapaasti asentaa uusia riippuvuuksia suorittamalla terminaalissa Foorumi-kansion juuressa komennon bower install riippuvuus, tai lisäämällä ne manuaalisesti haluaamasi paikkaan public-kansion sisällä.

Sovelluksen reitit on määritelty app-kansion tiedostossa app.js. Tällä hetkellä reiteissä esitetään vain staattisia näkymiä, eikä niihin ole liitetty yhtäkään kontrolleria. Kansiossa app/views on valmiiksi toteutettuja, staattisia näkymätiedostoja, joita voit halutessasi muokata. Sivupohjan virkaa ajava tiedosto, johon mm. navigaatiopalkki kuuluu, löytyy Foorumi-kansion juuresta tiedostosta views/index.html (ei siis app/views-kansiosta!).

Toteuttamaamme APIa varten on tehty pohja palvelulle Api, joka löytyy kansiosta app/services/api.js. Lisäksi app/controllers löytyy pohjat neljälle kontrollerille, ShowMessageController-, ShowTopicController-, TopicsListController- ja UsersController-kontrollerille.

AJAX-pyyntöjen lähettäminen palvelimelle

Angular tarjoaa $http-palvelun, jonka avulla pystemme lähettämään pyyntöjä palvelimelle. GET-pyyntö onnistuu funktiolla get seuraavasti:

$http.get('/messages')
  .success(function(data, status, headers, config){
    console.log('Palvelin lähetti vastauksen!');
    console.log(data);
  })
  .error(function(data, status, headers, config){
    console.log('Jotain meni pieleen...');
  });

Pyyntö on siis asynkroninen, joten täytyy määrittää, mitä tehdään, kun pyyntö palvelimelta lopulta saapuu perille. Se onnistuu määrittämällä success-funktiolle funktioparametrin, jota kutsutaan, kun palvelimelta tulee myöntävä vastaus statuskoodilla 200. Kutsuttavan funktion ensimmäinen parametri sisältää palvelimelta saadun vastauksen sisällön. Voimme myös antaa error-funktiolle parametriksi funktion, jota kutsutaan, jos palvelimelta ei tule myöntävää vastausta, eli jotain menee pieleen. Se on kätevää, kun halutaan välittää käyttäjälle palvelimen antama virheilmoitus.

GET-pyyntöjen lisäksi voimme tietenkin lähettää POST-pyyntöjä, jossa välitämme pyynnön yhteydessä palvelimelle dataa. Se onnistuu yllätys, yllätys post-funktion avulla:

$http.post('/messages', { content: 'Wazzup guys?' })
  .success(function(data, status, headers, config){
    console.log('Palvelin lähetti vastauksen!');
    console.log(data);
  })
  .error(function(data, status, headers, config){
    console.log('Jotain meni pieleen...');
  });

Lisäämme siis post-funktiokutsun toiseksi parametriksi pyynnön yhteydessä palvelimelle lähetettävän datan. Lähetettävä data muutetaan automaattisesti JSON-muotoon, joten sitä ei tarvitse tässä sitä erikseen tehdä.

Aihealueet sovelluksessa (2p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät Foorumi-kansioon! Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Toteuta seuraavaksi sovellukseesi selainpuolen toiminnot, joiden avulla käyttäjä voi nähdä kaikki aihealueet, nähdä aihealueen tietyllä id:llä ja lisätä aihealueen. app/app.js-tiedostossa on määritelty seuraavat reitit, jotka ovat oleellisia toimintojen toteutuksessa:

FoorumApp.config(function($routeProvider){
  $routeProvider
    .when('/', {
      controller: 'TopicsListController',
      templateUrl: 'app/views/topics/index.html'
    })
    .when('/topics/:id', {
      controller: 'ShowTopicController',
      templateUrl: 'app/views/topics/show.html'
    })
  // ...
});

Kuten huomaat, sovelluksen etusivulla on staattinen lista aihealueista ja esimerkiksi polusta #/topics/1 löytää staattinen sivu aihealueen viesteistä.

Aloita toteuttamalla app/services/api.js-tiedostossa sijaitsevaan Api-palveluun funktiot getTopics, joka hakee kaikki aihealueet API:si polusta /topics, getTopic, joka hakee aihealueen annetulla id-parametrilla API:si polusta /topics/ID ja addTopic, joka lisää topic-parametrina annetun aihealueen tekemällä POST-pyynnön sovelluksesi polkuun /topics. Muistathan, että lisättävä aihealue on { name: 'aihealueen nimi', description: 'aihealueen kuvaus' }-muotoinen objekti. Muistin virkistämiseksi, AJAX-pyynnöt tehtiin Angularissa $http-palvelun kautta. Tässä pieni esimerkki sen käyttämisestä:

ChatApp.service('Api', function($http){
  // GET-pyyntö polkuun /messages
  this.getMessages = function(){
    return $http.get('/messages');
  }

  // POST-pyyntö polkuun messages. Toinen parametrin on pyynnön mukana välitettävä data
  this.addMessage = function(message){
    return $http.post('/messages', message);
  }
});

Muistathan, että sekä get-, että post-funktiot lähettävät AJAX-pyynnön palvelimelle asynkronisesti, joten niille tulee antaa parametrina funktio, jota kutsutaan, kun pyyntöön saadaan vastaus palvelimelta. Tämä onnistuu ketjuttamalla pyynnön perään success-funktion kutsu, jota voi käyttää palvelua käyttävässä kontrollerissa seuraavasti:

ChatApp.controller('ChatController', function($scope, Api){
  Api.getMessages().success(function(messages){
    $scope.messages = messages;
  });
});

Kun Api-palvelun funktiot on toteutettu, toteuta kontrolleri niin, että se tarjoaa aihealueiden listaamiseen ja lisäämiseen tarvitut toiminnallisuudet. Aihealueiden listaamiselle on toteutettu valmiiksi staattinen näkymä tiedostossa app/views/topics/index.html. Muokkaa näkymää niin, että se oikeasti listaa sovellukseen talletettuja aihealueita. Samaisen tiedoston alalaidassa on lomake, jonka kautta tulisi pystyä lisäämään uusi aihealue. Käytä tiedostossa app/controllers/topics_list_controller.js löytyvää TopicsListController-kontrolleria hakemaan Api-palvelun getTopics-funktiolla kaikki aihealueet ja listaa ne näkymässä app/views/topics/index.html niin, että listatussa aihealueessa on sen nimi ja kuvaus. Käytä myös samassa kontrollerissa Api-palvelun addTopic-funktiota lisäämään aihealue käyttäjän lomakkeeseen syöttämien tietojen perusteella. Kun käyttäjä on lisännyt aihealueen, ohjaa hänet polkuun /topic/ID, jossa ID on lisätyn aihealueen id-attribuutin arvo (vinkki: $location-palvelun path-funktio).

Toteuta vielä lopuksi tiedostosta app/controllers/show_topic_controller.js löytyvä ShowTopicController-kontrolleri niin, että se hakee id-polkuparametrin perusteella aihealueen käyttämällä Api-palvelun, getTopic-funktiota. Muistathan, että polkuparametrin arvon sai $routeParams-palvelun avulla seuraavasti:

FoorumApp.controller('ShowTopicController', function($scope, $routeParams, Api) {
  // id-polkuparametrin arvo
  console.log($routeParams.id);
});

Muokkaa app/views/topics/show.html-näkymää niin, että sivun otsikkona on aihealueen nimi ja sen alapuolella sen kuvaus. Muuta ei tarvitse vielä tässä tehtävässä muuttaa.

Mallien väliset relaatiot

Kuten ehkä edellisestä tehtävästä arvasit, seuraavaksi meidän pitäisi pystyä liittämään viestejä aihealueisiin. Tietokannan tasolla olemme sen jo tehneet, koska models/index.js-tiedostosta löytyvät seuraavat rivit:

Topic.hasMany(Message);
// ...
Message.belongsTo(Topic);

Tämän perusteella Sequelize tietää, että aihealueseen liittyy monta viestiä ja viesti liittyy yhteen aihealueseen. Seuraava vaihe olisi muokata API:mme /topics/ID-polusta saamaamme JSON-muotoisten objektia niin, että siitä löytyy aihealueen attribuuttien lisäksi siihen liittyvät viestit. Otetaan käyttöön jo tarkkailemamme esimerkki kirja-mallista, joka määriteltiin seuraavasti:

var Book = sequelize.define('Book', {
  id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true },
  name: Sequelize.STRING,
  year: Sequelize.INTEGER,
  publisher: Sequelize.STRING
});

Kirjalla on tietenkin kirjoittaja, joten määritellään se omana mallinaan:

var Author = sequelize.define('Author', {
  id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true },
  name: Sequelize.STRING,
  dateOfBirth: Sequelize.DATE
});

Author.hasMany(Book);
Book.belongsTo(Author);

Kirjaan liittyy nyt aina kirjailija ja kirjailijalla on monta kirjaa. Kuulostaa järkevältä. Voimme nyt hakea tietokannasta kirjailijan ja hänen kirjoittamansa kirjat yhtä aikaa lisäämällä findOne-funktion kutsuun hieman asetuksia:

Author.findOne({
  where: { name: 'John Ronald Reuel Tolkien' },
  include: { model: Book }
}).then(function(author){
  console.log(author.Books);
});

Riittää siis lisätä findOne-funktion kutsuun parametriksi objekti, joka sisältää include-kentän, jossa määritellään, minkä mallien objektit liitetään haettavaan objektiin. Ainoana vaatimuksena on, että mallien välillällä on määritelty relaatio, kuten belongsTo, tai hasMany. include-kentän arvo voi olla myös taulukko, jos mallilla on monta relaatiota. Funktiokutsun seurauksena Sequelize lisää haettavaan kirjailijaan Books-kentän, joka sisältää taulukkona kaikki haettavan kirjalijan kirjat. Relaatiota voi määrittää myös include-kenttään sisäkkäin, jolloin voimme hakea kirjailijan kirjat ja kirjoihin liittyvät julkaisijat:

Author.findOne({
  where: { name: 'John Ronald Reuel Tolkien' },
  include: {
    model: Book,
    include: {
      model: Publisher
    }
  }
}).then(function(author){
  console.log(author.Books);
});

include-objektin käyttö toimii täysin samalla tavalla findAll-funktion kanssa. Jos haluamme lisätä kirjailijalle kirjan, meidän täytyy määrittää lisättävään kirjaan, että se kuuluu tietylle kirjailijalle. Koska olemme määritelleet, että kirjaan liittyy kirjalija, löytyy kirja-objektista kenttä AuthorId, joka kertoo, mikä on kirjailija-objektin id-attribuutin arvo, joka liittyy tähän kyseiseen kirjaan. Jos kirjaan ei liity kirjalijaa, on sen arvo null. Voimme siis määrittää kirjalle kirjailijan määrittämällä lisättävään kirjaan AuthorId-attribuutin:

var bookToAdd = {
  name: 'The Hobbit or There and Back Again',
  year: 1937
};

bookToAdd.AuthorId = 1; // Viittaa kirjailijaan nimeltä "John Ronald Reuel Tolkien".

Book.create(bookToAdd).then(function(book){
  console.log(book)
});

Nyt kirja nimeltä "The Hobbit or There and Back Again" viittaa kirjailijaan, jonka id-attribuutin arvo on 1, joka voisi olla vaikkapa kirjailija nimeltä "John Ronald Reuel Tolkien". Kirjan tapauksessa kentän siihen liittyvän kirjalijan id-attribuutin arvo löytyy kentästä AuthorId. Sama periaate pätee kaikkiin malleihin, joihin liittyy jokin toinen malli, kentän nimi on aina MalliId, jossa Malli, on malliin liittyvän mallin nimi.

API:n toteuttaminen: Viestit ja niiden aihealueet (3p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät Foorumi-kansioon! Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Lisätään seuraavaksi viestit sovelluksemme API:in. Viesteille on määritelty tiedostossa routes/messages.js valmiiksi kaksi reittiä: router.get('/:id', ...) ja router.post('/:id/reply', ...). Kaikki tiedostossa määritellyt polut alkavat polulla /messages, jonka määrittely löytyy app.js-tiedostosta. Lisäksi viestin lisäämiselle on määritelty reitti router.post('/:id/message', ...) tiedostossa routes/topics. Tämä polku taas alkaa polulla topics, kuten muutkin aihealueisiin liittyvät polut.

Hae ensimmäisessä reitissä viesti, jonka id-attribuutin arvo on muuttujan messageId-arvo. Liitä hakuun lisäksi kaikki viestiin liittyvät vastaukset (Reply) (vinkki: findOne ja sopiva include). Muistathan, että sovelluksen mallit löytyvät Models-muuttujasta, joten voit hakea esimerkiksi kaikki viestit seuraavasti:

Models.Message.findAll().then(function(messages){
  console.log(messages);
});

Lisää toisessa reitissä viestille, jonka id-attribuutin arvo löytyy muuttujassa messageId vastaus, joka löytyy muuttujasta replyToAdd (vinkki: create ja lisää replyToAdd-objektiin kenttä MessageId, jonka arvo on messageId). Lähetä selaimelle vastauksena lisätty vastaus JSON-muotoisena.

Kolmannessa reitissä lisää aihealueelle, jonka id-attribuutin arvo löytyy muuttujasta topicId viesti, joka löytyy muuttujasta messageToAdd (vinkki: create ja lisää messageToAdd-objektiin kenttä TopicId, jonka arvo on topicId). Lähetä selaimelle vastauksena lisätty viesti JSON-muotoisena.

Muokkaa vielä lopuksi tiedostossa routes/topics.js määriteltyä reittiä router.get('/:id', ...). Niin, että se lisää haettuun aihealueeseen siihen liittyvät viestit (vinkki: lisää vain findOne-funktion parametriksi objekti, jossa on sopiva include-kenttä).

Varmista vielä Postmanilla, että toteuttamasi toiminnot toimivat seuraavasti:

  • Tee GET-pyyntö osoitteeseen http://localhost:3000/topics ja ota talteen jonkun aihealueen id-attribuutti JSON-muotoisesta taulukosta. Jos taulukko on tyhjä, lisää uusi aihealue tekemällä POST-pyyntö polkuun http://localhost:3000/topics, joka sisältää JSON-muotoisena lisättävän aihealueen (muoto on { "name": "aihealueen nimi", "description": "aihealueen kuvaus" }). Lisää sen jälkeen valitsemallesi aihealueelle viesti tekemällä POST-pyyntö polkuun http://localhost:3000/topics/ID/message (osoitteessa ID on valitsemasi aihealueen id-attribuutin arvo), joka sisältää lisättävän viestin JSON-muotoisena (muoto on { "title": "viestin otsikko", "content": "viestin sisältö" }). Vastauksena pitäisi tulla lisätty viesti JSON-muotoisena.
  • Tee GET-pyyntö osoitteeseen http://localhost:3000/topics/ID, jossa ID äskeisessä kohdassa valitsemasi aihealueen id-attribuutin arvo. Varmista, että vastauksena saadusta JSON-muotoisessa objektista löytyy Messages-kenttä, josta löytyy taulukko, jossa on vähintään yksi objekti, joka on edellisessä kohdassa lisäämäsi viesti. Tee myös GET-pyyntö osoitteeseen http://localhost:3000/messages/ID, jossa ID on kyseisen viestin id-attribuutin arvo. Vastauksena pitäisi tulla viesti JSON-muotoisena objektina. Objektissa pitäisi olla myös Replies-kenttä, jossa on luultavasti tyhjä taulukko.
  • Tee vielä lopuksi POST pyyntö osoitteeseen http://localhost:3000/messages/ID/reply (ID on äskeisessä kohdassa tarkkailemasi viestin id-attribuutin arvo), joka sisältää lisättävän vastauksen JSON-muotoisena (muoto on { "content": "vastauksen sisältö" }). Vastauksena pitäisi tulla lisätty vastaus JSON-muotoisena. Tee vielä uudestaan GET-pyyntö osoitteeseen http://localhost:3000/messages/ID, jossa ID on sama kuin POST-pyynnön kanssa. Varmista, että viestin JSON-muotoisesen objektin Replies-kentän taulukossa on nyt vähintään yksi objekti, joka sisältää äsken lisäämäsi vastauksen.

Viestit sovelluksessa (2p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät Foorumi-kansioon! Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Muokataan seuraavaksi sovellustamme API:in tekemiemme muutosten mukaiseksi. Aloitetaan muokkaamalla näkymää app/views/topics/show.html, joka näyttää yksittäisen aihealueen ja siihen liittyvät viestit. Muokkaa näkymää niin, että siinä listataan aihealueen viestit ja listan otsikko kertoo, kuinka monta viestiä aihealueessa on (muista selkeä suomen kieli, vinkki: ng-pluralize-direktiivi). Muistathan, että aihealueen viestit pitäisi olla Messages-kentässä haetusta aihealue-objektista. Sinun ei tarvitse vielä välittää viestin alalaidassa olevasta "Käyttäjätunnus tähän"-kohdasta, mutta lisää viestin lisäysaika. Sequelize lisää automaattisesti lisättyihin objekteihin kentän createdAt, joka kuvaa aikaa, jolloin objekti on lisätty. Käytä sitä esittämään viestin lisäysaika ja käytä pohjassa annettua formaattia (vinkki: date-filtteri). Jos joudut tekemään muutoksia kontrolleriin, löydät sen tiedostosta app/controllers/show_topic_controller.js.

Seuraavaksi käyttäjän pitäisi pystyä lisäämään aihealueeseen viesti. Aloitetaan toiminnon toteuttaminen toteuttamalla app/services/api.js-tiedostosta löytyvään Api-palveluun funktio addMessage, joka lähettää POST pyynnön API:si polkuun /topics/ID/message (jossa ID on topicId-parametrin arvo), joka sisältää parametrina saadun message objektin (vinkki: $http-palvelu). Muisthan, että lisättävä viesti on { title: 'viestin otsikko', content: 'viestin sisältö' }-muotoinen objekti. Katso tarvittaessa mallia toteuttamastasi addTopic-funktiosta. Kun palvelun funktio on toteutettu, muokkaa tiedostosta app/controllers/show_topic_controller.js löytyvää kontrolleria ja näkymää app/views/topics/show.html niin, että viestin lisäys onnistuu näkymän alalaidasta löytyvän lomakkeen kautta. Kun käyttäjä on lisännyt viestin, ohjaa hänet lisätyn viestin sivulle. Muistathan, että POST-pyyntö API:n polkuun /topics/ID/message palauttaa lisätyn viestin, jonka id-attribuutin perusteella voit ohjata käyttäjän polkuun /messages/ID (vinkki: $location-palvelun path-funktio).

Siirrytään seuraavaksi viestin sivulle, joka staattinen versio löytyy sovelluksen sivulta #/messages/1. Tämä reitti on määritelty tiedostossa app/app.js seuraavasti:

FoorumApp.config(function($routeProvider){
  $routeProvider
    // ...
    .when('/messages/:id', {
      controller: 'ShowMessageController',
      templateUrl: 'app/views/messages/show.html'
    })
    // ...
});

Polussa /messages/:id näytetään siis näkymä app/views/messages/show.html ja siihen liitetään kontrolleri ShowMessageController, joka löytyy tiedostosta app/controllers/show_message_controller.js. Huomaat, että näkymän otsikkona on viestin otsikko, jonka alapuolella on viestin sisältö. Sisällön alapuolelta taas löytyy otsikko, joka kertoo kuinka monta vastausta viestillä on ja sen alapuolelta löytyy listattuna kaikki vastaukset. Älä tässäkään näkymässä välitä kohdasta "Käyttäjätunnus tähän", palaamme siihen myöhemmin.

Jotta pystymme esittämään viestin ja siihen liittyvät vastaukset, täytyy ne ensin hakea API:ltamme. Toteutetaan siis Api-palveluun funktio getMessage, joka tekee GET-pyynnön API:n polkuun /messages/ID, jossa ID on funktion id-parametrin arvo. Voit käyttää toteuttamaasi funktiota ShowMessageController-kontrollerissa seuraavasti:

FoorumApp.controller('ShowMessageController', function($scope, $routeParams, $location, Api){
  Api.getMessage($routeParams.id).success(function(message){
    $scope.message = message;
  });
});

Saamme siis esitettävän viestin id-attribuutin polkuparametrista $routeParams-palvelun avulla. Nyt voimme esittää viestin ja siihen liittyvät vastaukset app/views/messages/show.html-näkymässä. Muisthan, että viestiin liittyvien vastausten pitäisi olla viesti-objektin Replies-kentässä.

Toteuta vielä lopuksi toiminto, jonka avulla käyttäjä voi lisätä viestiin vastaukseen. Tee se toteuttamalla Api-palveluun funktio addReply, joka lähettää POST-pyynnön sovelluksesi polkuun /messages/ID/reply (jossa ID on funktion messageId-parametrin arvo), joka sisältää reply-parametrin arvon. Muistathan, että vastaus on { content: 'vastauksen sisältö' }-muotoinen objekti. Tee sen jälkeen tarvittavat muutokset ShowMessageController-kontrolleriin ja app/views/messages/show.html-näkymään. Kun käyttäjä on lisännyt vastauksen, lisää vastaus viesti-objektin Messages-taulukkoon, niin se tulee näkyviin näkymässä.

Yksinkertainen autentikaation toteuttaminen

Emme halua, että kuka tahansa pääsee käyttämään sovelluksemme toimintoja, vaan haluamme rajata ne vain kirjautuneille käyttäjille. Olemme jo määritelleet käyttäjälle mallin models/index.js-tiedostossa. Lisäksi on määritelty, että käyttäjällä on monta viesti ja vastausta sekä, että viestiin ja vastaukseen liittyy käyttäjä:

// ...

var User = Database.sequelize.define('User', {
  id: { type: Database.DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
  username: Database.DataTypes.STRING,
  password: Database.DataTypes.STRING
});

// ...

User.hasMany(Message);
User.hasMany(Reply);

Message.belongsTo(User);
Reply.belongsTo(User);

//...

Käyttäjällä on siis attribuutit username (käyttäjätunnus) ja password (salasana). Toteutetaan seuraavaksi sovellukseemme erittäin yksinkertainen autentikaatio, joka katsoo, löytyykö tietokannastamme käyttäjää annetulla käyttäjätunnuksella ja salasanalla. Jos käyttäjä löytyy, autentikaatio onnistuu, muuten se epäonnistuu. Siirrytään tiedostoon routes/users.js, johon on määritelty seuraava reitti:

// POST /users/authenticate
router.post('/authenticate', function(req, res, next){
  // Tarkista käyttäjän kirjautuminen tässä. Tee se katsomalla, löytyykö käyttäjää annetulla käyttäjätunnuksella ja salasanalla (Vinkki: findOne ja sopiva where)
  var userToCheck = req.body;
  res.send(200);
});

Reitti siis vastaa POST-pyyntöihin polkuun /users/authenticate ja olettaa saavansa pyynnön yhteydessä objektin, joka sisältää kentät username ja password, jotka vastaavat käyttäjätietoja, joilla käyttäjä yrittää authentikoitua. Toteutetaan reitti niin, että jos käyttäjä löytyy annetulla käyttäjätunnuksella ja salasanalla, lähetämmä vastauksena JSON-muotoisen objektin löytyneestä käyttäjästä, muuten lähetämme statuskoodin 403 (forbidden), joka kertoo vastauksen lähettäjälle, että authentikaatio epäonnistui. Voimme käyttää toteutukseen tuttua findOne-funktiota, johon lisäämme yksinkertaisen where-objektin:

// POST /users/authenticate
router.post('/authenticate', function(req, res, next){
  var userToCheck = req.body;

  if(userToCheck == null || userToCheck.username == null || userToCheck.password == null){
    res.send(403);
  }

  Models.User.findOne({
    where: {
      username: userToCheck.username,
      password: userToCheck.password
    }
  }).then(function(user){
    if(user){
      req.session.userId = user.id;
      res.json(user)
    }else{
      res.send(403);
    }
  });
});

Authentikaatiosta ei ole hyötyä, jos emme sen yhteydessä tallenna tietoa, kuka käyttäjä on authentikoitunut jollain tavalla. Tallennammekin tiedon kirjautuneesta käyttäjästä sessioon rivillä req.session.userId = user.id;. Session on palvelimelle tallennettu tieto, joka liittyy tiettyyn palvelimelle pyyntöjä lähettävään asiakaskoneeseen. Toisin sanoen, kun authentikoidun onnistuneesti jonkun käyttäjätunnuksen ja salasanan avulla, palvelimelle tallennetaan sessioon tieto, että käyttämäni asiakaskone olen authentikoitunut käyttäjällä, jonka id-attribuutin arvo on tallettu session avaimeen userId. Tämä tieto ei kuitenkaan säily palvelimella ikuisesti, mutta tarpeeksi kauan, jotta käyttäjä voi käyttää sovellusta ilman, että hänen täytyy authentikoitua jatkuvasti uudelleen.

Toteuteen vielä funktio, joka tarkastaa, onko käyttäjä kirjautunut sisään, eli toisin sanoen tarkastaa, onko req.session.userId-asetettu. Toteutetaan funktio tiedostoon utils/authentication.js-seuraavasti:

var authentication = function(req, res, next) {
  if (!req.session.userId || req.session.userId == null) {
    res.send(403);
  } else {
    next();
  }
}

// ...

Funktio siis tarkistaa, että session userId-avain on asetettu. Jos avainta ei ole asetettu, käyttäjä ei ole authentikoitunut ja lähetämme vastauksena statuskoodin 403 (forbidden). Muuten käyttäjä on authentikoitunut ja kutsumme mystistä next-funktiota.

Kuten ehkä huomaat, funktio näyttää erittäin paljon reiteissä määriteltyjä funktioita, jotka käsittelevät pyyntöjä. Se johtuu yksinkertaisesti siitä, että tätäkin funktiota käytetään pyynnön käsittelyyn, sillä tarkastelemme siinä pyyntöön liittyvää sessiota. Toteuttamamme funktio on Express:in nk. middleware-funktio, joita voi määrittää reitteihin ennen itse pyyntöä käsittelevää funktiota. else-haarassa kutsuttava next-funktio tarkoittaa vaan sitä, että voimme siirtyä seuraavaan middleware-funktioon. Käytetään seuraavaksi toteuttamaamme middleware-funktiota aihealueen lisäyksen yhteydessä. Sille löytyi reitti tiedostossa routes/topics.js:

// POST /topics
router.post('/', authentication, function(req, res, next) {
  // ...
});

Toteuttamme funktio on tallennettu muuttujaan authentication (näet sen tiedoston ensimmäisillä riveillä) ja se on nyt lisätty post-funktiokutsun toiseksi parametriksi ennen pyynnön lopullista käsittelijää. Nyt POST-pyyntö polkuun /topics etenee niin, että ensin kutsutaan authentication-funktiota. Jos käyttäjä ei ole authentikoitunut, pyyntöön vastataan statuskoodilla 403, joten lopulliseen käsittelijään ei siirrytä ollenkaan. Jos käyttäjä taas on authentikoitunut, kutsumme funktiota next, jolloin siirrymme lopulliseen käsittelijäämme, jossa lisäämme annetun aihealueen. Lisää sama middleware-funktio myös reitteihin, jotka lisäävät viestin ja vastauksen, niin käyttäjä ei pääse lisäämään niitä ilman authentikoitumista.

Tällä hetkellä selainpuolella on hieman ikävää, että käyttäjä näkee aihealueen, viestin ja vastauksen lisäyslomakkeen, mutta lisääminen niiden kautta ei onnistu, koska palvelin vastaa pyyntöihin statuskoodilla 403. Ne pitäisi siis kokonaan piilottaa, jos käyttäjä ei ole kirjautunut sisään. Käyttäjä ei pysty myöskään kirjautumaan sisään ja sen jälkeen kirjautumaan ulos, eikä rekisteröitymään. Seuraavissa tehtävissä puutumme näihin ongelmiin.

API:n toteuttaminen: Käyttäjät (3p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät Foorumi-kansioon! Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Jatketaan API:mme toteuttamista käyttäjien osalta. Varmista aluksi, että olet toteuttanut äskeisessä osiossa toteutetun käyttäjän authentikoitumisen käsittelevän reitin tiedostossa routes/users.js ja authentikoitumisen tarkistavan funktion tiedostossa utils/authentication.js. Varmista myös, että olet lisännyt authentication-funktion aihealueen, viestin ja vastauksen lisäämisen käsittelevään reittiin middleware-funktioksi.

Toteutaan seuraavaksi käyttäjän rekisteröityminen, eli uuden käyttäjän lisääminen. Sille löytyy reitti routes.post('/', ...) valmiina tiedostosta routes/users.js. Ennen kuin lisäät käyttäjän vanhalla tutulla create-funktiolla, tarkista ennen, ettei käyttäjätunnusta ole varattu findOne-funktion ja sopivan where-objektin avulla. Muistathan, että pääset käyttämään User-mallia Models-muuttujan kautta seuraavasti:

Models.User.findOne({
  where: { username: 'foo' }
}).then(function(user){
  console.log(user);
});

Kun käyttäjä on lisätty, lähettä vastauksena lisätty käyttäjä JSON-muotoisena. Jos käyttäjätunnus oli jo olemassa lähetä vastauksena statuskoodilla 400 (bad input) { error: 'Käyttäjätunnus on jo käytössä!' }-objekti JSON-muotoisena. Voit liittää vastaukseen erikseen statuskoodin seuraavasti:

res.status(400).json({ error: 'Käyttäjätunnus on jo käytössä!' });

Kuten huomaat, salasana on tällä hetkellä selkokielisenä muodossa, joka on tietoturvan kannalta erittäin huono asia. Tietoturva ei ole kuitenkaan tämän kurssin kannalta olennaista, mutta jos asia kiinnostaa sinua, voi salasanat esittää hajakoodatussa muodossa esimerkiksi tämän kirjaston avulla.

Kun käyttäjä lisääminen on toteutettu, hae kirjautunut käyttäjä reitissä router.get('/logged-in', ...). Siihen soveltuu findOne-funktio, joka ottaa parametrikseen muuttujan loggedInId. Lähetä vastauksena kirjautunut käyttäjä JSON-muodossa.

Muokataan vielä tiedostossa routes/topics.js-olevaa reittiä router.get('/:id', ...) niin, että se liittää aihealueen viesteihin niiden käyttäjät. Tee sama reitille router.get('/:id', ...) tiedostossa routes/messages.js, jossa liitä viestin vastauksiin niiden käyttäjät. Muistathan, että include-objekteja pystyi määrittämään sisäkkäin seuraavasti:

Models.Topic.findOne({
  where: {
    id: 1
  },
  include: {
    model: Models.Message,
    include: {
      model: Model.User
    }
  }
}).then(function(topic){
  console.log(topic);
});

Liitetään lopuksi lisättävään viestiin ja aihealueeseen käyttäjä. Se onnistuu yksinkertaisesti lisäämällä messageToAdd-objektiin kentät TopicId ja UserId siten, että niiden arvot viittaavat liitettävien objektien id-attribuuttien arvoihin seuraavasti:

// ...
var messageToAdd = req.body;
messageToAdd.TopicId = topicId;
messageToAdd.UserId = req.session.userId;

Models.Message.create(messageToAdd).then(function(message){
  console.log(message);
});

Vastaavasti vastauksen yhteydessä lisää MessageId-kentän arvoksi muuttujan messageId arvo.

Käyttäjät sovelluksessa (4p)

Älä koske tämän viikon tehtävien tehtäväpohjiin, vaan toteuta kaikki tehtävät Foorumi-kansioon! Kun tehtävä on valmis, lähetä vain tehtäväpohja TMC:llä.

Sovelluksemme on enään käyttäjien selainpuolen toteutusta vaille valmis. Käyttäjille on määritelty sovellukseemme seuraavat reitit tiedostossa app/app.js:

FoorumApp.config(function($routeProvider){
  $routeProvider
    // ...
    .when('/login', {
      controller: 'UsersController',
      templateUrl: 'app/views/users/login.html'
    })
    .when('/register', {
      controller: 'UsersController',
      templateUrl: 'app/views/users/register.html'
    })
    // ...
});

Kirjautumissivu löytyy polusta /login, joka esittää näkymän, joka löytyy tiedostosta app/views/users/login.html.Rekisteröitymissivu löytyy taas polusta /register, joka esittää näkymän, joka löytyy tiedostosta app/views/users/register.html. Molemmissa reiteissä on käytössä UsersController-kontrolleri, jonka pohja löytyy tiedostosta app/controllers/users_controller.js.

Aloita kirjautumisen ja rekisteröitymisen toteuttaminen lisäämällä tiedostosta app/services/api.js löytyvään Api-palveluun funktiot login ja register (vinkki: $http-palvelu). Muisthan, että käyttäjä on { username: 'käyttäjätunnus', password: 'salasana' }-muotoinen objekti. Muista lisäksi, että virheellisen kirjautumisen yhteydessä palvelin lähettää statuskoodin 403, joten voimme käsitellä epäonnistuneen kirjautumisen kontrollerissamme error-funktiolla:

Api.login({ username: 'foo', password: 'bar'})
  .success(function(user){
    console.log('Kirjautuminen onnistui!');
    console.log(user);
  })
  .error(function(){
    console.log('Kirjautuminen epäonnistui! Lisätään käyttäjälle virheilmoitus');
    $scope.errorMessage = 'Väärä käyttäjätunnus tai salasana!';
  });

Näytä epäonnistuneen kirjautumisen yhteydessä käyttäjälle kirjautumislomakkeessa jokin virheilmoitus, jos kirjautuminen onnistuu, ohjaa käyttäjä aihealueet listaavalla sivulle, eli polkuun / (vinkki: $location-palvelun path-funktio).

Kun kirjautuminen on toteutettu, toteuta rekisteröityminen. Muista, että jos käyttäjä yrittää rekisteröityä käyttäjätunnuksella, joka on jo käytössä, tulee palvelimelta vastauksena statuskoodi 400, jonka mukana tulee objekti { error: 'Käyttäjätunnus on jo käytössä!' }. Voit huomata tämän käyttämällä kyselyn kanssa error-funktiota, kuten kirjautumisenkin kanssa ja näyttää käyttäjälle virheilmoituksen. Jos rekisteröityminen onnistuu, ohjaa käyttäjä aihealueet listaavalla sivulle, eli polkuun /, kuten kirjautumisenkin kanssa.

Kun sekä kirjautuminen, että rekisteröityminen on toteutettu, muokkaa näkymiä app/views/topics/show.html, joka listaa viestit ja näkymää app/views/messages/show.html, joka näyttää aihealueen vastaukset niin, että kohdassa "Käyttäjätunnus tähän" on viestin tai vastauksen lisännyt käyttäjä. Muistathan, että käyttäjä-objekti pitäisi löytyä kentästä User.

Viimeistellään sovelluksemme vielä niin, että käyttäjä voi kirjautua ulos, eikä käyttäjä näe aihealueen, viestin tai vastauksen lisäämislomaketta ilman, että hän on kirjautunut sisään. Jos katsot tiedostoa app/app.js, näet, että tiedoston lopusta löytyy koodipätkä, joka lisää funktion logOut sovelluksemme globaaliin näkyvyysalueeseen, eli $rootScope-objektiin. Jos menet tiedostoon views/index.html, joka on sivupohjamme, huomaat, että navigaatiossa on linkki "Kirjaudu ulos", jota painattaessa funktiota logOut kutsutaan. Linkki ei tosin näytetä, koska se näytetään vain, jos userLoggedIn-muuttujalla on arvo, jota sillä ei vielä ole.

Toteuta Api-palveluun vielä funktio getUserLoggedIn, jonka jälkeen voimme lisätä reitteihimme resolve-objektin, joka hakee kirjautuneen käyttäjän ennen kuin reitti ladataan. Lisätään siihen funktio, joka hakee käyttäjän, joka on kirjautunut sisään ja lisää sen $rootScope-objektiin. Käytännössä se onnistuisi näin:

FoorumApp.config(function($routeProvider){
  $routeProvider
    .when('/', {
      controller: 'TopicsListController',
      templateUrl: 'app/views/topics/index.html',
      resolve: {
        userLoggedIn: function($rootScope, Api){
          return Api.getUserLoggedIn().success(function(user){
            $rootScope.userLoggedIn = user.username ? user : null;
          });
        }
      }
    })
    // ...

Lisää tämä resolve objekti jokaiseen reittiin, niin kirjautunut käyttäjä tarkastetaan jokaisessa reitissä ennen kuin se ladataan. Nyt voimme lisätä näkymiin app/views/topics/show.html ja app/views/messages/show.html ja ng-if-direktiivit, jotka näyttävät lisäyslomakkeet vain, jos userLoggedIn-muuttujalla on arvo. Lisäksi kirjautumisen jälkeen navigaation pitäisi ilmestyä "Kirjaudu ulos"-linkki, jota klikkaamalla käyttäjä voi kirjautua ulos.

Node.js-sovellus Herokuun

Lisätään seuraavaksi toteuttamme Foorumi-sovellus Herokuun. Se onnistuu lähes yhtä suoraviivaisesti, kuin viime viikolla Herokuun lisäämämme Elokuvakirjasto-sovellus. Tällä kertaa emme tosin käytä Firebasea, vaan tarvitsemme Herokulta tietokannan. Kun kehitimme sovellustamme omalla koneellamme käytössämme oli SQLite-tietokanta ja siitä mainittiikin, ettei se ole hyvä vaihtoehto tuotantoympäristössä, kuten Herokussa. Heroku tarjoaa onneksi meille PostgreSQL-tietokannan, johon vaihtaminen ei vaikuta sovellukseemme juuri ollenkaan.

Aloitetaan luomalla uusi Heroku-sovellus suorittamalla Foorumi-kansion juuressa komento heroku create. Jos et ole vielä asentanut Heroku Toolbelt:iä, lue viime viikon ohje "Sovellus muiden nähtäville: Heroku". Kun komento on suoritettu, pyydetään Herokulta käyttöömme PostgreSQL-tietokantaan suorittamalla Foorumi-kansion juuressa komento heroku addons:add heroku-postgresql:dev. Terminaaliin pitäisi ilmestyä tätä muistuttava teksti:

Adding heroku-postgresql:dev on fast-dusk-7858... done, v5 (free)
Attached as HEROKU_POSTGRESQL_BRONZE_URL
Database has been created and is available
 ! This database is empty. If upgrading, you can transfer
 ! data from another database with pgbackups:restore.
Use `heroku addons:docs heroku-postgresql:dev` to view documentation.

Tässä tapauksessa PostgreSQL-tietokannan URL löytyy konfiguraatiomuuttujasta HEROKU_POSTGRESQL_BRONZE_URL, mutta sen nimi vaihtelee, joten tarkasta, minkä niminen se on omassa tapauksessasi. Suorita sen jälkeen komento heroku config:get HEROKU_POSTGRESQL_BRONZE_URL, jossa HEROKU_POSTGRESQL_BRONZE_URL on oman konfiguraatiomuuttujasti nimi. Terminaaliin pitäisi ilmestyä tätä muistuttava teksti:

postgres://pfforbjhkrletg:aic5oO6Cran1g3hk6mJa5QqNZB@ec2-23-21-91-97.compute-1.amazonaws.com:5432/dek11b2j1g3mfb

Tämä on PostgreSQL-tietokantasi URL, jonka kautta siihen saa muodostettua yhteyden. Kopioi se talteen, tarvitsemme sitä pian. Seuraavaksi meidän täytyy kertoa Sequelize:lle, että käytössämme ei ole enää SQLite-tietokanta, vaan PostgeSQL-tietokanta. Se onnistuu siirtymällä tiedostoon db/connection.js ja korvaamalla muuttujan sequelize-arvo seuraavasti:

var sequelize = new Sequelize('postgres://gtoisioduequfh:MST5G0EEMFkbWWQMrUqsudkxLa@ec2-54-163-226-9.compute-1.amazonaws.com:5432/d99fquooctquiv', {
  dialect: 'postgres',
  protocol: 'postgres'
});

Korvaa vain ensimmäinen parametri oman PostgreSQL-tietokannan URL:illasi.

Suorita seuraavaksi Foorumi-kansion juuressa komennot git add -A, git commit -m "Sovellus Herokuun" ja git push heroku master (juuri tässä järjestyksessä!). Heroku alkaa valmistelemaan sovellustasi. Kun se on valmis suorita lopuksi komento heroku run node db/seed.js, joka alustaa tietokannan ja avaa sovelluksesi selaimessasi suorittamalla heroku open. Se on siinä!

Älä pelästy siitä, että sivuilla ei ole mitään sisältöä. Se johtuu yksinkertaisesti siitä, että tietokantasi on vaihtunut ja se on toistaiseksi tyhjä.

 

Huom! Koeasiaa pisteytys-kohdassa.