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.