Первоначально опубликовано 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, который затем отображает каждый Postcomponent. Каждый компонент 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

Ух ты, сколько кода только для одной из моих моделей! Наиболее важные выводы из этих фрагментов кода:

  1. Мои контейнеры определяют большинство нестандартных функций React, которые важны для работы моего приложения. Это связано с тем, что любая функция, которая нужна дочернему компоненту, может быть передана ему через свойства. В моем приложении состояние editCollectionId передается через реквизит несколько раз, так что оно достигает компонента Item! Это связано с тем, что мое приложение не позволяет пользователю редактировать элемент, если он сначала не нажмет кнопку «Редактировать коллекцию». Использование состояния и реквизита React делает это на удивление хорошо работающим с моими кнопками, компонентами и формами, которые плавно перетекают друг в друга.
  2. Компонент Collections не имеет состояния, потому что все, что ему нужно сделать, это визуализировать компоненты - он использует реквизиты в своей логике, чтобы решить, отображать ли компонент EditCollectionForm или Collection.
  3. Я использовал компоненты Material-UI, чтобы придать моему приложению стиль. Я также сделал кое-какой пользовательский CSS в App.css.

В целом, этот окончательный проект был определенно самым сложным для создания, потому что нужно было сделать так много разных «компонентов» (извините, плохой каламбур). Тем не менее, я получил массу удовольствия, разбираясь в логике и функциях, необходимых для соединения фронтенда и бэкенда. После того, как я пройду техническую оценку, я, надеюсь, разверну свое приложение на Heroku, а затем, наконец, начну поиск работы! Вы можете ожидать от меня еще одного поста в блоге, когда наступит первая неделя моего поиска работы.