Praktyczny przykład - półka z książkami

Na podsumowanie dotychczas prezentowanych informacji przyjrzyjmy się trochę bardziej złożonemu przykładowi: aplikacji frontend do zarządzania półką z książkami:

https://github.com/tenarjw/bookshelf/tree/master/books-frontend

Pierwsza wersja tej aplikacji jest uruchomiona na stronie: https://stackblitz.com/edit/react-kwrizr (można śledzić jej działanie przeglądając / modyfikując źródła). Poniżej zamieszono opis tych źródeł z krótkim objaśnieniem.

1) Komunikację między serwerem a aplikacją zaimplementowano - w module api.js – zgodnie ze specyfikacją REST (https://wprzybylski.com/index.php/2016/11/20/najlepsze-praktyki-dla-rest-api/).

Zostały tu zastosowane identyczne rozwiązania, jak te opisane w podrozdziale "Komunikacja z serwerem") - z wykorzystaniem biblioteki axios. Oto przykładowy fragment (zawiera dwie funkcje: odczytu wszystkich książek i stworzenia nowej książki):

import axios from 'axios';
import querystring from 'querystring';

const SERVER = 'https://books.otwartaedukacja.pl/books';

const throwError = message => {
  console.error(message);
  alert(message);
  throw Error(message);
}

export const getBooks = async () => {
  const response = await axios(SERVER);
  if (response.data.errors) throwError(response.data.message);
  return response.data;
}

export const createBook = async (book) => {
  console.log(book);
  const response = await axios.post(SERVER, querystring.stringify(book));
  if (response.data.errors) throwError(response.data.message);
  return response.data;
}

2) Na podstawie tych funkcji stworzony akcje dla "store" Redux'a:

a) identyfikatory (actions/book_type.js)

export const ACTION_BOOKS_LOADED = 'ACTION_BOOKS_LOADED';
export const ACTION_BOOK_CREATED = 'ACTION_BOOK_CREATED';
export const ACTION_BOOK_UPDATED = 'ACTION_BOOK_UPDATED';
export const ACTION_BOOK_SELECT = 'ACTION_BOOK_SELECT';
export const ACTION_ED_STATE = 'ACTION_ED_STATE';

b) Implementacja (actions/books.js - znów tylko kilka operacji):

import * as Api from '../api';
import * as actionType from './books_type';

export const loadBooks = () => {
  return async dispatch => {
    const books = await Api.getBooks();
    dispatch({
      type: actionType.ACTION_BOOKS_LOADED,
      books: books
    });
  }
};

export const createBook = (values) => {
  return async dispatch => {
    const book = await Api.createBook(values);
    dispatch({
      type: actionType.ACTION_BOOK_CREATED,
      book: book
    });
  }
};

export const selectBook = (id) => {
  return dispatch => {
    dispatch({
      type: actionType.ACTION_BOOK_SELECT,
      id: id
    });
  }
};

export const changeEdState = (newEdState) => {
  return dispatch => {
    dispatch({
      type: actionType.ACTION_ED_STATE,
      edState : newEdState
    });
  }
};

Operacje są wykonywane asynchronicznie, a po ich zakończeniu wywoływany jest dla obróbki danych Reducer. Funkcje zaimplementowano z myślą o wykorzystaniu redux-thunk (będziemy przy tworzeniu store podawać powyższe funkcje wywołujące akcje, a nie fabryki akcji.

Dodano tu dwie akcje nie związane z transmisją:

  • selectBook: wybór jednej z listy ksążek
  • changeEdState: zmiana stanu panelu edycyjnego (czy pojawiają się przyciski edycji/tworzenia etc...)

3) Przejdźmy do definicji store Redux'a

Najpierw struktura pamięci:

const initialState = {
  books: null, // książki
  selected: null, // wybrana książka
  id : 0, // id wybranej książki
  edState : {  // jakie operacje / przyciski dostępne
    edAvailable : true, // może edytować
    edActive : false, // otwarty formularz edycji
    newAvailable : true, // może dodawać
    newActive : false, // w trakcie dodawania
    delAvailable : false // może usuwać
  }
};

Reduktor zmieniający tą pamięć dla podanych akcji:

const  booksReducer = (state=initialState, action) => {
  switch (action.type) {
    case actionType.ACTION_BOOK_SELECT:
      let sel = findBook(state.books, action.id);
      return {
        ...state,
        selected: sel,
        id: sel.id
      }
    case actionType.ACTION_ED_STATE:
        return {
          ...state,
          edState: action.edState
        }
    case actionType.ACTION_BOOKS_LOADED:
      return {
        ...state,
        selected : null,
        id : 0,
        books: action.books
      }
    case actionType.ACTION_BOOK_CREATED:
      return {
        ...state,
        selected : null,
        id : 0,
        books: [...state.books, action.book]
      }
    case actionType.ACTION_BOOK_UPDATED:
      return {
        ...state,
        selected : action.book,
        id : action.book.id,
        // Zamien stara wersje ksiazki o podanym ID na nowa
        books: state.books.map((book) => book.id === action.book.id ? action.book : book)
      }
    default:
      return state;
  }
}

W zasadzie nie powinno tu być większych niejasności. W obsłudze wyboru książki (ACTION_BOOK_SELECT) wykorzystano prostą funkcję wyszukania elementu z listy (findBook). Z kolei w ACTION_BOOK_UPDATED mamy podmianę jednego elementu listy (z wykorzystaniem funkcji map).

4) Utworzenie store (plik store.js):

import { applyMiddleware, compose, createStore } from 'redux'

import thunk from 'redux-thunk';
import logger from 'redux-logger';

import rootReducer from './reducers'

const middleware = [thunk, logger];

const store = createStore(
  rootReducer,
  undefined,
  applyMiddleware(...middleware)
);

export default store;

Wykorzystano kompozycję reduktorów (reducers/index.js) oraz dodatki middleware opisane w rozdziale dotyczącym Reduxa.

5) Tworzymy komponenty interfejsu aplikacji.

Komponenty są zawarte w dwóch plikach BookList.js i BookForm.js. Pierwszy z nich zawiera listę książek oraz pasek z przyciskami wykonującymi akcje. Lista ksążek to komponent BookList:

class BookList extends Component {

  wybrany = (id) => {
     this.props.selectBook(id);
  }

  componentDidMount = () => {
    this.props.loadBooks();
  }

  render() {
      if (!this.props.store.books) {
        return <div>wczytuję ....</div>;
      }
      let lista = [];
      for (let book of this.props.store.books) {
        lista.push(<tr  onClick={ () => { this.wybrany(book.id)} } key={book.id}>
          <td>{book.id}</td>
          <td>{book.title}</td>
          <td>{book.authors}</td>
          <td></td>
          </tr>);
      }
      return (
      <div>
        <div><BookSelected  id={this.props.store.id}  /></div>
        <Table striped bordered condensed hover>
          <thead>
            <tr>
              <th>#</th>
              <th>Title</th>
              <th>Author</th>
              <th>Description</th>
            </tr>
          </thead>
          <tbody>
            {lista}
          </tbody>
        </Table>
      </div>
      );
  }
}

Zwrócić należy uwagę na wykonanie operacji ładowania książek wykonaną w funkcji (vide: czykl życia komponentu) componentDidMount. Każdy wiersz jest wyposażony w obsługę kliknięcia (fynkcja wybrany), która zmienia store Redux'a (selectBook).

Wszystkie komponenty korzystają z centralnej pamięci (store) dostarczanej przez Redux (props.store.*). Dlatego potrzebujemy wykonania na nich funkcji connect. Na przykład:

const mapStateToProps = state => ({
  store: state.books
});

const mapDispatchToProps = (dispatch) => bindActionCreators(
  { loadBooks, selectBook, deleteBook, changeEdState },
  dispatch
);

BookList = connect(
  mapStateToProps,
  mapDispatchToProps
)(BookList);

Przed tabelką z książkami znajduje się komponent BookSelected, który wyświetla aktualnie wybraną książkę i przyciski z akcjami (to chyba najbardziej nietypowy - a przez to być może trudny - element aplikacji):

class BookSelected extends Component {

  swButtonEdit = () => {
    let st = this.props.store.edState;
    this.props.changeEdState(
      {
        ...st,
        newActive : false,
        edActive : !st.edActive
      }
    )
  }

  swButtonNew = () => {
    let st = this.props.store.edState;
    this.props.changeEdState(
      {
        ...st,
        edActive : false,
        newActive : !st.newActive
      }
    )
  }

  deleteBook = () => {
    if (this.props.store.selected)
      this.props.deleteBook(this.props.store.selected.id);
  }

  render = () => {
    let navBar = [];
    if (this.props.store.edState.newAvailable)
      navBar.push(<button onClick={this.swButtonNew}>Dodawanie</button>);
    if (this.props.store.selected) {
      if (this.props.store.edState.edAvailable)
        navBar.push(<button onClick={this.swButtonEdit}>Edycja</button>);
      if (this.props.store.edState.delAvailable)
        navBar.push(<button onClick={this.deleteBook}>Usuń</button>);
    }
    let form = null;
    if (this.props.store.edState.edActive)
      form=<BookEdit book={this.props.store.selected} id={this.props.store.id} />;
    if (this.props.store.edState.newActive)
      form=<BookCreate />;
    return ( <div>
        <div>{navBar}</div>
        <div> {this.props.store.selected &&
                this.props.store.selected.authors}
              {this.props.store.selected &&
                '"'+this.props.store.selected.title+'"'}
        </div>
        {form}
      </div>)
  }
}

Funkcje swButtonEdit i swButtonNew osbługują kliknięcia przycisków w navBar. Ten z kolei jest komponowany zgodnie z definicją w store.edState. W odpowiednim stanie wyświetlane są komponenty formularzy BookEdit i BookCreate. Obydwa wyświetlają BookCommonForm (zob. components/BookForm.js):

class BookCommonForm extends Component {
  constructor(props, context) {
    super(props, context);
    this.state = {
          id: null,
          authors: '',
          title: ''
        }
  }

  componentWillReceiveProps = (nextProps) => {
    if (this.state.id !== nextProps.id) 
     if (nextProps.book){
        this.setState({
          id: nextProps.id,
          title: nextProps.book.title,
          authors: nextProps.book.authors});
     } else {
        this.setState({id: nextProps.id});
     }
  }

  handleChangeAuthors = (e) => {
    this.setState({ authors: e.target.value });
  }

  handleChangeTitle = (e) => {
    this.setState({ title: e.target.value });
  }

  saveBook = () => {
    if (this.state.id>=0) {
      this.props.updateBook(this.state.id, this.state);   } else {
      this.props.createBook(this.state);
    }
  }

  render = () => {
    return(
    <form>
    <FormGroup controlId="id" >
      <ControlLabel>#</ControlLabel>
      <FormControl readOnly type="text" value={this.state.id} />
    </FormGroup>
    <FormGroup controlId="title" >
      <ControlLabel>Tytuł</ControlLabel>
      <FormControl type="text" value={this.state.title}
        placeholder="Wpisz tytuł"
        onChange={this.handleChangeTitle}
      />
    </FormGroup>
    <FormGroup controlId="authors" >
       <ControlLabel>Autorzy</ControlLabel>
       <FormControl  type="text" value={this.state.authors}
           placeholder="Wpisz autorów"
           onChange={this.handleChangeAuthors}
         />
    </FormGroup>
    <Button  onClick={ this.saveBook }>Zapisz</Button>
    </form>
   );
  }
}

Tu należy zwrócić uwagę przede wszystkim na funkcję wywoływaną w ramach cyklu życka komponentu: componentWillReceiveProps. Komponent zawiera formularz, który działa w oparciu o stan (state). Tymczasem informacje o edytowanej książce są przekazywane poprzez własności (props). Dlatego musimy wstępnie wypełnić forularz tymi wartościami.

Przepływ informacji po kliknięciu w tabelkę (spis książek):

1) Obsługa kliknięcia (BookList): this.props.selectBook(id); // Zmienia się state Redux'a. Do store.selected wpisuje wybraną książkę a do store.id - jej indeks.

2) Odświeża się BookSelected powiązany ze store.id

3) BookSelected zawiera: <BookEdit book={this.props.store.selected} id={this.props.store.id} />.A więc BookEdit aktualizuje się gdy zmieni się wybór książki (kliknięcie).

4) W BookEdit may z kolei wywołanie komponentu::

<BookCommonForm id={props.id} book={props.book}/>

5) Wywołanykomponent BookCommonForm zmienia się dzięki implementacji ComponentWillReceiveProps. Po tym można przystąpić do edycji danych.