タグ
タグとはコンテンツと共に多対多の関係にある仕組みである。 タグは多くのサービスで利用されてて、 twitterのハッシュタグやYoutubeの動画タグ、インスタグラムのタグなど、 基本的にはユーザーが独自に作成できるものである。
多対多のモデルで下のように、PostTagという中間テーブルを作成することで表現する。
Post --< PostTag >-- Tag
モデルの作成
まずは、TagモデルとPostTagモデルを作成する。
bin/rails g model Tag name:string
bin/rails g model PostTag post:references tag:references
Tagのnameフィールドにindexとuniqueを持たせる。
class CreateTags < ActiveRecord::Migration[6.1]
def change
create_table :tags do |t|
t.string :name
t.timestamps
end
add_index :tags, [:name], unique: true
end
end
中間テーブルのPostTagのマイグレーションファイルを開いて、mysqlインデックスを追加する。 これはFavoritePostでもやったコンテンツの重複をデータベースレベルでも防ぐ記述である。
class CreatePostTags < ActiveRecord::Migration[6.1]
def change
create_table :post_tags do |t|
t.references :post, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
t.timestamps
end
add_index :post_tags, [:post_id, :tag_id], unique: true
end
end
マイグレーションを適応させる。
bin/rails db:migrate
git add .
git commit -m "Create Tag and PostTag mdoels"
タグの仕組みの実装
タグの仕組みが機能するようにリレーションと設定を行う。
最初にTagモデルのリレーションとバリデーションを行う。 中間テーブルを通して、TagモデルからPostを取得できるようにする。
class Tag < ApplicationRecord
has_many :post_tags, dependent: :destroy, foreign_key: 'tag_id'
has_many :posts, through: :post_tags
validates :name, uniqueness: true, presence: true
private
def json_for_default
{}
end
end
Postモデル側からもtagを取得できるようにリレーションを張る。 その下あたりに、フロントからタグを受け取って、処理するall_tags要素代入関数を作成する。
has_many :post_tags, dependent: :destroy
has_many :tags, through: :post_tags
def all_tags=(names)
tmp_tags = names.split(",").map do |name|
{name: name, created_at: DateTime.current, updated_at: DateTime.current}
end
Tag.upsert_all(tmp_tags) if tmp_tags.any?
self.tags = Tag.where(name: names.split(","))
end
postsコントローラーを開いて、all_tagsを受け取れるようにする。
def post_params
params.require(:post).permit(:user_id, :title, :content, :thumbnail, :remove_thumbnail, :all_tags)
end
たったのこれだけでタグの処理は完成する。 参考にしたサイトはこちら。
https://www.sitepoint.com/tagging-scratch-rails/
all_tags=関数はタグの処理を行っている。
タグの処理は分解するとこの3つのステップが必要になる。
- クライアントからゼロまたは1つ以上のタグを受け取る
- Postに付与された1つ以上のタグの中で未作成のものがあればタグを作成する
- Postに付与された最新の状態のPostTagリレーションを作成または削除する
このステップを上のコードに照らし合わせて1つ1つ紹介する。
1、all_tags=はnamesを受け取る。これはposts_controller.rbのpost_paramsに:all_tagsを追加することで代入される。
フロントからはカンマ区切りのalice,bob,catのようなタグを表す文字列が飛ばされてくる。
2、バルクインサート用のオブジェクトを作成する。カンマ区切りで配列を回し、データベースに収められるような形式にフォーマットする。
それをtmp_tagsに代入し、Tag.upsert_all(tmp_tags) if tmp_tags.any? でバルクインサートする。
upsert_allはオブジェクトを全てデータベースに挿入するが、すでに存在する値の場合は無視される。
そもそも、バルクインサートにした理由はタグが10個などある場合、その存在のチェックと作成をmapで回すとデータベースアクセスが10回発生してしまい、
処理に問題が出ると考えて一度に処理ができるバルクインサートにしてある。
3、バルクインサートした戻り値ではTagのモデルを受け取れないので、改めて、Tagをnameで検索しTagモデルを取得し、Post.tagsに代入する。
Postに付随するTagの関係が新しくなりPosts controllerのsaveまたはupdateの処理の時にPostTagのリレーションがリフレッシュされ、
付随するタグが増えていたら、新しくPostTagのレコードを追加し、削除されてたら、PostTagのレコードが削除される。
最後にposts_controllerのindexとshowアクションを編集して、タグを返すようにする。
def index
@posts = Post.eager_load(:user, :favorite_posts, :tags).map { |post|
post.is_favorite = post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil) }.first
post
}
render json: @posts, json_for: :list, include: {user: {json_for: :public}, tags: {json_for: :default}}
end
def show
@post = Post.eager_load(:user,:favorite_posts, :tags).find( params[:id])
@post.is_favorite = @post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil) }.first
render json: @post, json_for: :object, include: {user: {json_for: :public}, tags: {json_for: :default}}
end
git add .
git commit -m "Create tag system"
タグのフロントの実装
Next.js側に移動して、今度はタグの入力の仕組みを実装する。 このサイトの実装を参考にした。 https://dev.to/0shuvo0/lets-create-an-add-tags-input-with-react-js-d29
最初にタグのインプットフォームのコンポーネントを作成する。
touch components/TagsInput.js
import { useState } from 'react'
import { Box, Clickable, Svg, TextField } from '../atomic/'
function TagsInput({ tags, setTags }) {
function handleKeyDown(e) {
if (e.key !== 'Enter') return
const value = e.target.value
if (!value.trim()) return
setTags([...tags, value])
e.target.value = ''
}
function removeTag(index) {
setTags(tags.filter((el, i) => i !== index))
}
return (
<Box display="flex" border="1px solid #000" p=".5em" borderRadius={3} width="100%" mt="1em" alignItems="center" flexWrap="wrap" style={{ gap: '.5em' }}>
{tags.map((tag, index) => (
<Box display="inline-block" bg="whitesmoke" p=".5em .75em" borderRadius={20} key={index}>
<span className="text">{tag}</span>
<Clickable as="span" height={20} width={20} bg="skyblue" borderRadius="50%" display="inline-flex" justifyContent="center" alignItems="center" ml=".5em" onClick={() => removeTag(index)}>
<Svg name="Close" width={10} height={10} fill="white" />
</Clickable>
</Box>
))}
<TextField p=".5em 0" onKeyDown={handleKeyDown} type="text" placeholder="タグを入力..." style={{ flexGrow: '1' }} />
</Box>
)
}
export default TagsInput
postのnewページに移動して、TagsInputを利用する。
import TagsInput from '../../components/TagsInput'
タグの状態を持つ配列を作成。
export default function PostsNew() {
...
const [tags, setTags] = useState([])
...
}
onSubmit内のフォームオブジェクト作成の箇所に、tags配列をカンマで区切った文字列に変換してall_tagsとして登録する。
const onSubmit = async (data) => {
...
params.append('post[all_tags]', tags.join(','))
...
}
jsxにタグフォームを追加する。
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
タグ
</Text>
<TagsInput tags={tags} setTags={setTags} />
このような見た目になる

同じようにedit.jsxにも実装する。
import TagsInput from '../../../components/TagsInput'
tagsの配列を宣言する。
export default function PostsEdit() {
...
const [tags, setTags] = useState([])
...
}
editの場合はサーバーにpost.tagsを問い合わせて取得できる場合があるので、それをtagsにセットする。
const handleGetPost = async () => {
if (id) {
try {
const res = await getPost(id)
if (res.status === 200) {
setPost(res?.data)
setValue('title', res.data.title)
setValue('content', res.data.content)
setTags(res.data.tags.map((tag) => tag.name))
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}
}
onSubmitでは同じようにtagsの値をall_tagsに渡す。
const onSubmit = async (data) => {
...
params.append('post[all_tags]', tags.join(','))
...
}
jsxも同じ。
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
タグ
</Text>
<TagsInput tags={tags} setTags={setTags} />

git add .
git commit -m "Create tags input form"
タグの取得と表示
最初にrails側で新たなコントローラーを作成する。 Tags::Posts Controllerはtagのnameを受け取り、それに関連するpostsの一覧を返す。
bin/rails g controller tags/posts
これのroutingが結構癖がある。
resources :tags, param: :name, only: [] do
member do
scope module: :tags do
resources :posts, only: %i[index]
end
end
end
これでルーティングを確認すると、このようになる。
:nameをキーにして、postsを返すようにしてみる。
% bin/rails routes
Prefix Verb URI Pattern Controller#Action
posts GET /tags/:name/posts(.:format) tags/posts#index
コードを記述
class Tags::PostsController < ApplicationController
def index
@posts = Tag.find_by(name: params[:name]).posts.eager_load(:user, :favorite_posts, :tags).map { |post|
post.is_favorite = post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil) }.first
post
}
render json: @posts, json_for: :list, include: {user: {json_for: :public}, tags: {json_for: :default}}
end
end
API側はこれで完成。
git add .
git commit -m "Create a tags::posts_controller"
Next.js側でまずはトップページにタグを表示する。 jsxを加える場所はいいねや作成日があるところに加える。
...
<Box display="flex" position="absolute" bottom={0} right={0}>
<Box display="flex" style={{ gap: '6px' }} mr={15}>
{post.tags.map((tag) => (
<Link href={'/tags/' + tag.name} key={tag.id}>
<Text display="flex" color="dimgray" fontSize={12} style={{ textDecoration: 'underline' }}>
{tag.name}
</Text>
</Link>
))}
</Box>
<Text display="flex" color="dimgray" fontSize={12} mr={15}>
{post.favoritePostsCount}
<Text display="inline" ml={2}>
いいね
</Text>
</Text>
<Text color="dimgray" fontSize={12}>
{post.createdAt}
</Text>
</Box>
...

Postの詳細にもタグを一番下あたりに表示する。
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
タグ
</Text>
<Box display="flex" style={{ gap: '8px' }}>
{post?.tags.map((tag) => (
<Link href={'/tags/' + tag.name} key={tag.id}>
<Text
fontSize={18}
lineHeight="1.71"
letterSpacing={0.7}
color="dimgray"
style={{ textDecoration: 'underline' }}
>
{tag.name}
</Text>
</Link>
))}
</Box>

タグに紐づくPostを取得するAPI関数を作成する。
export const getTagsPosts = (name) => {
if (!Cookies.get('_access_token') || !Cookies.get('_client') || !Cookies.get('_uid')) {
return client.get(`/tags/${name}/posts`)
} else {
return client.get(`/tags/${name}/posts`, {
headers: {
'access-token': Cookies.get('_access_token') || '',
client: Cookies.get('_client') || '',
uid: Cookies.get('_uid') || '',
},
})
}
}
タグに紐づくPostの一覧ページを作成
mkdir pages/tags
touch pages/tags/[name].jsx
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { getTagsPosts, createFavoritePost, destroyFavoritePost } from '../../lib/api/post'
import { Box, Text, Link, Svg, Clickable } from '../../atomic/'
import Header from '../../components/Header'
import Footer from '../../components/Footer'
export default function TagsPosts() {
const router = useRouter()
const [posts, setPosts] = useState([])
const { name } = router.query
const handleGetPosts = async () => {
if (name) {
try {
const res = await getTagsPosts(name)
if (res?.data.length > 0) {
setPosts(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}
}
useEffect(() => {
handleGetPosts()
}, [name])
const handleFavoritePost = async (post) => {
try {
const res = post.isFavorite > 0 ? await destroyFavoritePost(post.id) : await createFavoritePost(post.id)
console.log(res)
if (res.status === 201 || res.status === 204) {
const res = await getTagsPosts(name)
setPosts(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}
return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Header />
<Box display="flex" alignItems="center">
<Box mx="auto" mt={120} borderBottomWidth={1} borderColor="gray">
{posts.map((post) => {
return (
<Box key={post.id} position="relative" width={[380, null, 440]} height={[80, null, 120]} borderTopWidth={1} borderColor="gray" py={[16, null, 18]} px={[6, null, 8.5]}>
<Box display="flex" position="absolute" top={[16, null, 18]} right={[6, null, 8.5]}>
<Clickable onClick={() => handleFavoritePost(post)} display="flex">
<Svg name="Star" height={14} width={14} my="auto" fill={post.isFavorite > 0 ? 'gold' : null} />
</Clickable>
</Box>
<Box display="flex">
<Text color="dimggray" fontSize={[18, null, 21]}>
{post.id},
</Text>
<Link href={`/posts/${post.id}`} color="gray" hoverColor="darkgray" style={{ textDecoration: 'underline' }}>
<Text color="black" fontSize={[18, null, 21]} ml={6}>
{post.title}
</Text>
</Link>
</Box>
<Box display="flex" position="absolute" bottom={0} right={0}>
<Box display="flex" style={{ gap: '6px' }} mr={15}>
{post.tags.map((tag) => (
<Link href={'/tags/' + tag.name} key={tag.id}>
<Text display="flex" color="dimgray" fontSize={12} style={{ textDecoration: 'underline' }}>
{tag.name}
</Text>
</Link>
))}
</Box>
<Text display="flex" color="dimgray" fontSize={12} mr={15}>
{post.favoritePostsCount}
<Text display="inline" ml={2}>
いいね
</Text>
</Text>
<Text color="dimgray" fontSize={12}>
{post.createdAt}
</Text>
</Box>
</Box>
)
})}
</Box>
</Box>
<Footer />
</main>
</div>
ppp )
}

git add .
git commit -m "Create a tags posts page"