Skip to main content

タグ

タグとはコンテンツと共に多対多の関係にある仕組みである。 タグは多くのサービスで利用されてて、 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を持たせる。

db/migrate/20220914022313_create_tags.rb
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でもやったコンテンツの重複をデータベースレベルでも防ぐ記述である。

db/migrate/20220914022336_create_post_tags.rb
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を取得できるようにする。

app/models/tag.rb
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要素代入関数を作成する。

app/models/post.rb
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を受け取れるようにする。

app/controllers/posts_controller.rb
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. クライアントからゼロまたは1つ以上のタグを受け取る
  2. Postに付与された1つ以上のタグの中で未作成のものがあればタグを作成する
  3. Postに付与された最新の状態のPostTagリレーションを作成または削除する

このステップを上のコードに照らし合わせて1つ1つ紹介する。

1all_tags=namesを受け取る。これはposts_controller.rbpost_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のindexshowアクションを編集して、タグを返すようにする。

app/controllers/posts_controller.rb
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
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を利用する。

pages/posts/new.jsx
import TagsInput from '../../components/TagsInput'

タグの状態を持つ配列を作成。

pages/posts/new.jsx
export default function PostsNew() {

...

const [tags, setTags] = useState([])

...

}

onSubmit内のフォームオブジェクト作成の箇所に、tags配列をカンマで区切った文字列に変換してall_tagsとして登録する。

pages/posts/new.jsx
const onSubmit = async (data) => {

...

params.append('post[all_tags]', tags.join(','))

...

}

jsxにタグフォームを追加する。

pages/posts/new.jsx
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
タグ
</Text>
<TagsInput tags={tags} setTags={setTags} />

このような見た目になる

tag new

同じようにedit.jsxにも実装する。

pages/posts/[id]/edit.jsx
import TagsInput from '../../../components/TagsInput'

tagsの配列を宣言する。

pages/posts/[id]/edit.jsx
export default function PostsEdit() {

...

const [tags, setTags] = useState([])

...

}

editの場合はサーバーにpost.tagsを問い合わせて取得できる場合があるので、それをtagsにセットする。

pages/posts/[id]/edit.jsx
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に渡す。

pages/posts/[id]/edit.jsx
const onSubmit = async (data) => {

...

params.append('post[all_tags]', tags.join(','))

...

}

jsxも同じ。

pages/posts/[id]/edit.jsx
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
タグ
</Text>
<TagsInput tags={tags} setTags={setTags} />

edit tags

git add .
git commit -m "Create tags input form"

タグの取得と表示

最初にrails側で新たなコントローラーを作成する。 Tags::Posts Controllerはtagのnameを受け取り、それに関連するpostsの一覧を返す。

bin/rails g controller tags/posts

これのroutingが結構癖がある。

config/routes.rb
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

コードを記述

app/controllers/tags/posts_controller.rb
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を加える場所はいいねや作成日があるところに加える。

pages/index.js

...

<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>

...

posts with tag

Postの詳細にもタグを一番下あたりに表示する。

pages/posts/[id].jsx
<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_with tag

タグに紐づくPostを取得するAPI関数を作成する。

lib/api/post.js
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
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 )
}

tags post

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