carrierwaveを使った画像アップロード with React
画像アップロードのスタートラインにやっと立てた感覚
それでも達成感がえぐいです。
要件
簡単な投稿CRUDアプリを想定。
投稿作成画面で投稿とそれに紐づく画像を1枚作成でき、投稿一覧にそれぞれの画像を表示させる
一覧、投稿、更新、削除ができるようにする
Postモデルはtitle,content,imageカラムを持つ
Rails
gem carrierwaveをインストールし、アップローダーを作成。今回はImageUploader
class Post < ApplicationRecord
  mount_uploader :image, ImageUploader
end
次にいつものようにコントローラーを作成。index, createを抜粋
class PostController < ApplicationController
  def index
    posts = Post.order(created_at: :desc)
    render json: posts
  end
  def create
    post = Post.new(post_params)
    if post.save
      render json: post
    else
      render json: post.errors
    end
  end
  def update
    post = Post.find(params[:id])
    if post.update(post_params)
      render json: post
    else
      render json: post.errors
    end
  end
  def destroy
    post = Post.find(params[:id])
    post.destroy!
    render json: post
  end
  private
  def post_params
    params.require(:post).permit(:title, :content, :image)
  end
end
ルーティング
root to: "posts#index"
resource :posts, only: %i[index create update destroy]
最後にrailsで使用するポートを3001に変更する(3000以外ならなんでも良い。Reactも3000番ポートを使用するため)
## config/puma.rb
port ENV.fetch("PORT") { 3001 }
React * TypeScript
今回作成するコンポーネントは....
- AllPosts: 投稿一覧ページ
- PostCard: それぞれの投稿をカード形式で表示するコンポーネント
- NewPost: 投稿作成ページ
*必要なライブラリーはインストール済みであるとする
NewPost
投稿作成ページであるNewPost.txsコンポーネントを作成
import { Heading, FormControl, FormLabel, Input, Box, Button, Stack } from "@chakra-ui/react";
import { ChangeEvent, memo, useState, VFC, } from "react";
import { useHistory } from "react-router-dom";
import axios from 'axios'
export const NewPost: VFC = memo(() => {
  const [newPostTitle, setNewPostTitle] = useState("")
  const [newPostContent, setNewPostContent] = useState("")
  const [image, setImage] = useState<File | >(null)
  const history = useHistory()
  const handlePostTitle = (e: ChangeEvent<HTMLInputElement>) => setNewPostTitle(e.target.value)
  const handlePostContent = (e: ChangeEvent<HTMLInputElement>) => setNewPostContent(e.target.value)
  const selectImage = (e: any) => {
    const selectedImage = e.target.files[0]
    setImage(selectedImage)
  }
  const createFormData = () => {
    const formData = new FormData()
    if (!image) return
    formData.append('post[title]', newPostTitle)
    formData.append('post[content]', newPostContent)
    formData.append('post[image]', image)
    return formData
  }
  const sendFormData = () => {
    const url = 'http://localhost:3001/posts'
    const data = createFormData()
    axios.post(url, data)
    .then(() => history.push('/'))
    .catch(e => {
      console.error(e)
    })
  }
  return (
    <>
      <Heading as="h1" color="teal" textAlign="center">NEW POST</Heading>
      <Box ml={6}>
        <Stack>
          <FormControl>
            <FormLabel>Title</FormLabel>
            <Input type="text" placeholder="some content" value={newPostTitle} onChange={handlePostTitle} />
          </FormControl>
          <FormControl>
            <FormLabel>Content</FormLabel>
            <Input type="text" placeholder="some content" value={newPostContent} onChange={handlePostContent} />
          </FormControl>
          <FormControl>
            <FormLabel>File</FormLabel>
            <Input type="file" onChange={(e) => selectImage(e)} />
            <Button onClick={sendFormData} bg="teal" color="white" mt={5}>Create</Button>
          </FormControl>
        </Stack>
      </Box>
    </>
  )
})
FormDataを作成し、対応するカラムに対応する値を格納し、パラメーターとして送る
AllPosts
投稿一覧を表示するコンポーネント
import { memo, useCallback, useEffect, useState, VFC } from "react";
import axios from 'axios'
import { PostCard } from "./PostCard";
import { Heading, Box, Wrap, WrapItem } from '@chakra-ui/react'
type Post = {
  id: number
  title: string
  content: string
  created_at: string
  image: {
    url: string
  }
}
export const AllPosts: VFC = memo(() => {
  const [posts, setPosts] = useState<Array<Post> | null>(null)
  const getAllPosts = useCallback(() => {
    axios.get('http://localhost:3001/posts')
      .then(res => {
        setPosts(res.data)
      })
      .catch((e) => console.error(e))
  },[])
  useEffect(() => {
    getAllPosts()
  }, [])
  return (
    <>
      <Box textAlign="center">
        <Heading color="gray.600">ALL POSTS</Heading>
      </Box>
      <Wrap p={{ base: 4, md: 10}}>
        {posts?.map((post) => (
          <WrapItem key={post.id}>
            <PostCard id={post.id} postTitle={post.title} postContent={post.content} postCreatedAt={post.created_at} imageUrl={post.image.url} getAllPosts={getAllPosts} />
          </WrapItem>
        ))}
      </Wrap>
    </>
  )
})
getAllPosts()...PostCard内で削除アクションを行った際に再度全ての投稿を表示させるための関数。propsとして渡すためuseCallbackを使ってみた
imageUrlとしてpost.image.urlを渡すことが味噌でした!
PostCard
import { memo, VFC } from "react";
import { Box, Image, Badge, Button, Flex } from '@chakra-ui/react'
import axios from 'axios'
type Props = {
  postTitle: string
  postContent: string
  postCreatedAt: string
  id: number
  imageUrl: string
  getAllPosts: () => void
}
export const PostCard: VFC<Props> = memo((props) => {
  const { postTitle, postContent, postCreatedAt, id, imageUrl, getAllPosts } = props
  const editPost = () => {
    alert("edit post")
  }
  const deletePost = (id: number) => {
    const sure = window.confirm("are you sure?")
    if (sure) {
      axios.delete(`http://localhost:3001/posts/${id}`)
      .then(() => {
        getAllPosts()
      })
      .catch(e => console.error(e))
    }
  }
  return (
    <Box maxW="sm" borderWidth="1px" borderRadius="lg" overflow="hidden">
      <Image style={{ width: "100%", height: "160px"}} src={imageUrl} alt="dummt image" />
      <Box p="6">
        <Box display="flex" alignItems="baseline">
          <Badge borderRadius="full" px="2" colorScheme="teal">
            {postTitle}
          </Badge>
          <Box
            color="gray.500"
            fontWeight="semibold"
            letterSpacing="wide"
            fontSize="xs"
            textTransform="uppercase"
            ml="2"
          >
            {postCreatedAt}
          </Box>
        </Box>
        <Box
          mt="1"
          fontWeight="semibold"
          as="h4"
          lineHeight="tight"
          isTruncated
        >
          {postContent}
        </Box>
        <Flex justifyContent="center">
          <Box mr={6}>
            <Button onClick={editPost} bg="teal" color="white">Edit</Button>
          </Box>
          <Box>
            <Button onClick={() => deletePost(id)} bg="red" color="white">Delete</Button>
          </Box>
        </Flex>
      </Box>
    </Box>
  )
})
