Первоначально опубликовано 16 сентября 2018 г. в блоге Learn.co
Ничего себе, этот проект занял некоторое время, но это того стоило. Мне пришлось использовать большую часть имеющихся у меня знаний о React и Redux, которые я изучил за последние несколько недель, и это было захватывающе. Я знал, что мой проект выйдет далеко за пределы минимальных требований в 2 контейнера и 5 компонентов без сохранения состояния, поэтому я определенно подготовился к долгому пути. Я также немного беспокоился о потере электричества (и интернета) из-за урагана Флоренс, так как я живу в Северной Каролине, но это не стало проблемой. Теперь давайте погрузимся!
Первым шагом была настройка Rails API. Я выполнил следующую команду, когда оказался в каталоге файлов своего проекта:
rails new . --api --database=postgresql -T --no-rdoc --no-ri
Это сгенерировало новое приложение rails, использующее postgres в качестве базы данных (это здорово, если вы планируете развернуть свое приложение с помощью Heroku, которым я являюсь) и настроило модуль API, к которому я перейду через секунду.
Вторым шагом было создание приложения React с использованием create-react-app
внутри папки client
. После того, как npm, наконец, установил все необходимое для работы React, я настроил прокси-сервер, чтобы API вызывал все, используя правильный порт.
{
"name": "client",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:3001",
"dependencies": {
...
}
Здесь важно отметить ключ прокси, который указывает на http://localhost:3001"
— именно там живет сервер rails. Настройка прокси означает, что наш сервер React и наш сервер Rails будут без труда взаимодействовать друг с другом как во время разработки, так и во время производства.
Следующим шагом было определение отношений ActiveModel.
class Collection < ApplicationRecord has_many :items has_many :reviews end
class Item < ApplicationRecord belongs_to :collection end
class Review < ApplicationRecord belongs_to :collection end
class Post < ApplicationRecord has_many :comments end
class Comment < ApplicationRecord belongs_to :post end
Моя цель состояла в том, чтобы позволить пользователям создавать сообщения, а затем комментировать их, а также создавать коллекции и наполнять их элементами. Оставлять отзывы о коллекциях — это то же самое, что оставлять комментарии к сообщениям, за исключением того, что отзывы также включают рейтинг.
Следующим шагом было создание базы данных и ее заполнение. После этого я настроил маршруты и действия контроллера.
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :reviews
resources :items
resources :collections
resources :comments
resources :posts
end
end
end
Пространство имен маршрутов внутри /api/v1/ считается лучшей практикой. Что касается действий контроллера, то они были простыми — нужно было просто отрендерить JSON в случае успеха.
module Api::V1
class CollectionsController < ApplicationController before_action :set_collection, only: [:show, :update, :destroy]
# GET /collections def index @collections = Collection.order(:id)
render json: @collections end
# GET /collections/1 def show render json: @collection end
# POST /collections def create @collection = Collection.new(collection_params)
if @collection.save render json: @collection, status: :created else render json: @collection.errors, status: :unprocessable_entity end end
# PATCH/PUT /collections/1 def update if @collection.update(collection_params) render json: @collection else render json: @collection.errors, status: :unprocessable_entity end end
# DELETE /collections/1 def destroy @collection.destroy if @collection.destroy head :no_content, status: :ok else render json: @collection.errors, status: :unprocessable_entity end end
private # Use callbacks to share common setup or constraints between actions. def set_collection @collection = Collection.find(params[:id]) end
# Only allow a trusted parameter "white collection" through. def collection_params params.require(:collection).permit(:name, :description, :owner) end end
end
Точно так же настроены остальные 4 модели. Теперь, когда все контроллеры настроены для рендеринга JSON, переход к http://localhost:3001/api/v1/posts
возвращает наши данные в формате JSON, что было полезно для определения того, как их рендерить в нашем приложении React.
Наконец, мы приступили к созданию компонентов React! Моя базовая структура была в основном ComponentContainer -> Components -> Component. Например, мой PostsContainer
отображает мой компонент Posts
, который затем отображает каждый Post
component. Каждый компонент Post
рендерит свой собственный CommentsContainer
, который рендерит Comments
, который, наконец, рендерит Comment
. Линия компонентов Collection
была настроена таким же образом, с компонентами Item
и Review
, вложенными в каждый Collection
.
Мой процесс для каждой модели заключался в том, чтобы сначала определить конкретную функцию для действия. Эти функции находились в файле типа postActions.js
. Эти функции отвечали за получение всех данных из API Rails. Вторым шагом было определение оператора case внутри редьюсера модели. Все редукторы собрались в combineReducers
, чтобы в хранилище редуктов было только одно состояние. Третьим шагом было подключение редукционного хранилища к соответствующему компоненту контейнера и определение его функций mapStateToProps
и mapDispatchToProps
. После этого я передал бы соответствующие реквизиты каждому компоненту, который будет отображать контейнер. Ниже представлены мои готовые компоненты Collection
:
export function fetchCollections() { return (dispatch) => { dispatch({ type: 'LOADING_COLLECTIONS' }); return fetch('/api/v1/collections.json') .then(response => response.json()) .then(collectionData => dispatch({ type: 'FETCH_COLLECTIONS', payload: collectionData })); } }
export function addCollection(state) { return dispatch => { dispatch({ type: 'ADDING_COLLECTION' });
const collectionData = { collection: { name: state.name, description: state.description, owner: state.owner } }
fetch('/api/v1/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(collectionData) }) .then(response => response.json()) .then(collectionJSON => dispatch({ type: "ADDED_COLLECTION", id: collectionJSON.id, name: collectionJSON.name, description: collectionJSON.description, owner: collectionJSON.owner })); } }
export function deleteCollection(id) { return dispatch => { dispatch({ type: 'DELETING_COLLECTION' }); if (window.confirm("Are you sure?")) { fetch('/api/v1/collections/' + id, { method: 'DELETE' }) .then(response => dispatch({ type: 'DELETED_COLLECTION', id: id })) } } }
export function updateCollection(state) { return dispatch => { dispatch({ type: 'UPDATING_COLLECTION'});
const editedCollectionData = { collection: { id: state.id, name: state.name, description: state.description, owner: state.owner, items: state.items, reviews: state.reviews } }
fetch('/api/v1/collections/' + state.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editedCollectionData) }) .then(response => response.json()) .then(collectionJSON => dispatch({ type: 'UPDATED_COLLECTION', id: collectionJSON.id, name: collectionJSON.name, description: collectionJSON.description, owner: collectionJSON.owner, items: collectionJSON.items, reviews: collectionJSON.reviews })); } }
export default function collectionsReducer(state = { loading: false, collections: [], }, action) { switch (action.type) { case 'LOADING_COLLECTIONS': return { ...state, loading: true }
case 'FETCH_COLLECTIONS': return { loading: false, collections: action.payload }
case 'ADDED_COLLECTION': const collection = { id: action.id, name: action.name, description: action.description, owner: action.owner, items: [], reviews: [] };
return { ...state, collections: [...state.collections, collection] }
case 'DELETED_COLLECTION': return { ...state, collections: state.collections.filter(collection => collection.id !== action.id) }
case 'UPDATED_COLLECTION': const editedCollection = { id: action.id, name: action.name, description: action.description, owner: action.owner, items: action.items, reviews: action.reviews };
let newState = { ...state, collections: state.collections.map(collection => { if (collection.id !== action.id) { return collection; } return editedCollection; }) };
return newState;
default: return state; } }
import React, { Component } from 'react' import { connect } from 'react-redux' import Collections from '../components/collections/Collections' import { fetchCollections, addCollection, deleteCollection, updateCollection } from '../actions/collectionActions' import { addReview } from '../actions/reviewActions' import AddCollectionForm from '../components/collections/AddCollectionForm'
class CollectionsContainer extends Component { constructor(props) { super(props); this.state = { editCollectionId: null } }
toggleEditOn = id => { this.setState({ editCollectionId: id }) }
toggleEditOff = () => { this.setState({ editCollectionId: null }) }
componentDidMount() { this.props.fetchCollections() }
render() { return ( <div className="collections-container"> <h1>Your Collections Feed!</h1> <AddCollectionForm addCollection={this.props.addCollection} /> <Collections collections={this.props.collections} deleteCollection={this.props.deleteCollection} updateCollection={this.props.updateCollection} addReview={this.props.addReview} toggleEditOn={this.toggleEditOn} toggleEditOff={this.toggleEditOff} editCollectionId={this.state.editCollectionId} /> </div> ) } }
const mapStateToProps = state => ({ collections: state.collections.collections })
const mapDispatchToProps = dispatch => ({ fetchCollections: () => dispatch(fetchCollections()), addCollection: state => dispatch(addCollection(state)), deleteCollection: state => dispatch(deleteCollection(state)), updateCollection: state => dispatch(updateCollection(state)), addReview: state => dispatch(addReview(state)) })
export default connect(mapStateToProps, mapDispatchToProps)(CollectionsContainer)
import React, { Component, Fragment } from 'react' import Collection from './Collection' import EditCollectionForm from './EditCollectionForm' import ItemsContainer from '../../containers/ItemsContainer' import ReviewsContainer from '../../containers/ReviewsContainer'
class Collections extends Component { render() { return ( <div className="collections"> {this.props.collections.map(collection => { if (this.props.editCollectionId === collection.id) { return ( <Fragment key={collection.id}> <EditCollectionForm collection={collection} key={collection.id} updateCollection={this.props.updateCollection} toggleEditOff={this.props.toggleEditOff} /> <ItemsContainer collection={collection} key={collection.id} editCollectionId={this.props.editCollectionId} /> <ReviewsContainer collection={collection} key={collection.id} /> </Fragment> ) } else { return ( <Collection key={collection.id} collection={collection} deleteCollection={this.props.deleteCollection} toggleEditOn={this.props.toggleEditOn} addReview={this.props.addReview} /> ) } })} </div> ) } }
export default Collections
import React, { Component } from 'react' import ItemsContainer from '../../containers/ItemsContainer' import ReviewsContainer from '../../containers/ReviewsContainer.js' import Moment from 'react-moment' import 'moment-timezone' import AddReviewForm from '../reviews/AddReviewForm' import Button from '@material-ui/core/Button'
class Collection extends Component { constructor(props) { super(props); this.state = { addReviewStatus: false } }
toggleAddReview = () => { this.setState({ addReviewStatus: !this.state.addReviewStatus }) }
render() { const collection = this.props.collection
const buttonOrForm = () => !this.state.addReviewStatus ? <Button variant="contained" color="primary" onClick={this.toggleAddReview}>Add a review</Button> : <AddReviewForm collection={collection} toggleAddReview={this.toggleAddReview} addReview={this.props.addReview}/>
return ( <div className="collection"> <h2 className="collection-name">{collection.name}</h2> <h4>Owner: {collection.owner} (Created <Moment date={collection.created_at} fromNow />)</h4> <p>{collection.description}</p> <h6>Last updated: <Moment date={collection.updated_at} fromNow /></h6> <Button variant="contained" color="primary" onClick={() => this.props.toggleEditOn(collection.id)}>Edit this collection</Button> <Button variant="contained" color="secondary" onClick={() => this.props.deleteCollection(collection.id)}>Delete this collection</Button> {buttonOrForm()} <ItemsContainer collection={collection} /> <ReviewsContainer collection={collection} /> </div> ) } }
export default Collection
Ух ты, сколько кода только для одной из моих моделей! Наиболее важные выводы из этих фрагментов кода:
- Мои контейнеры определяют большинство нестандартных функций React, которые важны для работы моего приложения. Это связано с тем, что любая функция, которая нужна дочернему компоненту, может быть передана ему через свойства. В моем приложении состояние
editCollectionId
передается через реквизит несколько раз, так что оно достигает компонентаItem
! Это связано с тем, что мое приложение не позволяет пользователю редактировать элемент, если он сначала не нажмет кнопку «Редактировать коллекцию». Использование состояния и реквизита React делает это на удивление хорошо работающим с моими кнопками, компонентами и формами, которые плавно перетекают друг в друга. - Компонент
Collections
не имеет состояния, потому что все, что ему нужно сделать, это визуализировать компоненты - он использует реквизиты в своей логике, чтобы решить, отображать ли компонентEditCollectionForm
илиCollection
. - Я использовал компоненты Material-UI, чтобы придать моему приложению стиль. Я также сделал кое-какой пользовательский CSS в
App.css
.
В целом, этот окончательный проект был определенно самым сложным для создания, потому что нужно было сделать так много разных «компонентов» (извините, плохой каламбур). Тем не менее, я получил массу удовольствия, разбираясь в логике и функциях, необходимых для соединения фронтенда и бэкенда. После того, как я пройду техническую оценку, я, надеюсь, разверну свое приложение на Heroku, а затем, наконец, начну поиск работы! Вы можете ожидать от меня еще одного поста в блоге, когда наступит первая неделя моего поиска работы.