Osan 5 oppimistavoitteet

  • React
    • child
    • ref
    • PropTypes
  • Frontendin testauksen alkeet
    • enzyme
    • shallow ja full DOM -rendering
  • Redux
    • Flux-pattern
    • Storage, reducerit, actionit
    • reducereiden testaus, deep-freeze
  • React+redux
    • Storagen välittäminen komponenteille propseilla ja kontekstissa
  • Javascript
    • computed property name
    • Spread-operaatio
    • Reduxin edellyttämästä funktionaalisesta ohjelmoinnista
      • puhtaat funktiot
      • immutable

Kirjautuminen React-sovelluksesta

Kaksi edellistä osaa keskittyivät lähinnä backendin toiminnallisuuteen. Edellisessä osassa backendiin toteutettua käyttäjänhallintaa ei ole tällä hetkellä tuettuna frontendissa millään tavalla.

Frontend näyttää tällä hetkellä olemassaolevat muistiinpanot ja antaa muuttaa niiden tilaa. Uusia muistiinpanoja ei kuitenkaan voi lisätä, sillä osan 4 muutosten myötä backend edellyttää, että lisäyksen mukana on käyttäjän identiteetin varmistava token.

Toteutetaan nyt osa käyttäjienhallinnan edellyttämästä toiminnallisuudesta frontendiin. Aloitetaan käyttäjän kirjautumisesta. Oletetaan vielä tässä osassa, että käyttäjät luodaan suoraan backendiin.

Sovelluksen yläosaan on nyt lisätty kirjautumislomake, myös uuden muistiinpanon lisäämisestä huolehtiva lomake on siirretty sivun yläosaan:

Komponentin App koodi näyttää seuraavalta:

import React from 'react'
import noteService from './services/notes'

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      notes: [],
      newNote: '',
      showAll: true,
      error: null,
      username: '',
      password: '',
      user: null
    }
  }

  componentDidMount() {
    noteService.getAll().then(notes =>
      this.setState({ notes })
    )
  }

  addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: this.state.newNote,
      date: new Date(),
      important: Math.random() > 0.5
    }

    noteService
      .create(noteObject)
      .then(newNote => {
        this.setState({
          notes: this.state.notes.concat(newNote),
          newNote: ''
        })
      })
  }

  toggleImportanceOf = (id) => {
    // ...
  }

  login = (event) => {
    event.preventDefault()
    console.log('login in with', this.state.username, this.state.password)
  }

  handleNoteChange = (event) => {
    this.setState({ newNote: event.target.value })
  }

  handlePasswordChange = (event) => {
    this.setState({ password: event.target.value })
  }

  handleUsernameChange = (event) => {
    this.setState({ username: event.target.value })
  }

  toggleVisible = () => {
    this.setState({ showAll: !this.state.showAll })
  }

  render() {
    // ...

    return (
      <div>
        <h1>Muistiinpanot</h1>

        <Notification message={this.state.error} />

        <h2>Kirjaudu</h2>

        <form onSubmit={this.login}>
          <div>
            käyttäjätunnus
            <input
              type="text"
              value={this.state.username}
              onChange={this.handleUsernameChange}
            />
          </div>
          <div>
            salasana
            <input
              type="password"
              value={this.state.password}
              onChange={this.handlePasswordChange}
            />
          </div>
          <button type="submit">kirjaudu</button>
        </form>

        <h2>Luo uusi muistiinpano</h2>

        <form onSubmit={this.addNote}>
          <input
            value={this.state.newNote}
            onChange={this.handleNoteChange}
          />
          <button type="submit">tallenna</button>
        </form>

        <h2>Muistiinpanot</h2>

        // ...

      </div >
    )
  }
}

export default App

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-1.

Kirjautumislomakkeen käsittely noudattaa samaa periaatetta kuin osassa 2. Lomakkeen kenttiä varten on lisätty komponentin tilaan kentät username ja password. Molemmille kentille on rekisteröity muutoksenkäsittelijä (handleUsernameChange ja handlePasswordChange), joka synkronoi kenttään tehdyt muutokset komponentin App tilaan. Kirjautumislomakkeen lähettämisestä vastaava metodi login ei tee vielä mitään.

Jos lomakkeella on paljon kenttiä, voi olla työlästä toteuttaa jokaiselle kentälle oma muutoksenkäsittelijä. React tarjoaakin tapoja, miten yhden muutoksenkäsittelijän avulla on mahdollista huolehtia useista syötekentistä. Jaetun käsittelijän on saatava jollain tavalla tieto minkä syötekentän muutos aiheutti tapahtuman. Eräs tapa tähän on lomakkeen syötekenttien nimeäminen.

Lisätään input elementteihin nimet name-attribuutteina ja vaihdetaan molemmat käyttämään samaa muutoksenkäsittelijää:

<form onSubmit={this.login}>
  <div>
    käyttäjätunnus
    <input
      type="text"
      name="username"
      value={this.state.username}
      onChange={this.handleLoginFieldChange}
    />
  </div>
  <div>
    salasana
    <input
      type="password"
      name="password"
      value={this.state.password}
      onChange={this.handleLoginFieldChange}
    />
  </div>
  <button type="submit">kirjaudu</button>
</form>

Yhteinen muutoksista huolehtiva tapahtumankäsittelijä on seuraava:

handleLoginFieldChange = (event) => {
  if (event.target.name === 'password') {
    this.setState({ password: event.target.value })
  } else if (event.target.name === 'username') {
    this.setState({ username: event.target.value })
  }
}

Tapahtumankäsittelijän parametrina olevan tapahtumaolion event kentän target.name arvona on tapahtuman aiheuttaneen komponentin name-attribuutti, eli joko username tai password. Koodi haarautuu nimen perusteella ja asettaa tilaan oikean kentän arvon.

Javascriptissa on ES6:n myötä uusi syntaksi computed property name, jonka avulla olion kentän voi määritellä muuttujan avulla. Esim. seuraava koodi

const field = 'name'

const object = { [field] : 'Arto Hellas' }

määrittelee olion { name: 'Arto Hellas'}

Näin saamme eliminoitua if-lauseen tapahtumankäsittelijästä ja se pelkistyy yhden rivin mittaiseksi:

handleLoginFieldChange = (event) => {
  this.setState({ [event.target.name]: event.target.value })
}

Kirjautuminen tapahtuu tekemällä HTTP POST -pyyntö palvelimen osoitteeseen api/login. Eristetään pyynnön tekevä koodi omaan moduuliin, tiedostoon services/login.js.

Käytetään nyt promisejen sijaan async/await-syntaksia HTTP-pyynnön tekemiseen:

import axios from 'axios'
const baseUrl = '/api/login'

const login = async (credentials) => {
  const response = await axios.post(baseUrl, credentials)
  return response.data
}

export default { login }

Kirjautumisen käsittelystä huolehtiva metodi voidaan toteuttaa seuraavasti:

login = async (event) => {
  event.preventDefault()
  try{
    const user = await loginService.login({
      username: this.state.username,
      password: this.state.password
    })

    this.setState({ username: '', password: '', user})
  } catch(exception) {
    this.setState({
      error: 'käyttäjätunnus tai salasana virheellinen',
    })
    setTimeout(() => {
      this.setState({ error: null })
    }, 5000)
  }
}

Kirjautumisen onnistuessa nollataan kirjautumislomakkeen kentät ja talletetaan palvelimen vastaus (joka sisältää tokenin sekä kirjautuneen käyttäjän tiedot) sovelluksen tilan kenttään user.

Jos kirjautuminen epäonnistuu, eli metodin loginService.login suoritus aiheuttaa poikkeuksen, ilmoitetaan siitä käyttäjälle.

Onnistunut kirjautuminen ei nyt näy sovelluksen käyttäjälle mitenkään. Muokataan sovellusta vielä siten, että kirjautumislomake näkyy vain jos käyttäjä ei ole kirjautuneena eli this.state.user === null ja uuden muistiinpanon luomislomake vain jos käyttäjä on kirjautuneena, eli (eli this.state.user sisältää kirjautuneen käyttäjän tiedot.

Määritellään ensin komponentin App metodiin render apufunktiot lomakkeiden generointia varten:

const loginForm = () => (
  <div>
    <h2>Kirjaudu</h2>

    <form onSubmit={this.login}>
      <div>
        käyttäjätunnus
        <input
          type="text"
          name="username"
          value={this.state.username}
          onChange={this.handleLoginFieldChange}
        />
      </div>
      <div>
        salasana
        <input
          type="password"
          name="password"
          value={this.state.password}
          onChange={this.handleLoginFieldChange}
        />
      </div>
      <button type="submit">kirjaudu</button>
    </form>
  </div>
)

const noteForm = () => (
  <div>
    <h2>Luo uusi muistiinpano</h2>

    <form onSubmit={this.addNote}>
      <input
        value={this.state.newNote}
        onChange={this.handleNoteChange}
      />
      <button type="submit">tallenna</button>
    </form>
  </div>
)

ja renderöidään ne ehdollisesti komponentin App render-metodissa:

class App extends React.Component {
  // ..
  return (
    <div>
      <h1>Muistiinpanot</h1>

      <Notification message={this.state.error}/>

      {this.state.user === null && loginForm()}

      {this.state.user !== null && noteForm()}


      <h2>Muistiinpanot</h2>

      // ...

    </div>
  )
}

Lomakkeiden ehdolliseen renderöintiin käytetään hyväksi aluksi hieman erikoiselta näyttävää, mutta Reactin yhteydessä yleisesti käytettyä kikkaa:

{this.state.user === null && loginForm()}

Jos ensimmäinen osa evaluoituu epätodeksi eli on falsy, ei toista osaa eli lomakkeen generoivaa koodia suoriteta ollenkaan.

Voimme suoraviivaistaa edellistä vielä hieman käyttämällä kysymysmerkkioperaattoria:

return (
  <div>
    <h1>Muistiinpanot</h1>

    <Notification message={this.state.error}/>

    {this.state.user === null ?
      loginForm() :
      noteForm()
    }

    <h2>Muistiinpanot</h2>

    // ...

  </div>
)

Eli jos this.state.user === null on truthy, suoritetaan loginForm ja muussa tapauksessa noteForm.

Tehdään vielä sellainen muutos, että jos käyttäjä on kirjautunut, renderöidään kirjautuneet käyttäjän nimi:

return (
  <div>
    <h1>Muistiinpanot</h1>

    <Notification message={this.state.error}/>

    {this.state.user === null ?
      loginForm() :
      <div>
        <p>{this.state.user.name} logged in</p>
        {noteForm()}
      </div>
    }

    <h2>Muistiinpanot</h2>

    // ...

  </div>
)

Ratkaisu näyttää hieman rumalta, mutta jätämme sen koodiin toistaiseksi.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-2. HUOM koodissa on parissa kohtaa käytetty vahingossa komponentin kentästä nimeä new_note, oikea (seuraaviin tageihin korjattu) muoto on newNote,

Sovelluksemme pääkomponentti App on tällä hetkellä jo aivan liian laaja ja nyt tekemämme muutokset ovat ilmeinen signaali siitä, että lomakkeet olisi syytä refaktoroida omiksi komponenteikseen. Jätämme sen kuitenkin harjoitustehtäväksi.

Muistiinpanojen luominen

Frontend on siis tallettanut onnistuneen kirjautumisen yhteydessä backendilta saamansa tokenin sovelluksen tilaan this.state.user.token:

Korjataan uusien muistiinpanojen luominen siihen muotoon, mitä backend edellyttää, eli lisätään kirjautuneen käyttäjän token HTTP-pyynnön Authorization-headeriin.

noteService-moduuli muuttuu seuraavasti:

import axios from 'axios'
const baseUrl = '/api/notes'

let token = null

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const setToken = (newToken) => {
  token = `bearer ${newToken}`
}

const create = async (newObject) => {
  const config = {
    headers: { 'Authorization': token }
  }

  const response = await axios.post(baseUrl, newObject, config)
  return response.data
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update, setToken }

Moduulille on määritelty vain moduulin sisällä näkyvä muuttuja token, jolle voidaan asettaa arvo moduulin exporttaamalla funktiolla setToken. Async/await-syntaksiin muutettu create asettaa moduulin tallessa pitämän tokenin Authorization-headeriin, jonka se antaa axiosille metodin post kolmantena parametrina.

Kirjautumisesta huolehtivaa tapahtumankäsittelijää pitää vielä viilata sen verran, että se kutsuu metodia noteService.setToken(user.token) onnistuneen kirjautumisen yhteydessä:

login = async (event) => {
  event.preventDefault()
  try {
    const user = await loginService.login({
      username: this.state.username,
      password: this.state.password
    })

    noteService.setToken(user.token)
    this.setState({ username: '', password: '', user})
  } catch(exception) {
    // ...
  }
}

Uusien muistiinpanojen luominen onnistuu taas!

Tokenin tallettaminen selaimen local storageen

Sovelluksessamme on ikävä piirre: kun sivu uudelleenladataan, tieto käyttäjän kirjautumisesta katoaa. Tämä hidastaa melkoisesti myös sovelluskehitystä, esim. testatessamme uuden muistiinpanon luomista, joudumme joka kerta kirjautumaan järjestelmään.

Ongelma korjaantuu helposti tallettamalla kirjautumistiedot local storageen eli selaimessa olevaan avain-arvo- eli key-value-periaatteella toimivaan tietokantaan.

Local storage on erittäin helppokäyttöinen. Metodilla setItem talletetaan tiettyä avainta vastaava arvo, esim:

window.localStorage.setItem('nimi', 'juha tauriainen')

tallettaa avaimen nimi arvoksi toisena parametrina olevan merkkijonon.

Avaimen arvo selviää metodilla getItem:

window.localStorage.getItem('nimi')

ja removeItem poistaa avaimen.

Storageen talletetut arvot säilyvät vaikka sivu uudelleenladattaisiin. Storage on ns. origin-kohtainen, eli jokaisella selaimella käytettävällä web-sovelluksella on oma storagensa.

Laajennetaan sovellusta siten, että se asettaa kirjautuneen käyttäjän tiedot local storageen.

Koska storageen talletettavat arvot ovat merkkijonoja, emme voi tallettaa storageen suoraan Javascript-oliota, vaan ne on muutettava ensin JSON-muotoon metodilla JSON.stringify. Vastaavasti kun JSON-muotoinen olio luetaan local storagesta, on se parsittava takaisin Javascript-olioksi metodilla JSON.parse.

Kirjautumisen yhteyteen tehtävä muutos on seuraava:

login = async (event) => {
  event.preventDefault()

  try {
    const user = await loginService.login({
      username: this.state.username,
      password: this.state.password
    })

    window.localStorage.setItem('loggedNoteappUser', JSON.stringify(user))
    noteService.setToken(user.token)
    this.setState({ username: '', password: '', user})
  } catch(exception) {
    // ...
  }
}

Kirjautuneen käyttäjän tiedot tallentuvat nyt local storageen ja niitä voidaan tarkastella konsolista:

Sovellusta on vielä laajennettava siten, että kun sivulle tullaan uudelleen, esim. selaimen uudelleenlataamisen yhteydessä, tulee sovelluksen tarkistaa löytyykö local storagesta tiedot kirjautuneesta käyttäjästä. Jos löytyy, asetetaan ne sovelluksen tilaan ja noteServicelle.

Sopiva paikka tähän on App-komponentin metodi componentDidMount johon tutustuimme jo osassa 2.

Kyseessä on siis ns. lifecycle-metodi, jota React-kutsuu heti komponentin ensimmäisen renderöinnin jälkeen. Metodissa on tällä hetkellä jo muistiinpanot palvelimelta lataava koodi. Laajennetaan koodia seuraavasti

componentDidMount() {
  noteService.getAll().then(notes =>
    this.setState({ notes })
  )

  const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser')
  if (loggedUserJSON) {
    const user = JSON.parse(loggedUserJSON)
    this.setState({user})
    noteService.setToken(user.token)
  }
}

Nyt käyttäjä pysyy kirjautuneena sovellukseen ikuisesti. Sovellukseen olisikin kenties syytä lisätä logout-toiminnallisuus, joka poistaisi kirjautumistiedot local storagesta. Jätämme kuitenkin uloskirjautumisen harjoitustehtäväksi.

Meille riittää se, että sovelluksesta on mahdollista kirjautua ulos kirjoittamalla konsoliin

window.localStorage.removeItem('loggedNoteappUser')

tai local storagen tilan kokonaan nollaavan komennon

window.localStorage.clear()

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-3.

Tehtäviä

Tee nyt tehtävät 5.1-5.4

Kirjautumislomakkeen näyttäminen vain tarvittaessa

Muutetaan sovellusta siten, että kirjautumislomaketta ei oletusarvoisesti näytetä:

Lomake aukeaa, jos käyttäjä painaa nappia login:

Napilla cancel käyttäjä saa tarvittaessa suljettua lomakkeen.

Aloitetaan eristämällä kirjautumislomake omaksi komponentikseen:

const LoginForm = ({ handleSubmit, handleChange, username, password }) => {
  return (
    <div>
      <h2>Kirjaudu</h2>

      <form onSubmit={handleSubmit}>
        <div>
          käyttäjätunnus
          <input
            value={username}
            onChange={handleChange}
            name="username"
          />
        </div>
        <div>
          salasana
          <input
            type="password"
            name="password"
            value={password}
            onChange={handleChange}
          />
        </div>
        <button type="submit">kirjaudu</button>
      </form>
    </div>
  )
}

Reactin suosittelemaan tyyliin tila ja tilaa käsittelevät funktiot on kaikki määritelty komponentin ulkopuolella ja välitetään komponentille propseina.

Huomaa, että propsit otetaan vastaan destrukturoimalla, eli sen sijaan että määriteltäisiin

const LoginForm = (props) => {
  return (
      <form onSubmit={props.handleSubmit}>
        <div>
          käyttäjätunnus
          <input
            value={props.username}
            onChange={props.handleChange}
            name="username"
          />
        </div>
        // ...
        <button type="submit">kirjaudu</button>
      </form>
    </div>
  )
}

jolloin muuttujan props kenttiin on viitattava muuttujan kautta esim. props.handleSubmit, otetaan kentät suoraan vastaan omiin muuttujiinsa.

Nopea tapa toiminnallisuuden toteuttamiseen on muuttaa komponentin App käyttämä funktio loginForm seuraavaan muotoon:

const loginForm = () => {
  const hideWhenVisible = { display: this.state.loginVisible ? 'none' : '' }
  const showWhenVisible = { display: this.state.loginVisible ? '' : 'none' }

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={e => this.setState({ loginVisible: true })}>log in</button>
      </div>
      <div style={showWhenVisible}>
        <LoginForm
          visible={this.state.visible}
          username={this.state.username}
          password={this.state.password}
          handleChange={this.handleLoginFieldChange}
          handleSubmit={this.login}
        />
        <button onClick={e => this.setState({ loginVisible: false })}>cancel</button>
      </div>
    </div>
  )
}

Komponentin App tilaan on nyt lisätty kenttä loginVisible joka määrittelee sen näytetäänkö kirjautumislomake.

Näkyvyyttä säätelevää tilaa vaihdellaan kahden napin avulla, molempiin on kirjoitettu tapahtumankäsittelijän koodi suoraan:

<button onClick={e => this.setState({ loginVisible: true })}>log in</button>

<button onClick={e => this.setState({ loginVisible: false })}>cancel</button>

Komponenttien näkyvyys on määritelty asettamalla komponentille CSS-määrittely, jossa display-propertyn arvoksi asetetaan none jos komponentin ei haluta näkyvän:

const hideWhenVisible = { display: this.state.loginVisible ? 'none' : '' }
const showWhenVisible = { display: this.state.loginVisible ? '' : 'none' }

// ...

<div style={hideWhenVisible}>
  // nappi
</div>

<div style={showWhenVisible}>
  // lomake
</div>

Käytössä on taas kysymysmerkkioperaattori, eli jos this.state.visible on true, tulee napin CSS-määrittelyksi

display: 'none';

jos this.state.loginVisible on false, ei display saa mitään (napin näkyvyyteen liittyvää) arvoa.

Hyödynsimme mahdollisuutta määritellä React-komponenteille koodin avulla inline-tyylejä. Palaamme asiaan tarkemmin seuraavassa osassa.

Komponentin lapset, eli this.props.children

Kirjautumislomakkeen näkyvyyttä ympäröivän koodin voi ajatella olevan oma looginen kokonaisuutensa ja se onkin hyvä eristää pois komponentista App omaksi komponentikseen.

Tavoitteena on luoda komponentti Togglable, jota käytetään seuraavalla tavalla:

<Togglable buttonLabel="login">
  <LoginForm
    visible={this.state.visible}
    username={this.state.username}
    password={this.state.password}
    handleChange={this.handleLoginFieldChange}
    handleSubmit={this.login}
  />
</Togglable>

Komponentin käyttö poikkeaa aiemmin näkemistämme siinä, että käytössä on nyt avaava ja sulkeva tagi, joiden sisällä määritellään toinen komponentti eli LoginForm. Reactin terminologiassa LoginForm on nyt komponentin Togglable lapsi.

Togglablen avaavan ja sulkevan tagin sisälle voi sijoittaa lapsiksi mitä tahansa React-elementtejä, esim.:

<Togglable buttonLabel="paljasta">
  <p>tämä on aluksi piilossa</p>
  <p>toinen salainen rivi</p>
</Togglable>

Komponentin koodi on seuraavassa:

class Togglable extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      visible: false
    }
  }

  toggleVisibility = () => {
    this.setState({visible: !this.state.visible})
  }

  render() {
    const hideWhenVisible = { display: this.state.visible ? 'none' : '' }
    const showWhenVisible = { display: this.state.visible ? '' : 'none' }

    return (
      <div>
        <div style={hideWhenVisible}>
          <button onClick={this.toggleVisibility}>{this.props.buttonLabel}</button>
        </div>
        <div style={showWhenVisible}>
          {this.props.children}
          <button onClick={this.toggleVisibility}>cancel</button>
        </div>
      </div>
    )
  }
}

Mielenkiintoista ja meille uutta on this.props.children, jonka avulla koodi viittaa komponentin lapsiin, eli avaavan ja sulkevan tagin sisällä määriteltyihin React-elementteihin.

Tällä kertaa lapset ainoastaan renderöidään komponentin oman renderöivän koodin seassa:

<div style={showWhenVisible}>
  {this.props.children}
  <button onClick={this.toggleVisibility}>cancel</button>
</div>

Toisin kuin “normaalit” propsit, children on Reactin automaattisesti määrittelemä, aina olemassa oleva propsi. Jos komponentti määritellään automaattisesti suljettavalla eli /> loppuvalla tagilla, esim.

<Note
  key={note.id}
  note={note}
  toggleImportance={this.toggleImportanceOf(note.id)}
/>

on this.props.children tyhjä taulukko.

Komponentti Togglable on uusiokäytettävä ja voimme käyttää sitä tekemään myös uuden muistiinpanon luomisesta huolehtivan formin vastaavalla tavalla tarpeen mukaan näytettäväksi.

Eristetään ensin muistiinpanojen luominen omaksi komponentiksi

const NoteForm = ({ onSubmit, handleChange, value}) => {
  return (
    <div>
      <h2>Luo uusi muistiinpano</h2>

      <form onSubmit={onSubmit}>
        <input
          value={value}
          onChange={handleChange}
        />
        <button type="submit">tallenna</button>
      </form>
    </div>
  )
}

ja määritellään lomakkeen näyttävä koodi komponentin Togglable sisällä

<Togglable buttonLabel="new note">
  <NoteForm
    onSubmit={this.addNote}
    value={this.state.newNote}
    handleChange={this.handleNoteChange}
  />
</Togglable>

ref eli viite komponenttiin

Ratkaisu on melko hyvä, haluaisimme kuitenkin parantaa sitä erään seikan osalta.

Kun uusi muistiinpano luodaan, olisi loogista jos luomislomake menisi piiloon. Nyt lomake pysyy näkyvillä. Lomakkeen piilottamiseen sisältyy kuitenkin pieni ongelma, sillä näkyvyyttä kontrolloidaan Togglable-komponentin tilassa olevalla muuttujalla ja komponentissa määritellyllä metodilla toggleVisibility. Miten pääsemme niihin käsiksi komponentin ulkopuolelta?

Koska React-komponentit ovat Javascript-olioita, on niiden metodeja mahdollista kutsua jos komponenttia vastaavaan olioon onnistutaan saamaan viite.

Eräs keino viitteen saamiseen on React-komponenttien attribuutti ref.

Muutetaan lomakkeen renderöivää koodia seuraavasti:

<div>
  <Togglable buttonLabel="new note" ref={component => this.noteForm = component}>
    <NoteForm
      ...
    />
  </Togglable>
</div>

Kun komponentti Togglable renderöidään, suorittaa React ref-attribuutin sisällä määritellyn funktion:

component => this.noteForm = component

parametrin component arvona on viite komponenttiin. Funktio tallettaa viitteen muuttujaan this.noteForm eli App-komponentin kenttään noteForm.

Nyt mistä tahansa komponentin App sisältä on mahdollista päästä käsiksi uusien muistiinpanojen luomisen sisältävään Togglable-komponenttiin.

Voimme nyt piilottaa lomakkeen kutsumalla this.noteForm.toggleVisibility() samalla kun uuden muistiinpanon luominen tapahtuu:

addNote = (e) => {
  e.preventDefault()
  this.noteForm.toggleVisibility()

  // ..
}

Refeille on myös muita käyttötarkoituksia kuin React-komponentteihin käsiksi pääseminen.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-4.

Huomio komponenteista

Kun Reactissa määritellään komponentti

class Togglable extends React.Component {
  // ...
}

ja otetaan se käyttöön seuraavasti

<div>
  <Togglable buttonLabel="1" ref={component => this.t1 = component}>
    ensimmäinen
  </Togglable>

  <Togglable buttonLabel="2" ref={component => this.t2 = component}>
    toinen
  </Togglable>

  <Togglable buttonLabel="3" ref={component => this.t3 = component}>
    kolmas
  </Togglable>
</div>

syntyy kolme erillistä komponenttiolioa, joilla on kaikilla oma tilansa:

ref-attribuutin avulla on talletettu viite jokaiseen komponenttiin muuttujiin this.t1, this.t2 ja this.t3.

Tehtäviä

Tee nyt tehtävät 5.5-5.10

PropTypes

Komponentti Togglable olettaa, että sille määritellään propsina buttonLabel napin teksti. Jos määrittely unohtuu

<Togglable>
  buttonLabel unohtui...
</Togglable>

Sovellus kyllä toimii, mutta selaimeen renderöityy hämäävästi nappi, jolla ei ole mitään tekstiä.

Haluaisimmekin varmistaa että jos Togglable-komponenttia käytetään, on propsille “pakko” antaa arvo.

Kirjaston olettamat ja edellyttämät propsit ja niiden tyypit voidaan määritellä kirjaston prop-types avulla. Asennetaan kirjasto

npm install --save prop-types

buttonLabel voidaan määritellä pakolliseksi string-tyyppiseksi propsiksi seuraavasti

import PropTypes from 'prop-types'

class Togglable extends React.Component {
  // ...
}

Togglable.propTypes = {
  buttonLabel: PropTypes.string.isRequired
}

Jos propsia ei määritellä, seurauksena on konsoliin tulostuva virheilmoitus

Koodi kuitenkin toimii edelleen, eli mikään ei pakota määrittelemään propseja PropTypes-määrittelyistä huolimatta. On kuitenkin erittäin epäprofessionaalia jättää konsoliin mitään punaisia tulosteita.

Määritellään Proptypet myös LoginForm-komponentille:

import PropTypes from 'prop-types'

const LoginForm = ({ handleSubmit, handleChange, username, password }) => {
  return (
    // ...
  )
}

LoginForm.propTypes = {
  handleSubmit: PropTypes.func.isRequired,
  handleChange: PropTypes.func.isRequired,
  username: PropTypes.string.isRequired,
  password: PropTypes.string.isRequired
}

Funktionaalisen komponentin proptypejen määrittely tapahtuu samalla tavalla kuin luokkaperustaisten.

Jos propsin tyyppi on väärä, esim. yritetään määritellä propsiksi handleChange merkkijono, seurauksena on varoitus:

Luokkaperustaisille komponenteille PropTypet on mahdollista määritellä myös luokkamuuttujina, seuraavalla syntaksilla:

import PropTypes from 'prop-types'

class Togglable extends React.Component {
  static propTypes = {
    buttonLabel: PropTypes.string.isRequired
  }

  // ...
}

Muuttujamäärittelyn edessä oleva static määrittelee nyt, että propTypes-kenttä on nimenomaan komponentin määrittelevällä luokalla Togglable eikä luokan instansseilla. Oleellisesti ottaen kyseessä on ainoastaan Javascriptin vielä standardoimattoman ominaisuuden mahdollistava syntaktinen oikotie määritellä seuraava:

Togglable.propTypes = {
  buttonLabel: PropTypes.string.isRequired
}

Surffatessasi internetissä saatat vielä nähdä ennen Reactin versiota 0.16 tehtyjä esimerkkejä, joissa PropTypejen käyttö ei edellytä erillistä kirjastoa. Versiosta 0.16 alkaen PropTypejä ei enää määritelty React-kirjastossa itsessään ja kirjaston prop-types käyttö on pakollista.

Tehtäviä

Tee nyt tehtävä 5.11

React-sovelluksen testaus

Reactilla tehtyjen frontendien testaamiseen on monia tapoja. Aloitetaan niihin tutustuminen nyt.

Testit tehdään samaan tapaan kuin edellisessä osassa eli Facebookin Jest-kirjastolla. Jest onkin valmiiksi konfiguroitu create-react-app:illa luotuihin projekteihin.

Jestin lisäksi käytetään AirBnB:n kehittämää enzyme-kirjastoa.

Asennetaan enzyme komennolla:

npm install --save-dev enzyme enzyme-adapter-react-16

Testataan aluksi muistiinpanon renderöivää komponenttia:

const Note = ({ note, toggleImportance }) => {
  const label = note.important ? 'make not important' : 'make important'
  return (
    <div className="wrapper">
      <div className="content">
        {note.content}
      </div>
      <div>
        <button onClick={toggleImportance}>{label}</button>
      </div>
    </div>
  )
}

Testauksen helpottamiseksi komponenttiin on lisätty sisällön määrittelevälle div-elementille CSS-luokka content.

shallow-renderöinti

Ennen testien tekemistä, tehdään enzymen konfiguraatioita varten tiedosto src/setupTests.js ja sille seuraava sisältö:

import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'

configure({ adapter: new Adapter() })

Nyt olemme valmiina testien tekemiseen.

Koska Note on yksinkertainen komponentti, joka ei käytä yhtään monimutkaista alikomponenttia vaan renderöi suoraan HTML:ää, sopii sen testaamiseen hyvin enzymen shallow-renderöijä.

Tehdään testi tiedoston src/components/Note.test.js, eli samaan hakemistoon, missä komponentti itsekin sijaitsee.

Ensimmäinen testi varmistaa, että komponentti renderöi muistiinpanon sisällön:

import React from 'react'
import { shallow } from 'enzyme'
import Note from './Note'

describe.only('<Note />', () => {
  it('renders content', () => {
    const note = {
      content: 'Komponenttitestaus tapahtuu jestillä ja enzymellä',
      important: true
    }

    const noteComponent = shallow(<Note note={note} />)
    const contentDiv = noteComponent.find('.content')

    expect(contentDiv.text()).toContain(note.content)
  })
})

Edellisessä osassa määrittelimme testitapaukset metodin test avulla. Nyt käytössä oleva it viittaa samaan olioon kuin test, eli on sama kumpaa käytät. It on tietyissä piireissä suositumpi ja käytössä mm. Enzymen dokumentaatiossa joten käytämme it-muotoa tässä osassa.

Alun konfiguroinnin jälkeen testi renderöi komponentin metodin shallow avulla:

const noteComponent = shallow(<Note note={note} />)

Normaalisti React-komponentit renderöityvät DOM:iin. Nyt kuitenkin renderöimme komponentteja shallowWrapper-tyyppisiksi, testaukseen sopiviksi olioiksi.

ShallowWrapper-muotoon renderöidyillä React-komponenteilla on runsaasti metodeja, joiden avulla niiden sisältöä voidaan tutkia. Esimerkiksi find mahdollistaa komponentin sisällä olevien elementtien etsimisen enzyme-selektorien avulla. Eräs tapa elementtien etsimiseen on CSS-selektorien käyttö. Liitimme muisiinpanon sisällön kertovaan div-elementtiin luokan content, joten voimme etsiä elementin seuraavasti:

const contentDiv = noteComponent.find('.content')

ekspektaatiossa varmistamme, että elementtiin on renderöitynyt oikea teksti, eli muistiinpanon sisältö:

expect(contentDiv.text()).toContain(note.content)

Testien suorittaminen

Create-react-app:issa on konfiguroitu testit oletusarvoisesti suoritettavaksi ns. watch-moodissa, eli jos suoritat testit komennolla npm test, jää konsoli odottamaan koodissa tapahtuvia muutoksia. Muutosten jälkeen testit suoritetaan automaattisesti ja Jest alkaa taas odottamaan uusia muutoksia koodiin.

Jos haluat ajaa testit “normaalisti”, se onnistuu komennolla

CI=true npm test

Mikäli testejä suoritettaessa ei löydetä tiedostossa src/setupTests.js tehtyä adapterin konfigurointia, auttaa seuraavan asetuksen lisääminen tiedostoon package-lock.json:

  "jest": {
    ...
    "setupFiles": [
      "<rootDir>/src/setupTests.js"
    ],
    ...
  }

Testien sijainti

Reactissa on (ainakin) kaksi erilaista konventiota testien sijoittamiseen. Sijoitimme testit ehkä vallitsevan tavan mukaan, eli samaan hakemistoon missä testattava komponentti sijaitsee.

Toinen tapa olisi sijoittaa testit “normaaliin” tapaan omaan erilliseen hakemistoon. Valitaanpa kumpi tahansa tapa, on varmaa että se on jonkun mielestä täysin väärä.

Itse en pidä siitä, että testit ja normaali koodi ovat samassa hakemistossa. Noudatamme kuitenkin nyt tätä tapaa, sillä se on oletusarvo create-react-app:illa konfiguroiduissa sovelluksissa.

Testien debuggaaminen

Testejä tehdessä törmäämme tyypillisesti erittäin moniin ongelmiin. Näissä tilanteissa vanha kunnon console.log on hyödyllinen. Voimme tulostaa shallow-metodin avulla renderöityjä komponentteja ja niiden sisällä olevia elementtejä metodin debug avulla:

describe.only('<Note />', () => {
  it('renders content', () => {
    const note = {
      content: 'Komponenttitestaus tapahtuu jestillä ja enzymellä',
      important: true
    }

    const noteComponent = shallow(<Note note={note} />)
    console.log(noteComponent.debug())


    const contentDiv = noteComponent.find('.content')
    console.log(contentDiv.debug())

    // ...
  })
})

Konsoliin tulostuu komponentin generoima html:

console.log src/components/Note.test.js:16
  <div className="wrapper">
    <div className="content">
      Komponenttitestaus tapahtuu jestillä ja enzymellä
    </div>
    <div>
      <button onClick={[undefined]}>
        make not important
      </button>
    </div>
  </div>

console.log src/components/Note.test.js:20
  <div className="content">
    Komponenttitestaus tapahtuu jestillä ja enzymellä
  </div>

Nappien painelu testeissä

Sisällön näyttämisen lisäksi toinen Note-komponenttien vastuulla oleva asia on huolehtia siitä, että painettaessa noten yhteydessä olevaa nappia, tulee propsina välitettyä tapahtumankäsittelijäfunktiota toggleImportance kutsua.

Testaus onnistuu seuraavasti:

it('clicking the button calls event handler once', () => {
  const note = {
    content: 'Komponenttitestaus tapahtuu jestillä ja enzymellä',
    important: true
  }

  const mockHandler = jest.fn()

  const noteComponent = shallow(
    <Note
      note={note}
      toggleImportance={mockHandler}
    />
  )

  const button = noteComponent.find('button')
  button.simulate('click')

  expect(mockHandler.mock.calls.length).toBe(1)
})

Testissä on muutama mielenkiintoinen seikka. Tapahtumankäsittelijäksi annetaan Jestin avulla määritelty mock-funktio:

const mockHandler = jest.fn()

Testi hakee renderöidystä komponentista button-elementin ja klikkaa sitä. Koska komponentissa on ainoastaan yksi nappi, on sen hakeminen helppoa:

const button = noteComponent.find('button')
button.simulate('click')

Klikkaaminen tapahtuu metodin simulate avulla.

Testin ekspektaatio varmistaa, että mock-funktiota on kutsuttu täsmälleen kerran:

expect(mockHandler.mock.calls.length).toBe(1)

Mockoliot ja -funktiot ovat testauksessa yleisesti käytettyjä valekomponentteja, joiden avulla korvataan testattavien komponenttien riippuvuuksia, eli niiden tarvitsemia muita komponentteja. Mockit mahdollistavat mm. kovakoodattujen syötteiden palauttamisen sekä niiden metodikutsujen lukumäärän sekä parametrien testauksen aikaisen tarkkailun.

Esimerkissämme mock-funktio sopi tarkoitukseen erinomaisesti, sillä sen avulla on helppo varmistaa, että metodia on kutsuttu täsmälleen kerran.

Komponentin Togglable testit

Tehdään komponentille Togglable muutama testi. Lisätään komponentin lapset renderöivään div-elementtiin CSS-luokka togglableContent:

class Togglable extends React.Component {

  render() {
    const hideWhenVisible = { display: this.state.visible ? 'none' : '' }
    const showWhenVisible = { display: this.state.visible ? '' : 'none' }

    return (
      <div>
        <div style={hideWhenVisible}>
          <button onClick={this.toggleVisibility}>{this.props.buttonLabel}</button>
        </div>
        <div style={showWhenVisible} className="togglableContent">
          {this.props.children}
          <button onClick={this.toggleVisibility}>cancel</button>
        </div>
      </div>
    )
  }
}

Testit ovat seuraavassa

import React from 'react'
import { shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import Note from './Note'
import Togglable from './Togglable'

describe('<Togglable />', () => {
  let togglableComponent

  beforeEach(() => {
    togglableComponent = shallow(
      <Togglable buttonLabel="show...">
        <div className="testDiv" />
      </Togglable>
    )
  })

  it('renders its children', () => {
    expect(togglableComponent.contains(<div class="testDiv" />)).toEqual(true)
  })

  it('at start the children are not displayed', () => {
    const div = togglableComponent.find('.togglableContent')
    expect(div.getElement().props.style).toEqual({ display: 'none' })
  })

  it('after clicking the button, children are displayed', () => {
    const button = togglableComponent.find('button')

    button.at(0).simulate('click')
    const div = togglableComponent.find('.togglableContent')
    expect(div.getElement().props.style).toEqual({ display: '' })
  })

})

Ennen jokaista testiä suoritettava beforeEach alustaa shallow-renderöimällä Togglable-komponentin muuttujaan togglableComponent.

Ensimmäinen testi tarkastaa, että Togglable renderöi lapsikomponentin <div class="testDiv" />. Loput testit varmistavat, että Togglablen sisältämä lapsikomponentti on alussa näkymättömissä, eli sen sisältävään div-elementin liittyy tyyli { display: ‘none’ }, ja että nappia painettaessa komponentti näkyy, eli tyyli on { display: ‘’ }. Koska Togglablessa on kaksi nappia, painallusta simuloidessa niistä pitää valita oikea, eli tällä kertaa ensimmäinen.

Tehtäviä

Tee nyt tehtävät 5.12-14

mount ja full DOM -renderöinti

Käyttämämme shallow-renderöijä on useimmista tapauksissa riittävä. Joskus tarvitsemme kuitenkin järeämmän työkalun sillä shallow renderöi ainoastaan “yhden tason”, eli sen komponentin, jolle metodia kutsutaan.

Jos yritämme esim. sijoittaa kaksi Note-komponenttia Togglable-komponentin sisälle ja tulostamme syntyvän ShallowWrapper -olion

it('shallow renders only one level', () => {
  const note1 = {
    content: 'Komponenttitestaus tapahtuu jestillä ja enzymellä',
    important: true
  }
  const note2 = {
    content: 'shallow ei renderöi alikomponentteja',
    important: true
  }

  const togglableComponent = shallow(
    <Togglable buttonLabel="show...">
      <Note note={note1} />
      <Note note={note2} />
    </Togglable>
  )

  console.log(togglableComponent.debug())
})

huomaamme, että Togglable komponentti on renderöitynyt, eli “muuttunut” HTML:ksi, mutta sen sisällä olevat Note-komponentit eivät ole HTML:ää vaan React-komponentteja.

<div>
  <div style=>
    <button onClick={[Function]}>
      show...
    </button>
  </div>
  <div style= className="togglableContent">
    <Note note= />
    <Note note= />
    <button onClick={[Function]}>
      cancel
    </button>
  </div>
</div>

Jos komponentille tehdään edellisten esimerkkien tapaan yksikkötestejä, shallow-renderöinti on useimmiten riittävä. Jos haluamme testata isompia kokonaisuuksia, eli tehdä frontendin integraatiotestausta, ei shallow-renderöinti riitä vaan on turvauduttava komponentit kokonaisuudessaan renderöivään mount:iin.

Muutetaan testi käyttämään shallowin sijaan mountia:

import React from 'react'
import { shallow, mount } from 'enzyme'
import Note from './Note'
import Togglable from './Togglable'

it('mount renders all components', () => {
  const note1 = {
    content: 'Komponenttitestaus tapahtuu jestillä ja enzymellä',
    important: true
  }
  const note2 = {
    content: 'mount renderöi myös alikomponentit',
    important: true
  }

  const noteComponent = mount(
    <Togglable buttonLabel="show...">
      <Note note={note1} />
      <Note note={note2} />
    </Togglable>
  )

  console.log(noteComponent.debug())
})

Tuloksena on kokonaisuudessaan HTML:ksi renderöitynyt Togglable-komponentti:

<Togglable buttonLabel="show...">
  <div>
    <div style=>
      <button onClick={[Function]}>
        show...
      </button>
    </div>
    <div style= className="togglableContent">
      <Note note=>
        <div className="wrapper">
          <div className="content">
            Komponenttitestaus tapahtuu jestillä ja enzymellä
          </div>
          <div>
            <button onClick={[undefined]}>
              make not important
            </button>
          </div>
        </div>
      </Note>
      <Note note=>
        <div className="wrapper">
          <div className="content">
            mount renderöi myös alikomponentit
          </div>
          <div>
            <button onClick={[undefined]}>
              make not important
            </button>
          </div>
        </div>
      </Note>
      <button onClick={[Function]}>
        cancel
      </button>
    </div>
  </div>
</Togglable>

Mountin avulla renderöitäessä testi pääsee siis käsiksi periaatteessa samaan HTML-koodiin, joka todellisuudessa renderöidään selaimeen ja tämä luonnollisesti mahdollistaa huomattavasti monipuolisemman testauksen kuin shallow-renderöinti. Komennolla mount tapahtuva renderöinti on kuitenkin hitaampaa, joten jos shallow riittää, sitä kannattaa käyttää.

Huomaa, että testin käyttämä metodi debug ei palauta todellista HTML:ää vaan debuggaustarkoituksiin sopivan tekstuaalisen esitysmuodon komponentista. Todellisessa HTML:ssä ei mm. ole ollenkaan React-komponenttien tageja.

Jos on tarvetta tietää, mikä on testattaessa syntyvä todellinen HTML, sen saa selville metodilla html.

Jos muutamme testin viimeisen komennon muotoon

console.log(noteComponent.html())

tulostuu todellinen HTML:

<div>
  <div><button>show...</button></div>
  <div style="display: none;">
    <div class="wrapper">
      <div class="content">Komponenttitestaus tapahtuu jestillä ja enzymellä</div>
      <div><button>make not important</button></div>
    </div>
    <div class="wrapper">
      <div class="content">mount renderöi myös alikomponentit</div>
      <div><button>make not important</button></div>
    </div>
    <button>cancel</button></div>
</div>

Komennon mount palauttamaa renderöidyn “komponenttipuun” ReactWrapper-tyyppisenä oliona, joka tarjoaa hyvin samantyyppisen rajapinnan komponentin sisällön tutkimiseen kuin ShallowWrapper.

Lomakkeiden testaus

Lomakkeiden testaaminen Enzymellä on jossain määrin haasteellista. Enzymen dokumentaatio ei mainitse lomakkeista sanaakaan. Issueissa asiasta kuitenkin keskustellaan.

Tehdään testi komponentille NoteForm. Lomakkeen koodi näyttää seuraavalta

const NoteForm = ({ onSubmit, handleChange, value }) => {
  return (
    <div>
      <h2>Luo uusi muistiinpano</h2>

      <form onSubmit={onSubmit}>
        <input
          value={value}
          onChange={handleChange}
        />
        <button type="submit">tallenna</button>
      </form>
    </div>
  )
}

Lomakkeen toimintaperiaatteena on synkronoida lomakkeen tila sen ulkopuolella olevan React-komponentin tilaan. Lomakettamme on jossain määrin vaikea testata yksistään.

Teemmekin testejä varten apukomponentin Wrapper, joka renderöi NoteForm:in ja hallitsee lomakkeen tilaa:

class Wrapper extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      formInput: ''
    }
  }
  onChange = (e) => {
    this.setState({ formInput: e.target.value })
  }
  render() {
    return (
      <NoteForm
        value={this.state.formInput}
        onSubmit={this.props.onSubmit}
        handleChange={this.onChange}
      />
  )}
}

Testi on seuraavassa:

import React from 'react'
import { mount } from 'enzyme'
import NoteForm from './NoteForm'

it('renders content', () => {
  const onSubmit = jest.fn()

  const wrapper = mount(
    <Wrapper onSubmit={onSubmit} />
  )

  const input = wrapper.find('input')
  const button = wrapper.find('button')

  input.simulate('change', { target: { value: 'lomakkeiden testaus on hankalaa' } })
  button.simulate('submit')

  expect(wrapper.state().formInput).toBe('lomakkeiden testaus on hankalaa')
  expect(onSubmit.mock.calls.length).toBe(1)
})

Testi luo Wrapper-komponentin, jolle se välittää propseina mockatun funktion onSubmit. Wrapper välittää funktion edelleen NoteFormille tapahtuman onSubmit käsittelijäksi.

Syötekenttään input kirjoittamista simuloidaan tekemällä syötekenttään tapahtuma change ja määrittelemällä sopiva olio, joka määrittelee syötekenttään ‘kirjoitetun’ sisällön.

Lomakkeen nappia tulee painaa simuloimalla tapahtumaa submit, tapahtuma click ei lähetä lomaketta.

Testin ensimmäinen ekspektaatio tutkii komponentin Wrapper tilaa metodilla state, ja varmistaa, että lomakkeelle kirjoitettu teksti on siirtynyt tilaan. Toinen ekspektaatio varmistaa, että lomakkeen lähetys on aikaansaanut tapahtumankäsittelijän kutsumisen.

Frontendin integraatiotestaus

Suoritimme edellisessä osassa backendille integraatiotestejä, jotka testasivat backendin tarjoaman API:n läpi backendia ja tietokantaa. Backendin testauksessa tehtiin tietoinen päätös olla kirjoittamatta yksikkötestejä sillä backendin koodi on melko suoraviivaista ja ongelmat tulevatkin esiin todennäköisemmin juuri monimutkaisemmissa skenaarioissa, joita integraatiotestit testaavat hyvin.

Toistaiseksi kaikki frontendiin tekemämme testit ovat olleet yksittäisten komponenttien oikeellisuutta valvovia yksikkötestejä. Yksikkötestaus on toki tärkeää, mutta kattavinkaan yksikkötestaus ei riitä antamaan riittävää luotettavuutta sille, että järjestelmä toimii kokonaisuudessaan.

Tehdään nyt sovellukselle yksi integraatiotesti. Integraatiotestaus on huomattavasti komponenttien yksikkötestausta hankalampaa. Erityisesti sovelluksemme kohdalla ongelmia aiheuttaa kaksi seikkaa: sovellus hakee näytettävät muistiinpanot palvelimelta ja sovellus käyttää local storagea kirjautuneen käyttäjän tietojen tallettamiseen.

Local storage ei ole oletusarvoiseti käytettävissä testejä suorittaessa, sillä kyseessä on selaimen tarjoama toiminnallisuus ja testit ajetaan selaimen ulkopuolella. Ongelma on helppo korjata määrittelemällä testien suorituksen ajaksi mock joka matkii local storagea. Tapoja tähän on monia.

Koska testimme ei edellytä local storagelta juuri mitään toiminnallisuutta, teemme tiedostoon src/setupTests.js hyvin yksinkertaisen mockin

let savedItems = {}

const localStorageMock = {
  setItem: (key, item) => {
    savedItems[key] = item
  },
  getItem: (key) => savedItems[key],
  clear: savedItems = {}
}

window.localStorage = localStorageMock

Toinen ongelmistamme on se, että sovellus hakee näytettävät muistiinpanot palvelimelta. Muistiinpanojen haku tapahtuu heti komponentin App luomisen jälkeen, kun metodi componentDidMount kutsuu noteService:n metodia getAll:

componentDidMount() {
  noteService.getAll().then(notes =>
    this.setState({ notes })
  )

  // ...
}

Jestin manual mock -konsepti tarjoaa tilanteeseen hyvän ratkaisun. Manual mockien avulla voidaan kokonainen moduuli, tässä tapauksessa noteService korvata testien ajaksi vaihtoehtoisella esim. kovakoodattua dataa tarjoavalla toiminnallisuudella.

Luodaan Jestin ohjeiden mukaisesti hakemistoon src/services alihakemisto __mocks__ (alussa ja lopussa kaksi alaviivaa) ja sinne tiedosto notes.js jonka määrittelemä metodi getAll palauttaa kovakoodatun listan muistiinpanoja:

let token = null

const notes = [
  {
    id: "5a451df7571c224a31b5c8ce",
    content: "HTML on helppoa",
    date: "2017-12-28T16:38:15.541Z",
    important: false,
    user: {
      _id: "5a437a9e514ab7f168ddf138",
      username: "mluukkai",
      name: "Matti Luukkainen"
    }
  },
  {
    id: "5a451e21e0b8b04a45638211",
    content: "Selain pystyy suorittamaan vain javascriptiä",
    date: "2017-12-28T16:38:57.694Z",
    important: true,
    user: {
      _id: "5a437a9e514ab7f168ddf138",
      username: "mluukkai",
      name: "Matti Luukkainen"
    }
  },
  {
    id: "5a451e30b5ffd44a58fa79ab",
    content: "HTTP-protokollan tärkeimmät metodit ovat GET ja POST",
    date: "2017-12-28T16:39:12.713Z",
    important: true,
    user: {
      _id: "5a437a9e514ab7f168ddf138",
      username: "mluukkai",
      name: "Matti Luukkainen"
    }
  }
]

const getAll = () => {
  return Promise.resolve(notes)
}

export default { getAll, notes }

Määritelty metodi getAll palauttaa muistiinpanojen listan käärittynä promiseksi metodin Promise.resolve avulla sillä käytettäessä metodia, oletetaan sen paluuarvon olevan promise:

noteService.getAll().then(notes =>

Olemme valmiina määrittelemään testin:

import React from 'react'
import { mount } from 'enzyme'
import App from './App'
import Note from './components/Note'
jest.mock('./services/notes')
import noteService from './services/notes'

describe('<App />', () => {
  let app
  beforeAll(() => {
    app = mount(<App />)
  })

  it('renders all notes it gets from backend', () => {
    app.update()
    const noteComponents = app.find(Note)
    expect(noteComponents.length).toEqual(noteService.notes.length)
  })
})

Komennolla jest.mock(‘./services/notes’) otetaan juuri määritelty mock käyttöön. Loogisempi paikka komennolle olisi kenties testien määrittelyt tekevä tiedosto src/setupTests.js

Testin toimivuuden kannalta on oleellista metodin app.update kutsuminen, näin pakotetaan sovellus renderöitymään uudelleen siten, että myös mockatun backendin palauttamat muistiinpanot renderöityvät.

Testauskattavuus

Testauskattavuus saadaan helposti selville suorittamalla testit komennolla

CI=true npm test -- --coverage

Melko primitiivinen HTML-muotoinen raportti generoituu hakemistoon coverage/lcov-report. HTML-muotoinen raportti kertoo mm. yksittäisen komponenttien testaamattomat koodirivit:

Huomaamme, että parannettavaa jäi vielä runsaasti.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-5.

Tehtäviä

Tee nyt tehtävät 5.15 ja 5.16

Snapshot-testaus

Jest tarjoaa “perinteisen” testaustavan lisäksi aivan uudenlaisen tavan testaukseen, ns. snapshot-testauksen. Mielenkiintoista snapshot-testauksessa on se, että sovelluskehittäjän ei tarvitse itse määritellä ollenkaan testejä, snapshot-testauksen käyttöönotto riittää.

Periaatteena on verrata komponenttien määrittelemää HTML:ää aina koodin muutoksen jälkeen siihen, minkälaisen HTML:n komponentit määrittelivät ennen muutosta.

Jos snapshot-testi huomaa muutoksen komponenttien määrittelemässä HTML:ssä, voi kyseessä joko olla haluttu muutos tai vahingossa aiheutettu “bugi”. Snapshot-testi huomauttaa sovelluskehittäjälle, jos komponentin määrittelemä HTML muuttuu. Sovelluskehittäjä kertoo muutosten yhteydessä, oliko muutos haluttu. Jos muutos tuli yllätyksenä, eli kyseessä oli bugi, sovelluskehittäjä huomaa sen snapshot-testauksen ansiosta nopeasti.

End to end -testaus

Olemme tehneet sekä backendille että frontendille hieman niitä kokonaisuutena testavia integraatiotestejä. Eräs tärkeä testauksen kategoria on vielä käsittelemättä, järjestelmää kokonaisuutena testaavat “end to end” (eli E2E) -testit.

Web-sovellusten E2E-testaus tapahtuu simuloidun selaimen avulla esimerkiksi Selenium-kirjastoa käyttäen. Toinen vaihtoehto on käyttää ns. headless browseria eli selainta, jolla ei ole ollenkaan graafista käyttöliittymää. Esim. Chromea on mahdollista suorittaa Headless-moodissa.

E2E testit ovat potentiaalisesti kaikkein hyödyllisin testikategoria, sillä ne tutkivat järjestelmää saman rajapinnan kautta kuin todelliset käyttäjät.

E2E-testeihin liittyy myös ikäviä puolia. Niiden konfigurointi on haastavampaa kuin yksikkö- ja integraatiotestien. E2E-testit ovat tyypillisesti myös melko hitaita ja isommassa ohjelmistossa niiden suoritusaika voi helposti nousta minuutteihin, tai jopa tunteihin. Tämä on ikävää sovelluskehityksen kannalta, sillä sovellusta koodatessa on erittäin hyödyllistä pystyä ajamaan testejä mahdollisimman usein koodin regressioiden varalta.

Palaamme end to end -testeihin kurssin viimeisessä, eli seitsemännessä osassa.

Sovellusten tilan hallinta Reduxilla

Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä tila ja sitä käsittelevät metodit sovelluksen juurikomponentissa. Tilaa ja metodeja on välitetty propsien avulla niitä tarvitseville komponenteille. Tämä toimii johonkin pisteeseen saakka, mutta kun sovellusten koko kasvaa, muuttuu tilan hallinta haasteelliseksi.

Flux-arkkitehtuuri

Facebook kehitti tilan hallinnan ongelmia helpottamaan Flux-arkkitehtuurin. Fluxissa sovelluksen tilan hallinta erotetaan kokonaan Reactin komponenttien ulkopuolisiin varastoihin eli storeihin. Storessa olevaa tilaa ei muuteta suoraan, vaan tapahtumien eli actionien avulla.

Kun action muuttaa storen tilaa, renderöidään näkymät uudelleen:

Jos sovelluksen käyttö, esim. napin painaminen aiheuttaa tarpeen tilan muutokseen, tehdään tilanmuutos actonin avulla. Tämä taas aiheuttaa uuden näytön renderöitymisen:

Flux tarjoaa siis standardin tavan sille miten ja missä sovelluksen tila pidetään sekä tavalle tehdä tilaan muutoksia.

Redux

Facebookilla on olemassa valmis toteutus Fluxille, käytämme kuitenkin saman periaatteen mukaan toimivaa, mutta hieman yksinkertaisempaa Redux-kirjastoa, jota myös Facebookilla käytetään nykyään alkuperäisen Flux-toteutuksen sijaan.

Tutustutaan Reduxiin tekemällä laskurin toteuttava sovellus:

Tehdään uusi create-react-app-sovellus ja asennetaan siihen redux komennolla

npm install redux --save

Fluxin tapaan Reduxissa sovelluksen tila talletetaan storeen.

Koko sovelluksen tila talletetaan yhteen storen tallettamaan Javascript-objektiin. Koska sovelluksemme ei tarvitse mitään muuta tilaa kuin laskurin arvon, talletetaan se storeen suoraan. Jos sovelluksen tila olisi monipuolisempi, talletettaisiin “eri asiat” storessa olevan olioon erillisinä kenttinä.

Storen tilaa muutetaan actionien avulla. Actionit ovat olioita, joilla on vähintään actionin tyypin määrittelevä kenttä type. Sovelluksessamme tarvitsemme esimerkiksi seuraavaa actionia:

{
  type: 'INCREMENT'
}

Jos actioneihin liittyy dataa, määritellään niille tarpeen vaatiessa muitakin kenttiä. Laskurisovelluksemme on kuitenkin niin yksinkertainen, että actioneille riittää pelkkä tyyppikenttä.

Actionien vaikutus sovelluksen tilaan määritellään reducerin avulla. Käytännössä reducer on funktio, joka saa parametrikseen olemassaolevan staten tilan sekä actionin ja palauttaa staten uuden tilan.

Määritellään nyt sovelluksellemme reduceri:

const counterReducer = (state, action) => {
  if (action.type === 'INCREMENT') {
    return state + 1
  } else if (action.type === 'DECREMENT') {
    return state - 1
  } else if (action.type === 'ZERO') {
    return 0
  }

  return state
}

Ensimmäinen parametri on siis storessa oleva tila. Reducer palauttaa uuden tilan actionin tyypin mukaan.

Muutetaan koodia vielä hiukan. Reducereissa on tapana käyttää if:ien sijaan switch-komentoa. Määritellään myös parametrille state oletusarvoksi 0. Näin reducer toimii vaikka store -tilaa ei olisi vielä alustettu.

const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
  }
  return state
}

Reduceria ei ole tarkoitus kutsua koskaan suoraan sovelluksen koodista. Reducer ainoastaan annetaan parametrina storen luovalle createStore-funktiolle:

import {createStore} from 'redux'

const counterReducer = (state = 0, action) => {
  // ...
}

const store = createStore(counterReducer)

Store käyttää nyt reduceria käsitelläkseen actioneja, jotka dispatchataan eli “lähetetään” storagelle sen dispatch-metodilla:

store.dispatch({type: 'INCREMENT'})

Storen tilan saa selville metodilla getState.

Esim. seuraava koodi

const store = createStore(counterReducer)
console.log(store.getState())
store.dispatch({type: 'INCREMENT'})
store.dispatch({type: 'INCREMENT'})
store.dispatch({type: 'INCREMENT'})
console.log(store.getState())
store.dispatch({type: 'ZERO'})
store.dispatch({type: 'DECREMENT'})
console.log(store.getState())

tulostaisi konsoliin

0
3
-1

sillä ensin storen tila on 0. Kolmen INCREMENT-actionin jälkeen tila on 3, ja lopulta actionien ZERO ja DECREMENT jälkeen -1.

Kolmas tärkeä metodi storella on subscribe, jonka avulla voidaan määritellä takaisinkutsufunktioita, joita store kutsuu sen tilan muuttumisen yhteydessä.

Jos esim. lisäisimme seuraavan funktion subscribellä, tulostuisi jokainen storen muutos konsoliin.

store.subscribe(() => {
  const storeNow = store.getState()
  console.log(storeNow)
})

eli koodi

const store = createStore(counterReducer)

store.subscribe(() => {
  const storeNow = store.getState()
  console.log(storeNow)
})

store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'ZERO' })
store.dispatch({ type: 'DECREMENT' })

aiheuttaisi tulostuksen

1
2
3
0
-1

Laskurisovelluksemme koodi on seuraavassa. Kaikki koodi on kirjoitettu samaan tiedostoon, jolloin store on suoraan React-koodin käytettävissä. Tutustumme React/Redux-koodin parempiin strukturointitapoihin myöhemmin.

import React from 'react'
import ReactDOM from 'react-dom'
import {createStore} from 'redux'

const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'ZERO':
      return 0
  }
  return state
}

const store = createStore(counterReducer)

class App extends React.Component {
  render() {
    return(
      <div>
        <div>
          {store.getState()}
        </div>
        <button onClick={e => store.dispatch({ type: 'INCREMENT'})}>
          plus
        </button>
        <button onClick={e => store.dispatch({ type: 'DECREMENT' })}>
          minus
        </button>
        <button onClick={e => store.dispatch({ type: 'ZERO' })}>
          zero
        </button>
      </div>
    )
  }
}

const renderApp = () => {
  ReactDOM.render(<App />, document.getElementById('root'))
}

renderApp()
store.subscribe(renderApp)

Koodissa on pari huomionarvoista seikkaa. App renderöi laskurin arvon kysymällä sitä storesta metodilla store.getState(). Nappien tapahtumankäsittelijät dispatchaavat suoraan oikean tyyppiset actionit storelle.

Kun storessa olevan tilan arvo muuttuu, ei React osaa automaattisesti renderöidä sovellusta uudelleen. Olemmekin rekisteröineet koko sovelluksen renderöinnin suorittavan funktion renderApp kuuntelemaan storen muutoksia metodilla store.subscribe. Huomaa, että joudumme kutsumaan heti alussa metodia renderApp(), ilman kutsua sovelluksen ensimmäistä renderöintiä ei koskaan tapahdu.

Redux-muistiinpanot

Tavoitteenamme on muuttaa muistiinpanosovellus käyttämään tilanhallintaan reduxia. Katsotaan kuitenkin ensin eräitä konsepteja hieman yksinkertaistetun muistiinpanosovelluksen kautta.

Sovelluksen ensimmäinen versio seuraavassa

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    state.push(action.data)
    return state
  }

  return state
}

const store = createStore(noteReducer)

store.dispatch({
  type: 'NEW_NOTE',
  data: {
    content: 'sovelluksen tila talletetaan storeen',
    important: true,
    id: 1
  }
})

store.dispatch({
  type: 'NEW_NOTE',
  data: {
    content: 'tilanmuutokset tehdään actioneilla',
    important: false,
    id: 2
  }
})

class App extends React.Component {
  render() {
    return(
      <div>
        <ul>
          {store.getState().map(note=>
            <li key={note.id}>
              {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
            </li>
          )}
         </ul>
      </div>
    )
  }
}

Toistaiseksi sovelluksessa ei siis ole toiminnallisuutta uusien muistiinpanojen lisäämiseen, voimme kuitenkin tehdä sen dispatchaamalla NEW_NOTE-tyyppisiä actioneja koodista.

Actioneissa on nyt tyypin lisäksi kenttä data, joka sisältää lisättävän muistiinpanon:

{
  type: 'NEW_NOTE',
  data: {
    content: 'tilanmuutokset tehdään actioneilla',
    important: false,
    id: 2
  }
}

puhtaat funktiot, immutable

Reducerimme alustava versio on yksinkertainen:

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    state.push(action.data)
    return state
  }

  return state
}

Tila on nyt taulukko. NEW_NOTE-tyyppisen actionin seurauksena tilaan lisätään uusi muistiinpano metodilla push.

Sovellus näyttää toimivan, mutta määrittelemämme reduceri on huono, se rikkoo Reactin reducerien perusolettamusta siitä, että reducerien tulee olla puhtaita funktioita.

Puhtaat funktiot ovat sellaisia, että ne eivät aiheuta mitään sivuvaikutuksia ja niiden tulee aina palauttaa sama vastaus samoilla parametreilla kutsuttaessa.

Lisäsimme tilaan uuden muistiinpanon metodilla state.push(action.data) joka muuttaa state-olion tilaa. Tämä ei ole sallittua. Ongelma korjautuu helposti käyttämällä metodia concat, joka luo uuden taulukon, jonka sisältönä on vanhan taulukon alkiot sekä lisättävä alkio:

const noteReducer = (state = [], action) => {
  if (action.type === 'NEW_NOTE') {
    return state.concat(action.data)
  }

  return state
}

Reducen tilan tulee koostua muuttumattomista eli immutable olioista. Jos tilaan tulee muutos, ei vanhaa oliota muuteta, vaan se korvataan uudella muuttuneella oliolla. Juuri näin toimimme uudistuneessa reducerissa, vanha taulukko korvaantuu uudella.

Laajennetaan reduceria siten, että se osaa käsitellä muistiinpanon tärkeyteen liittyvän muutoksen:

{
  type: 'TOGGLE_IMPORTANCE',
  data: {
    id: 2
  }
}

Koska meillä ei ole vielä koodia joka käyttää ominaisuutta, laajennetaan reduceria testivetoisesti. Aloitetaan tekemällä testi actionin NEW_NOTE käsittelylle.

Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/noteReducer.js. Otetaan käyttöön myös kirjasto deep-freeze, jonka avulla voimme varmistaa, että reducer on määritelty oikeaoppisesti puhtaana funktiona. Asennetaan kirjasto kehitysaikaiseksi riippuvuudeksi

npm install --save-dev deep-freeze

Testi on seuraavassa:

import noteReducer from './noteReducer'
import deepFreeze from 'deep-freeze'

describe('noteRenderer', () => {
  it('returns new state with action NEW_NOTE', () => {
    const state = []
    const action = {
      type: 'NEW_NOTE',
      data: {
        content: 'sovelluksen tila talletetaan storeen',
        important: true,
        id: 1
      }
    }

    deepFreeze(state)
    const newState = noteReducer(state, action)

    expect(newState.length).toBe(1)
    expect(newState).toContainEqual(action.data)
  })
})

Komento deepFreeze(state) varmistaa, että reducer ei muuta parametrina olevaa storen tilaa. Jos reduceri käyttää state:n manipulointiin komentoa push, testi ei mene läpi

Tehdään sitten testi actionin TOGGLE_IMPORTANCE käsittelylle:

it('returns new state with action TOGGLE_IMPORTANCE', () => {
  const state = [
    {
      content: 'sovelluksen tila talletetaan storeen',
      important: true,
      id: 1
    },
    {
      content: 'tilanmuutokset tehdään actioneilla',
      important: false,
      id: 2
    }]

  const action = {
    type: 'TOGGLE_IMPORTANCE',
    data: {
      id: 2
    }
  }

  deepFreeze(state)
  const newState = noteReducer(state, action)

  expect(newState.length).toBe(2)

  expect(newState).toContainEqual(state[0])

  expect(newState).toContainEqual({
    content: 'tilanmuutokset tehdään actioneilla',
    important: true,
    id: 2
  })
})

Reduceri laajenee seuraavasti

const noteReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_NOTE':
      return state.concat(action.data)
    case 'TOGGLE_IMPORTANCE':
      const id = action.data.id
      const noteToChange = state.find(n => n.id === id)
      const changedNote = { ...noteToChange, important: !noteToChange.important }
      return state.map(note => note.id !== id ? note : changedNote )
    default:
      return state
  }
}

Luomme tärkeyttä muuttaneesta muistiinpanosta kopion osasta 2 tutulla syntaksilla ja korvaamme tilan uudella tilalla, mihin otetaan muuttumattomat muistiinpanot ja muutettavasta sen muutettu kopio changedNote.

array spread -syntaksi

Koska reducerilla on nyt suhteellisen hyvät testit, voimme refaktoroida koodia turvallisesti.

Uuden muistiinpanon lisäys luo palautettavan tilan taulukon concat-funktiolla. Katsotaan nyt miten voimme toteuttaa saman hyödyntämällä Javascriptin array spread -syntaksia:

const noteReducer = (state = [], action) => {
  switch(action.type) {
    case 'NEW_NOTE':
      return [...state, action.data]
    case 'TOGGLE_IMPORTANCE':
      // ...
    default:
    return state
  }
}

Spread-syntaksi toimii seuraavasti. Jos määrittelemme

const luvut = [1, 2, 3]

niin ...luvut hajottaa taulukon yksittäisiksi alkioiksi, eli voimme sijoittaa sen esim, toisen taulukon sisään:

[...luvut, 4, 5]

ja lopputuloksena on taulukko, jonka sisältö on [1, 2, 3, 4, 5].

Jos olisimme sijoittaneet taulukon toisen sisälle ilman spreadia, eli

[luvut, 4, 5]

lopputulos olisi ollut [ [1, 2, 3], 4, 5].

Samannäköinen syntaksi toimii taulukosta destrukturoimalla alkioita otettaessa siten, että se kerää loput alkiot:

const luvut = [1, 2, 3, 4, 5, 6]

const [eka, toka, ...loput] = luvut

console.log(eka)    // tulostuu 1
console.log(toka)   // tulostuu 2
console.log(loput)  // tulostuu [3, 4, 5, 6]

Tehtäviä

Tee nyt tehtävät 5.17 ja 5.18

Lisää toiminnallisuutta ja ei-kontrolloitu lomake

Lisätään sovellukseen mahdollisuus uusien muistiinpanojen tekemiseen sekä tärkeyden muuttamiseen:

const generateId = () => Number((Math.random() * 1000000).toFixed(0))

class App extends React.Component {
  addNote = (event) => {
    event.preventDefault()
    const content = event.target.note.value
    store.dispatch({
      type: 'NEW_NOTE',
      data: {
        content: content,
        important: false,
        id: generateId()
      }
    })
    event.target.note.value = ''
  }
  toggleImportance = (id) => () => {
    store.dispatch({
      type: 'TOGGLE_IMPORTANCE',
      data: { id }
    })
  }
  render() {
    return(
      <div>
        <form onSubmit={this.addNote}>
          <input name="note" />
          <button type="submit">lisää</button>
        </form>
        <ul>
          {store.getState().map(note=>
            <li key={note.id} onClick={this.toggleImportance(note.id)}>
              {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
            </li>
          )}
         </ul>
      </div>
    )
  }
}

Molemmat toiminnallisuudet on toteutettu suoraviivaisesti. Huomionarvoista uuden muistiinpanon lisäämisessä on nyt se, että toisin kuin aiemmat Reactilla toteutetut lomakkeet, emme ole nyt sitoneet lomakkeen kentän arvoa komponentin App tilaan. React kutsuu tälläisiä lomakkeita ei-kontrolloiduiksi.

Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita (ne eivät esim. mahdollista lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella ym…), meidän käyttötapaukseemme ne kuitenkin tällä kertaa sopivat. Voit halutessasi lukea aiheesta enemmän täältä.

Muistiinpanon lisäämisen käsittelevä metodi on yksinkertainen, se ainoastaan dispatchaa muistiinpanon lisäävän actionin:

addNote = (event) => {
  event.preventDefault()
  const content = event.target.note.value
  store.dispatch({
    type: 'NEW_NOTE',
    data: {
      content: content,
      important: false,
      id: generateId()
    }
  })
  event.target.note.value = ''
}

Uuden muistiinpanon sisältö saadaan suoraan lomakkeen syötekentästä, johon kentän nimeämisen ansiosta päästään käsiksi tapahtumaolion kautta event.target.note.value

Tärkeyden muuttaminen tapahtuu klikkaamalla muistiinpanon nimeä. Käsittelijä on erittäin yksinkertainen:

toggleImportance = (id) => () => {
  store.dispatch({
    type: 'TOGGLE_IMPORTANCE',
    data: { id }
  })
}

Kyseessä on jälleen tuttu funktio, joka palauttaa funktion, eli kullekin muistiinpanolle generoituu käsittelijäksi funktio, jolla on muistiinpanon yksilöllinen id. Esim. jos id olisi 12345, käsittelijä olisi seuraava:

() => {
  store.dispatch({
    type: 'TOGGLE_IMPORTANCE',
    data: { id: 12345 }
  })
}

action creatorit

Alamme huomata, että jo näinkin yksinkertaisessa sovelluksessa Reduxin käyttö yksinkertaistaa sovelluksen ulkoasusta vastaavaa koodia melkoisesti. React-komponenttien on oikeastaan tarpeetonta tuntea reduxin actionien tyyppejä ja esitysmuotoja. Eristetään ne erilliseen olioon, jonka metodit ovat action creatoreja:

const actionFor = {
  noteCreation(content) {
    return {
      type: 'NEW_NOTE',
      data: {
        content,
        important: false,
        id: generateId()
      }
    }
  },
  importanceToggling(id) {
    return {
      type: 'TOGGLE_IMPORTANCE',
      data: { id }
    }
  }
}

Komponentin App ei tarvitse enää tietää mitään actionien sisäisestä esitystavasta:

class App extends React.Component {
  addNote = (event) => {
    event.preventDefault()
    store.dispatch(
      actionFor.noteCreation(event.target.note.value)
    )
    event.target.note.value = ''
  }
  toggleImportance = (id) => () => {
    store.dispatch(
      actionFor.importanceToggling(id)
    )
  }

  // ...
}

staten välittäminen propseissa ja contextissa

Sovelluksemme on reduceria lukuunottamatta tehty samaan tiedostoon. Kyseessä ei tietenkään ole järkevä käytäntö, eli on syytä eriyttää App omaan moduuliinsa.

Herää kuitenkin kysymys miten App pääsee muutoksen jälkeen käsiksi storeen? Ja yleisemminkin, kun komponentti koostuu suuresta määrästä komponentteja, tulee olla jokin mekanismi, minkä avulla komponentit pääsevät käsiksi storeen.

Tapoja on muutama, käsitellään tässä osassa kahta helpoimmin ymmärrettävää. Parhaan tavan eli kirjaston React-redux määrittelevän connect-metodin säästämme seuraavaan osaan, sillä se on hieman abstrakti ja on kenties hyvä totutella Reduxiin aluksi ilman connectin tuomia käsitteellisiä haasteita.

Yksinkertaisin vaihtoehto on välittää store propsien avulla. Sovelluksen käynnistyspiste index.js typistyy seuraavasti

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import App from './App'
import noteReducer from './noteReducer'

const store = createStore(noteReducer)

const render = () => {
  ReactDOM.render(<App store={store}/>,
  document.getElementById('root'))
}

render()
store.subscribe(render)

Muutos omaan moduuliinsa eriytettyyn komponenttiin App on pieni, storeen viitataan propsien kautta this.props.store:

import React from 'react'
import actionFor from './actionCreators'

class App extends React.Component {
  addNote = (event) => {
    event.preventDefault()
    this.props.store.dispatch(
      actionFor.noteCreation(event.target.note.value)
    )
    event.target.note.value = ''
  }
  toggleImportance = (id) => () => {
    this.props.store.dispatch(
      actionFor.importanceToggling(id)
    )
  }
  render() {
    return (
      <div>
        <form onSubmit={this.addNote}>
          <input name="note" />
          <button type="submit">lisää</button>
        </form>
        <ul>
          {this.props.store.getState().map(note =>
            <li key={note.id} onClick={this.toggleImportance(note.id)}>
              {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
            </li>
          )}
        </ul>
      </div>
    )
  }
}

export default App

Jos sovelluksessa on enemmän storea tarvitsevia komponentteja, tulee App-komponentin välittää store propseina kaikille sitä tarvitseville komponenteille.

Eriytetään uuden muistiinpanon luominen sekä muistiinpanojen lista ja yksittäisen muisiinpanon esittäminen omiksi komponenteiksi:

class NoteForm extends React.Component {
  addNote = (event) => {
    event.preventDefault()
    this.props.store.dispatch(
      actionFor.noteCreation(event.target.note.value)
    )
    event.target.note.value = ''
  }
  render() {
    return(
      <form onSubmit={this.addNote}>
        <input name="note" />
        <button>lisää</button>
      </form>
    )
  }
}

const Note = ({note, handleClick}) => {
  return(
    <li onClick={handleClick}>
      {note.content} <strong>{note.important ? 'tärkeä' : ''}</strong>
    </li>
  )
}

class NoteList extends React.Component {
  toggleImportance = (id) => () => {
    this.props.store.dispatch(
      actionFor.importanceToggling(id)
    )
  }
  render() {
    return(
      <ul>
        {this.props.store.getState().map(note =>
          <Note
            key={note.id}
            note={note}
            handleClick={this.toggleImportance(note.id)}
          />
        )}
      </ul>
    )
  }
}

Komponenttiin App ei jää enää paljoa koodia:

class App extends React.Component {
  render() {
    return (
      <div>
        <NoteForm store={this.props.store} />
        <NoteList store={this.props.store} />
      </div>
    )
  }
}

Toisin kuin aiemmin ilman Reduxia tekemässämme React-koodissa, tapahtumankäsittelijät on nyt siirretty pois App-komponentista. Yksittäisen muistiinpanon renderöinnistä huolehtiva Note on erittäin yksinkertainen, eikä ole tietoinen siitä, että sen propsina saama tapahtumankäsittelijä dispatchaa actionin. Tälläisiä komponentteja kutsutaan Reactin terminologiassa presentational-komponenteiksi.

NoteList taas on sellainen mitä kutsutaan container-komponenteiksi, se sisältää sovelluslogiikkaa, eli määrittelee mitä Note-komponenttien tapahtumankäsittelijät tekevät ja koordinoi presentational-komponenttien, eli Notejen konfigurointia.

Palaamme presentational/container-jakoon tarkemmin seuraavassa osassa.

storen välittäminen sitä tarvitseviin komponentteihin propsien avulla on melko ikävää. Vaikka App ei itse tarvitse storea, sen on otettava store vastaan, pystyäkseen välittämään sen edelleen komponenteille NoteForm ja NoteList.

Tutustumme vielä tämän osan lopuksi storen välittämiseen Reactin contextin avulla.

Manuaalin sanoin:

In some cases, you want to pass data through the component tree without having to pass the props down manually at every level. You can do this directly in React with the powerful “context” API.

Reactin Context API on vielä kokeellinen ja se voi hävitä tulevista versiosta. Contextin käyttö ei olekaan kovin suositeltavaa. Katsomme kuitenkin mistä on kyse.

Asennetaan ensin contextin käyttöä helpottava react-redux-kirjasto sekä contextien määrittelyyn tarvittava prop-types:

npm install react-redux prop-types --save

Muutetaan tiedostoa index.js seuraavasti:

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './App'
import noteReducer from './noteReducer'

const store = createStore(noteReducer)

const render = () => {
  ReactDOM.render(
    <Provider store={store}>
      <App/>
    </Provider>,
  document.getElementById('root'))
}

render()
store.subscribe(render)

Komponentti App on sijoitettu react-redux-kirjaston tarjoavan Provider komponentin lapseksi. Store on annettu Providerille propsina.

Provider määrittelee storen saataville komponentin App ja sen alikomponenttien kontekstista.

Koska App ei tarvitse itse storea, voi sen muuttaa muotoon:

class App extends React.Component {
  render() {
    return (
      <div>
        <NoteForm />
        <NoteList />
      </div>
    )
  }
}

NoteList muuttuu seuraavasti:

import React from 'react'
import PropTypes from 'prop-types'
import actionFor from '../actionCreators'
import Note from './Note'

class NoteList extends React.Component {
  toggleImportance = (id) => () => {
    this.context.store.dispatch(
      actionFor.importanceToggling(id)
    )
  }
  render() {
    return(
      <ul>
        {this.context.store.getState().map(note =>
          <Note
            key={note.id}
            note={note}
            handleClick={this.toggleImportance(note.id)}
          />
        )}
      </ul>
    )
  }
}

NoteList.contextTypes = {
  store: PropTypes.object
}

Muutos on siis hyvin pieni. Nyt propsien sijaan storen viite on this.context.store. Komponentille on myös pakko määritellä sen vastaanottaman kontekstin tyyppi:

NoteList.contextTypes = {
  store: PropTypes.object
}

Ilman määrittelyä konteksti jää tyhjäksi.

Komponenttiin NoteForm tehtävä muutos on samanlainen. Koska Note ei riipu millään tavalla storesta, se jää muuttumattomaksi.

Tehdään sovellukseen vielä yksi parannus. Lisätään storea käyttäviin komponentteihin NoteForm ja NoteList seuraavan koodin sisältävät metodit componentDidMount ja componentWillUnmount:

class NoteForm extends React.Component {
  componentDidMount() {
    const { store } = this.context
    this.unsubscribe = store.subscribe(() =>
      this.forceUpdate()
    )
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  // ...
}

Näin komponentit rekisteröityvät kuuntelemaan storessa tapahtuvia muutoksia ja niiden tapahtuessa uudelleenrenderöimään itsensä (ja lapsikomponenttinsa) metodilla forceUpdate.

Nyt pääsemme eroon tiedostossa index.js tapahtuneesta koko sovelluksen uudelleenrenderöinnistä ja koodi yksinkertaistuu muotoon.

ReactDOM.render(
  <Provider store={createStore(noteReducer)}>
    <App />
  </Provider>,
  document.getElementById('root')
)

Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part5-6.

Egghead.io:ssa on ilmaiseksi saatavilla Reduxin kehittäjän Dan Abramovin loistava tutoriaali Getting started with Redux. Neljässä viimeisessä videossa käytettävää connect-metodia käsittelemme vasta kurssin seuraavassa osassa.

Tehtäviä

Tee nyt tehtävät 5.19-5.21