Kurssin seitsemännen osan tehtävät poikkeavat jossain määrin. Tässä luvussa on normaaliin tapaan kolme luvun teoriaan liittyvää tehtävää.

Tämän luvun tehtävien lisäksi seitsemäs osa sisältää kertaavan ja hieman tämän osan teoriaakin soveltavan tehtäväsarjan, jossa laajennetaan osissa 4 ja 5 tehtyä Bloglist-sovellusta.

Sovelluksen navigaatiorakenne

Palataan osan 6 jälkeen jälleen Reduxittoman Reactin pariin.

On erittäin tyypillistä, että web-sovelluksissa on navigaatiopalkki, jonka avulla on mahdollista vaihtaa sovelluksen näkymää. Muistiinpanosovelluksemme voisi sisältää pääsivun:

fullstack content

ja omat sivunsa muistiinpanojen ja käyttäjien tietojen näyttämiseen:

fullstack content

Vanhan koulukunnan websovelluksessa sovelluksen näyttämän sivun vaihto tapahtui siten että selain teki palvelimelle uuden HTTP GET -pyynnön ja renderöi sitten palvelimen palauttaman näkymää vastaavan HTML-koodin.

Single page appeissa taas ollaan todellisuudessa koko ajan samalla sivulla, ja selaimessa suoritettava Javascript-koodi luo illuusion eri "sivuista". Jos näkymää vaihdettaessa tehdään HTTP-kutsuja, niiden avulla haetaan ainoastaan JSON-muotoista dataa, jota uuden näkymän näyttäminen ehkä edellyttää.

Navigaatiopalkki ja useita näkymiä sisältävä sovellus on erittäin helppo toteuttaa Reactilla.

Seuraavassa on eräs tapa:

import React, { useState } from 'react'
import ReactDOM from 'react-dom'

const Home = () => (
  <div> <h2>TKTL notes app</h2> </div>
)

const Notes = () => (
  <div> <h2>Notes</h2> </div>
)

const Users = () => (
  <div> <h2>Users</h2> </div>
)

const App = () => {
  const [page, setPage] = useState('home')

 const  toPage = (page) => (event) => {
    event.preventDefault()
    setPage(page)
  }

  const content = () => {
    if (page === 'home') {
      return <Home />
    } else if (page === 'notes') {
      return <Notes />
    } else if (page === 'users') {
      return <Users />
    }
  }

  const padding = {
    padding: 5
  }

  return (
    <div>
      <div>
        <a href="" onClick={toPage('home')} style={padding}>
          home
        </a>
        <a href="" onClick={toPage('notes')} style={padding}>
          notes
        </a>
        <a href="" onClick={toPage('users')} style={padding}>
          users
        </a>
      </div>

      {content()}
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

Eli jokainen näkymä on toteutettu omana komponenttinaan ja sovelluksen tilassa page pidetään tieto siitä, minkä näkymää vastaava komponentti menupalkin alla näytetään.

Menetelmä ei kuitenkaan ole optimaalinen. Kuten kuvista näkyy, sivuston osoite pysyy samana vaikka välillä ollaankin eri näkymässä. Jokaisella näkymällä tulisi kuitenkin olla oma osoitteensa, jotta esim. bookmarkien tekeminen olisi mahdollista. Sovelluksessamme ei myöskään selaimen back-painike toimi loogisesti, eli back ei vie edelliseksi katsottuun sovelluksen näkymään vaan jonnekin ihan muualle. Jos sovellus kasvaisi suuremmaksi ja sinne haluttaisiin esim. jokaiselle käyttäjälle sekä muistiinpanolle oma yksittäinen näkymänsä, itse koodattu reititys eli sivuston navigaationhallinta menisi turhan monimutkaiseksi.

Reactissa on onneksi valmis komponentti React router joka tarjoaa erinomaisen ratkaisun React-sovelluksen navigaation hallintaan.

Muutetaan ylläoleva sovellus käyttämään React routeria. Asennetaan React router komennolla

npm install --save react-router-dom

React routerin tarjoama reititys saadaan käyttöön muuttamalla sovellusta seuraavasti:

import {
  BrowserRouter as Router,
  Route, Link, Redirect, withRouter
} from 'react-router-dom'

const App = () => {

  const padding = { padding: 5 }

  return (
    <div>
      <Router>
        <div>
          <div>
            <Link style={padding} to="/">home</Link>
            <Link style={padding} to="/notes">notes</Link>
            <Link style={padding} to="/users">users</Link>
          </div>
          <Route exact path="/" render={() => <Home />} />
          <Route path="/notes" render={() => <Notes />} />
          <Route path="/users" render={() => <Users />} />
        </div>
      </Router>
    </div>
  )
}

Reititys, eli komponenttien ehdollinen, selaimen urliin perustuva renderöinti otetaan käyttöön sijoittamalla komponentteja Router-komponentin lapsiksi, eli Router-tagien sisälle.

Huomaa, että vaikka komponenttiin viitataan nimellä Router kyseessä on BrowserRouter, sillä importtaus tapahtuu siten, että importattava olio uudelleennimetään:

import {
  BrowserRouter as Router,
  Route, Link, Redirect, withRouter
} from 'react-router-dom'

Manuaalin mukaan

BrowserRouter is a Router that uses the HTML5 history API (pushState, replaceState and the popState event) to keep your UI in sync with the URL.

Normaalisti selain lataa uuden sivun osoiterivillä olevan urlin muuttuessa. HTML5 history API:n avulla BrowserRouter kuitenkin mahdollistaa sen, että selaimen osoiterivillä olevaa urlia voidaan käyttää React-sovelluksen sisäiseen "reitittämiseen", eli vaikka osoiterivillä oleva url muuttuu, sivun sisältöä manipuloidaan ainoastaan Javascriptillä ja selain ei lataa uutta sisältöä palvelimelta. Selaimen toiminta back- ja forward-toimintojen ja bookmarkien tekemisen suhteen on kuitenkin loogista, eli toimii kuten perinteisillä web-sivuilla.

Routerin sisälle määritellään selaimen osoiteriviä muokkaavia linkkejä komponentin Link avulla. Esim.

<Link to="/notes">notes</Link>

luo sovellukseen linkin, jonka teksti on notes ja jonka klikkaaminen vaihtaa selaimen osoiteriville urliksi /notes.

Selaimen urliin perustuen renderöitävät komponentit määritellään komponentin Route avulla. Esim.

<Route path="/notes" render={() => <Notes />} />

määrittelee, että jos selaimen osoiteena on /notes, renderöidään komponentti Notes.

Sovelluksen juuren, eli osoitteen / määritellään renderöivän komponentti Home:

<Route exact path="/" render={() => <Home />} />

joudumme käyttämään routen path attribuutin edessä määrettä exact, muuten Home renderöityy kaikilla muillakin poluilla, sillä juuri / on kaikkien muiden polkujen alkuosa.

Parametroitu route

Tarkastellaan sitten hieman modifioitua versiota edellisestä esimerkistä. Esimerkin koodi kokonaisuudessaan on täällä.

Sovellus sisältää nyt viisi eri näkymää, joiden näkyvyyttä kontrolloidaan routerin avulla. Edellisestä esimerkistä tuttujen komponenttien Home, Notes ja Users lisäksi mukana on kirjautumisnäkymää vastaava Login ja yksittäisen muistiinpanon näkymää vastaava Note.

Home ja Users ovat kuten aiemmassa esimerkissä. Notes on hieman monimutkaisempi, se renderöi propseina saamansa muistiinpanojen listan siten, että jokaisen muistiinpanon nimi on klikattavissa

fullstack content

Nimen klikattavuus on toteutettu komponentilla Link ja esim. muistiinpanon, jonka id on 3 nimen klikkaaminen aiheuttaa selaimen osoitteen arvon päivittymisen muotoon notes/3:

const Notes = (props) => (
  <div>
    <h2>Notes</h2>
    <ul>
      {props.notes.map(note =>
        <li key={note.id}>
          <Link to={`/notes/${note.id}`}>{note.content}</Link>
        </li>
      )}
    </ul>
  </div>
)

Kun selain siirtyy muistiinpanon yksilöivään osoitteeseen, esim. notes/3, renderöidään komponentti Note:

const Note = ({ note }) => (
  <div>
    <h2>{note.content}</h2>
    <div>{note.user}</div>
    <div><strong>{note.important ? 'important' : ''}</strong></div>
  </div>
)

Tämä tapahtuu laajentamalla komponentissa App olevaa reititystä seuraavasti:

<Router>
  <div>
    <div>
      <Link style={padding} to="/">home</Link>
      <Link style={padding} to="/notes">notes</Link>
      <Link style={padding} to="/users">users</Link>
    </div>

    <Route exact path="/" render={() =>
      <Home />
    } />
    <Route exact path="/notes" render={() =>      <Notes notes={notes} />    } />    <Route exact path="/notes/:id" render={({ match }) =>      <Note note={noteById(match.params.id)} />    } />  </div></Router>

Kaikki muistiinpanot renderöivään routeen on lisätty määre exact path="/notes" sillä muuten se renderöityisi myös /notes/3-muotoisten polkujen yhteydessä.

Yksittäisen muistiinpanon näkymän renderöivä route määritellään "expressin tyyliin" merkkaamalla reitin parametrina oleva osa merkinnällä :id

<Route exact path="/notes/:id" />

Renderöityvän komponentin määrittävä render-attribuutti pääsee käsiksi id:hen parametrinsa match avulla seuraavasti:

render={({ match }) =>
  <Note note={noteById(match.params.id)} />}

Muuttujassa match.params.id olevaa id:tä vastaava muistiinpano selvitetään apufunktion noteById avulla

const noteById = (id) =>
  notes.find(note => note.id === Number(id))

renderöityvä Note-komponentti saa siis propsina urlin yksilöivää osaa vastaavan muistiinpanon.

withRouter ja history

Sovellukseen on myös toteutettu erittäin yksinkertainen kirjautumistoiminto. Jos sovellukseen ollaan kirjautuneena, talletetaan tieto kirjautuneesta käyttäjästä komponentin App tilaan user.

Mahdollisuus Login-näkymään navigointiin renderöidään menuun ehdollisesti

<Router>
  <div>
    <div>
      <Link style={padding} to="/">home</Link>
      <Link style={padding} to="/notes">notes</Link>
      <Link style={padding} to="/users">users</Link>
      {user        ? <em>{user} logged in</em>        : <Link to="/login">login</Link>      }    </div>
  </div>
</Router>

eli jos käyttäjä on kirjautunut, renderöidäänkin linkin Login sijaan kirjautuneen käyttäjän käyttäjätunnus:

fullstack content

Kirjautumisesta huolehtivan komponentin koodi seuraavassa

import {
  // ...
  withRouter} from 'react-router-dom'

const LoginNoHistory = (props) => {
  const onSubmit = (event) => {
    event.preventDefault()
    props.onLogin('mluukkai')
    props.history.push('/')  }

  return (
    <div>
      <h2>login</h2>
      <form onSubmit={onSubmit}>
        <div>
          username: <input />
        </div>
        <div>
          password: <input type='password' />
        </div>
        <button type="submit">login</button>
      </form>
    </div>
  )
}

const Login = withRouter(LoginNoHistory)

Lomakkeen toteutukseen liittyy muutama huomionarvoinen seikka. Kirjautumisen yhteydessä funktiossa onSubmit kutsutaan propseina vastaanotetun history-olion metodia push. Käytetty komento props.history.push('/') saa aikaan sen, että selaimen osoiteriville tulee osoitteeksi / ja sovellus renderöi osoitetta vastaavan komponentin Home.

Komponentti saa käyttöönsä propsin history siten, että se "kääritään" funktiolla withRouter. Funktio withRouter toimii hyvin samaan tapaan kuin Reduxin yhteydessä käytetty connect, se lisää parametrina saamallensa komponentille joukon propseja.

redirect

Näkymän Users routeen liittyy vielä eräs mielenkiintoinen detalji:

<Route path="/users" render={() =>
  user ? <Users /> : <Redirect to="/login" />
} />

Jos käyttäjä ei ole kirjautuneena, ei renderöidäkään näkymää Users vaan sen sijaan uudelleenohjataan käyttäjä Redirect-komponentin avulla kirjautumisnäkymään

<Redirect to="/login" />

Todellisessa sovelluksessa olisi kenties parempi olla kokonaan näyttämättä navigaatiovalikossa kirjautumista edellyttäviä näkymiä jos käyttäjä ei ole kirjautunut sovellukseen.

Seuraavassa vielä komponentin App koodi kokonaisuudessaan:

const App = () => {
  const [notes, setNotes] = useState([
    {
      id: 1,
      content: 'HTML on helppoa',
      important: true,
      user: 'Matti Luukkainen'
    },
    // ...
  ])

  const [user, setUser] = useState(null)

  const login = (user) => {
    setUser(user)
  }

  const noteById = (id) =>
    notes.find(note => note.id === Number(id))

  const padding = { padding: 5 }

  return (
    <div>
      <Router>
        <div>
          <div>
            <Link style={padding} to="/">home</Link>
            <Link style={padding} to="/notes">notes</Link>
            <Link style={padding} to="/users">users</Link>
            {user
              ? <em>{user} logged in</em>
              : <Link to="/login">login</Link>
            }
          </div>

          <Route exact path="/" render={() =>
            <Home />
          } />
          <Route exact path="/notes" render={() =>
            <Notes notes={notes} />
          } />
          <Route exact path="/notes/:id" render={({ match }) =>
            <Note note={noteById(match.params.id)} />
          } />
          <Route path="/users" render={() =>
            user ? <Users /> : <Redirect to="/login" />
          } />
          <Route path="/login" render={() =>
            <Login onLogin={login} />
          } />
        </div>
      </Router>
      <div>
        <br />
        <em>Note app, Department of Computer Science 2019</em>
      </div>
    </div>
  )
}

Komponentin sisällössä määritellään myös kokonaan Router:in ulkopuolella oleva nykyisille web-sovelluksille tyypillinen footer-elementti, eli sivuston pohjalla oleva osa, joka on näkyvillä riippumatta siitä mikä komponentti sovelluksen reititetyssä osassa näytetään.