コミットを比較

..

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 22d85eac49 #90 2026-06-05 01:56:07 +09:00
みてるぞ bf4c7f339a #90 2026-06-05 01:51:50 +09:00
みてるぞ 4ff89b94e5 #90 2026-06-05 01:06:53 +09:00
みてるぞ 6f4b388284 #90 2026-06-05 00:43:54 +09:00
みてるぞ d68bcc8c5b #90 2026-06-03 23:56:52 +09:00
26個のファイルの変更1297行の追加259行の削除
+2
ファイルの表示
@@ -69,3 +69,5 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
+4
ファイルの表示
@@ -306,6 +306,9 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.2)
actionpack (= 8.0.2) actionpack (= 8.0.2)
activesupport (= 8.0.2) activesupport (= 8.0.2)
@@ -477,6 +480,7 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rack-cors rack-cors
rails (~> 8.0.2) rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0) rspec-rails (~> 8.0)
rubocop-rails-omakase rubocop-rails-omakase
sprockets-rails sprockets-rails
+81 -20
ファイルの表示
@@ -1,26 +1,69 @@
class NicoTagsController < ApplicationController class NicoTagsController < ApplicationController
def index def index
limit = (params[:limit] || 20).to_i name = params[:name].presence
cursor = params[:cursor].presence linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q = Tag.nico_tags q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
.order(updated_at: :desc) q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor if linked_tag
linked_tag_ids =
tags = q.limit(limit + 1).to_a Tag
.joins(:tag_name)
next_cursor = nil .where('tag_names.name LIKE ?', "%#{ linked_tag }%")
if tags.size > limit .pluck(:id)
next_cursor = tags.last.updated_at.iso8601(6) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
tags = tags.first(limit) q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
count = q.count
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt| TagRepr.base(tag).merge(
TagRepr.base(lt) recent_post_tag_created_at: tag.recent_post_tag_created_at,
}) linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, next_cursor: } }, count: }
end end
def update def update
@@ -33,13 +76,15 @@ class NicoTagsController < ApplicationController
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico? return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, linked_tags = nil
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
return render_unprocessable_entity('ニコニコ・タグ同士は連携できません.', field: :tags)
end
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
@@ -49,5 +94,21 @@ class NicoTagsController < ApplicationController
end end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end end
end end
+1 -1
ファイルの表示
@@ -453,7 +453,7 @@ class PostsController < ApplicationController
if missing_ids.present? if missing_ids.present?
post.errors.add :parent_post_ids, post.errors.add :parent_post_ids,
"存在しない親投稿 ID があります: #{ missing_ids.join(' ') }" "存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post raise ActiveRecord::RecordInvalid, post
end end
+19 -19
ファイルの表示
@@ -2,20 +2,18 @@ require 'rails_helper'
RSpec.describe 'error responses', type: :request do RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do describe 'manual input errors' do
it 'returns a stable errors array for bad requests' do it 'returns a stable payload for bad requests' do
user = create(:user) get '/tags/name/%20/deerjikists'
sign_in_as(user)
put "/users/#{ user.id }", params: { name: ' ' }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to contain_exactly( expect(json).to include(
include('code' => 'bad_request', 'type' => 'bad_request',
'field' => 'name', 'message' => be_present,
'message' => be_present)) 'errors' => {},
'base_errors' => [be_present])
end end
it 'returns a stable errors array for unprocessable requests' do it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member) member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag') tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member) sign_in_as(member)
@@ -23,25 +21,27 @@ RSpec.describe 'error responses', type: :request do
patch "/tags/#{ tag.id }", params: { category: 'nico' } patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to contain_exactly( expect(json).to include(
include('code' => 'invalid', 'type' => 'validation_error',
'field' => 'category', 'message' => '入力内容を確認してください.',
'message' => be_present)) 'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end end
end end
describe 'model validation errors' do describe 'model validation errors' do
it 'returns field, code, and message for model errors' do it 'returns field messages for model errors' do
user = create(:user) user = create(:user)
sign_in_as(user) sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 } put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include( expect(json).to include(
include('code' => 'too_long', 'type' => 'validation_error',
'field' => 'name', 'message' => '入力内容を確認してください.')
'message' => be_present)) expect(json.fetch('errors').fetch('name')).to include(be_present)
end end
end end
end end
+18 -8
ファイルの表示
@@ -141,16 +141,21 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do context 'when logged in' do
before { sign_in_as(guest_user) } before { sign_in_as(guest_user) }
it 'returns 400 when tag is blank' do it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload } post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 400 when both file and url are blank' do it 'returns 422 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' } post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'creates a material with an attached file' do it 'creates a material with an attached file' do
@@ -261,21 +266,26 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns 400 when tag is blank' do it 'returns 422 when tag is blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: ' ', tag: ' ',
file: dummy_upload file: dummy_upload
} }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 400 when both file and url are blank' do it 'returns 422 when both file and url are blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' tag: 'material_update_no_payload'
} }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
+93 -7
ファイルの表示
@@ -3,12 +3,68 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns tags and next_cursor when overflowing limit' do it 'returns paginated tags and total count' do
create_list(:tag, 21, :nico) create_list(:tag, 3, :nico)
get '/tags/nico', params: { limit: 20 }
get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(20) expect(json['tags'].size).to eq(1)
expect(json['next_cursor']).to be_present expect(json['count']).to eq(3)
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: {
name: 'search_',
linked_tag: 'destination_',
link_status: 'linked'
}
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id])
get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' }
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
end end
end end
@@ -75,7 +131,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
it '400 when linked tag normalises to nico tag' do it 'returns 422 when linked tag normalises to nico tag' do
sign_in_as(member) sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') other_nico = create(:tag, :nico, name: 'nico:linked_ng')
@@ -87,7 +143,37 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count) }.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
sign_in_as(member)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns tag name validation errors on the tags field and rolls back created tags' do
sign_in_as(member)
TagNameSanitisationRule.create!(
priority: 1,
source_pattern: 'invalid',
replacement: 'valid'
)
nico_tag
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
end end
end end
end end
+16 -4
ファイルの表示
@@ -704,7 +704,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'return 400' do it 'returns 422 with tag field errors' do
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -714,7 +714,13 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload thumbnail: dummy_upload
) )
expect(response).to have_http_status(:bad_request), response.body expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end end
end end
@@ -931,7 +937,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'return 400' do it 'returns 422 with tag field errors' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params( put "/posts/#{post_record.id}", params: post_update_params(
@@ -939,7 +945,13 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title', title: 'updated title',
tags: 'nico:nico_tag') tags: 'nico:nico_tag')
expect(response).to have_http_status(:bad_request), response.body expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end end
end end
+24
ファイルの表示
@@ -275,6 +275,30 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
end end
context 'when a row is invalid' do
let(:payload) do
[
{ platform: '', code: code1 },
]
end
it 'returns 422 with indexed field errors and does not replace existing deerjikists' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present])
end
end
context 'when youtube code is handle' do context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
+4 -2
ファイルの表示
@@ -90,12 +90,14 @@ RSpec.describe 'Users', type: :request do
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it 'returns 400 when name is blank' do it 'returns 422 when name is blank' do
put "/users/#{user.id}", put "/users/#{user.id}",
params: { name: ' ' }, params: { name: ' ' },
headers: auth_headers(user) headers: auth_headers(user)
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['名前は必須です.'])
end end
it 'updates name and returns user slice' do it 'updates name and returns user slice' do
+2 -1
ファイルの表示
@@ -62,6 +62,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/> <Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
@@ -158,4 +159,4 @@ const App: FC = () => {
</>) </>)
} }
export default App export default App
+79
ファイルの表示
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
const api = vi.hoisted (() => ({
isApiError: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('extractValidationError', () => {
it ('extracts field and base errors from 422 validation responses', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'name'> ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { name: ['名前は必須です.'] },
base_errors: ['全体エラー'],
},
},
})
expect (validationError).toEqual ({
message: '入力内容を確認してください.',
fieldErrors: { name: ['名前は必須です.'] },
baseErrors: ['全体エラー'],
})
})
it ('preserves dotted field keys for indexed form rows', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'deerjikists.0.platform'> ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
expect (validationError?.fieldErrors).toEqual ({
'deerjikists0Platform': ['プラットフォームを入力してください.'],
})
})
it ('does not treat 400 bad requests as form validation errors', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError ({
response: {
status: 400,
data: {
type: 'bad_request',
message: 'リクエストが不正です.',
errors: {},
base_errors: ['リクエストが不正です.'],
},
},
})
expect (validationError).toBeNull ()
})
it ('ignores non-api errors', async () => {
api.isApiError.mockReturnValueOnce (false)
const { extractValidationError } = await import ('@/lib/apiErrors')
expect (extractValidationError (new Error ('network'))).toBeNull ()
})
})
+7 -2
ファイルの表示
@@ -19,8 +19,13 @@ export const extractValidationError = <T extends string = string> (err: unknown)
if (!(isApiError (err)) || err.response?.status !== 422) if (!(isApiError (err)) || err.response?.status !== 422)
return null return null
const data = toCamel ((err.response.data ?? { }) as Record<string, unknown>, const rawData = toCamel ((err.response.data ?? { }) as Record<string, unknown>,
{ deep: true }) as RawValidationError { deep: true }) as RawValidationError
const data: RawValidationError = {
type: rawData.type as string | undefined,
message: rawData.message as string | undefined,
errors: rawData.errors as Record<string, string[]> | undefined,
baseErrors: rawData.baseErrors as string[] | undefined }
if (data.type !== 'validation_error' && !(data.errors)) if (data.type !== 'validation_error' && !(data.errors))
return null return null
+28
ファイルの表示
@@ -10,6 +10,7 @@ const postsApi = vi.hoisted (() => ({
})) }))
const tagsApi = vi.hoisted (() => ({ const tagsApi = vi.hoisted (() => ({
fetchNicoTags: vi.fn (),
fetchTag: vi.fn (), fetchTag: vi.fn (),
fetchTagByName: vi.fn (), fetchTagByName: vi.fn (),
fetchTagChanges: vi.fn (), fetchTagChanges: vi.fn (),
@@ -37,6 +38,7 @@ describe ('prefetchForURL', () => {
postsApi.fetchPost.mockResolvedValue ({ id: 1 }) postsApi.fetchPost.mockResolvedValue ({ id: 1 })
postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 }) postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 })
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchNicoTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchTag.mockResolvedValue ({ id: 1 }) tagsApi.fetchTag.mockResolvedValue ({ id: 1 })
tagsApi.fetchTagByName.mockResolvedValue (null) tagsApi.fetchTagByName.mockResolvedValue (null)
tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 }) tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 })
@@ -85,6 +87,32 @@ describe ('prefetchForURL', () => {
) )
}) })
it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags/nico?name=source&linked_tag=destination'
+ '&link_status=linked&page=3&limit=10',
)
await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source',
linkedTag: 'destination',
linkStatus: 'linked',
page: 3,
limit: 10,
order: 'updated_at:desc',
})
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '',
linkedTag: '',
linkStatus: 'all',
page: 2,
limit: 20,
order: 'updated_at:desc',
})
})
it ('prefetches wiki show pages and related tag/post data', async () => { it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({ wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
id: 3, id: 3,
+21 -1
ファイルの表示
@@ -3,7 +3,7 @@ import { match } from 'path-to-regexp'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage, import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki' fetchWikiPages } from '@/lib/wiki'
@@ -170,6 +170,24 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
} }
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
const keys = {
name: url.searchParams.get ('name') ?? '',
linkedTag: url.searchParams.get ('linked_tag') ?? '',
linkStatus: (url.searchParams.get ('link_status') || 'all') as
'all' | 'linked' | 'unlinked',
page: Number (url.searchParams.get ('page') || 1),
limit: Number (url.searchParams.get ('limit') || 20),
order: (url.searchParams.get ('order') || 'updated_at:desc') as
'name:asc' | 'name:desc' | 'created_at:asc' | 'created_at:desc'
| 'updated_at:asc' | 'updated_at:desc' }
await qc.prefetchQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
}
const prefetchTagShow: Prefetcher = async (qc, url) => { const prefetchTagShow: Prefetcher = async (qc, url) => {
const m = mTag (url.pathname) const m = mTag (url.pathname)
if (!(m)) if (!(m))
@@ -206,6 +224,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mWiki (u.pathname))), && Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow }, run: prefetchWikiPageShow },
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex }, { test: u => u.pathname === '/tags', run: prefetchTagsIndex },
{ test: u => ['/tags/nico', '/nico/tags'].includes (u.pathname),
run: prefetchNicoTagsIndex },
{ test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname)) { test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname))
&& Boolean (mTag (u.pathname))), && Boolean (mTag (u.pathname))),
run: prefetchTagShow }, run: prefetchTagShow },
+3 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { FetchPostsParams, FetchTagsParams } from '@/types' import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
@@ -11,6 +11,8 @@ export const postsKeys = {
export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const, root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const, index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
show: (name: string) => ['tags', name] as const, show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) => changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const, ['tags', 'changes', p] as const,
+19 -1
ファイルの表示
@@ -1,6 +1,11 @@
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' import type { Deerjikist,
FetchNicoTagsParams,
FetchTagsParams,
NicoTag,
Tag,
TagVersion } from '@/types'
export const fetchTags = async ( export const fetchTags = async (
@@ -23,6 +28,19 @@ export const fetchTags = async (
...(order && { order }) } }) ...(order && { order }) } })
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
page,
limit,
name,
linked_tag: linkedTag,
link_status: linkStatus === 'all' ? '' : linkStatus,
order } })
export const fetchTag = async (id: string): Promise<Tag | null> => { export const fetchTag = async (id: string): Promise<Tag | null> => {
try try
{ {
+72
ファイルの表示
@@ -0,0 +1,72 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const tagsApi = vi.hoisted (() => ({
fetchDeerjikistsByTag: vi.fn (),
}))
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
</Routes>,
{ route: '/tags/7/deerjikists' },
)
describe ('DeerjikistDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows indexed validation errors returned for deerjikist rows', async () => {
tagsApi.fetchDeerjikistsByTag.mockResolvedValueOnce ({
tag: buildTag ({ id: 7, name: 'deerjika', category: 'deerjikist' }),
deerjikists: [{ platform: null, code: 'abc' }],
})
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
renderPage ()
await screen.findByDisplayValue ('abc')
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/7/deerjikists',
[{ platform: null, code: 'abc' }],
)
})
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+33 -33
ファイルの表示
@@ -20,7 +20,7 @@ import type { FC, FormEvent } from 'react'
import type { Deerjikist, Platform } from '@/types' import type { Deerjikist, Platform } from '@/types'
type DeerjikistFormField = type DeerjikistFormField =
'deerjikists' | `deerjikists.${ number }.platform` | `deerjikists.${ number }.code` 'deerjikists' | `deerjikists${ number }Platform` | `deerjikists${ number }Code`
const DeerjikistDetailPage: FC = () => { const DeerjikistDetailPage: FC = () => {
@@ -105,45 +105,45 @@ const DeerjikistDetailPage: FC = () => {
{/* プラットフォーム */} {/* プラットフォーム */}
<FormField <FormField
label="プラットフォーム" label="プラットフォーム"
messages={fieldErrors[`deerjikists.${ i }.platform`]}> messages={fieldErrors[`deerjikists${ i }Platform`]}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<select <select
disabled={disabled} disabled={disabled}
value={datum.platform ?? ''} value={datum.platform ?? ''}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)} className={inputClass (invalid)}
onChange={e => setData (prev => { onChange={e => setData (prev => {
const rtn = [...prev] const rtn = [...prev]
rtn[i] = { ...rtn[i], rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null } platform: (e.target.value || null) as Platform | null }
return rtn return rtn
})}> })}>
<option value="">&nbsp;</option> <option value="">&nbsp;</option>
{PLATFORMS.map (p => ( {PLATFORMS.map (p => (
<option key={p} value={p}> <option key={p} value={p}>
{PLATFORM_NAMES[p]} {PLATFORM_NAMES[p]}
</option>))} </option>))}
</select>)} </select>)}
</FormField> </FormField>
{/* コード */} {/* コード */}
<FormField <FormField
label="コード" label="コード"
messages={fieldErrors[`deerjikists.${ i }.code`]}> messages={fieldErrors[`deerjikists${ i }Code`]}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
value={datum.code} value={datum.code}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)} className={inputClass (invalid)}
onChange={e => setData (prev => { onChange={e => setData (prev => {
const rtn = [...prev] const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value } rtn[i] = { ...rtn[i], code: e.target.value }
return rtn return rtn
})}/>)} })}/>)}
</FormField> </FormField>
</fieldset> </fieldset>
))} ))}
+36 -2
ファイルの表示
@@ -1,11 +1,13 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPost: vi.fn (), apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -16,6 +18,12 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('MaterialNewPage', () => { describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (false)
})
it ('initializes tag from query and submits form data', async () => { it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({}) api.apiPost.mockResolvedValueOnce ({})
@@ -35,4 +43,30 @@ describe ('MaterialNewPage', () => {
expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
}) })
it ('shows validation errors for file and url fields', async () => {
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: {
file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'],
},
base_errors: [],
},
},
})
renderWithProviders (<MaterialNewPage/>)
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '虹夏' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
})
}) })
+41 -3
ファイルの表示
@@ -1,13 +1,14 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -18,6 +19,11 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('PostNewPage', () => { describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('blocks guests', () => { it ('blocks guests', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
@@ -55,4 +61,36 @@ describe ('PostNewPage', () => {
expect (formData.get ('tags')).toBe ('tag1 tag2') expect (formData.get ('tags')).toBe ('tag1 tag2')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
}) })
it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: ['投稿内容を確認してください.'],
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
})
}) })
+273
ファイルの表示
@@ -0,0 +1,273 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
...values,
category: 'nico',
linkedTags: values.linkedTags ?? [],
recentPostTagCreatedAt: values.recentPostTagCreatedAt ?? null,
})
const renderPage = (route = '/tags/nico') =>
renderWithProviders (
<NicoTagListPage user={buildUser ({ role: 'member' })}/>,
{ route },
)
describe ('NicoTagListPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
dialogue.confirm.mockResolvedValue (true)
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear ()
})
it ('loads a filtered page from URL search parameters', async () => {
api.apiGet.mockResolvedValue ({
tags: [buildNicoTag ({
id: 1,
name: 'nico:linked',
createdAt: '2024-01-02T03:04:05Z',
recentPostTagCreatedAt: '2025-01-02T03:04:05Z',
updatedAt: '2026-01-02T03:04:05Z',
})],
count: 21,
})
renderPage (
'/tags/nico?name=linked&linked_tag=destination&link_status=linked'
+ '&page=2&order=name:asc',
)
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/nico',
{ params: {
page: 2,
limit: 20,
name: 'linked',
linked_tag: 'destination',
link_status: 'linked',
order: 'name:asc',
} },
)
})
expect (await screen.findByText ('21 件')).toBeInTheDocument ()
expect (screen.getByLabelText ('前のページ')).toBeInTheDocument ()
expect (screen.queryByText ('なし')).not.toBeInTheDocument ()
expect (screen.getByText (dateString ('2025-01-02T03:04:05Z'))).toBeInTheDocument ()
expect (screen.queryByText (dateString ('2026-01-02T03:04:05Z'))).not.toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'ニコニコタグ ▲' })).toHaveAttribute (
'href',
expect.stringContaining ('order=name%3Adesc'),
)
expect (screen.getByRole ('link', { name: '最初に記載された日時' })).toHaveAttribute (
'href',
expect.stringContaining ('order=created_at%3Adesc'),
)
expect (screen.getByRole ('link', { name: '最近記載された日時' })).toHaveAttribute (
'href',
expect.stringContaining ('order=updated_at%3Adesc'),
)
fireEvent.mouseEnter (screen.getByLabelText ('前のページ'))
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: {
page: 1,
limit: 20,
name: 'linked',
linked_tag: 'destination',
link_status: 'linked',
order: 'name:asc',
} },
)
})
})
it ('scrolls to the table when moving between pages', async () => {
api.apiGet.mockResolvedValue ({
tags: [buildNicoTag ({ id: 1, name: 'nico:linked' })],
count: 21,
})
renderPage ()
fireEvent.click (await screen.findByLabelText ('次のページ'))
await waitFor (() => {
expect (scrollIntoView).toHaveBeenCalledWith ({ behavior: 'smooth' })
})
})
it ('navigates with submitted search conditions', async () => {
api.apiGet.mockResolvedValue ({ tags: [], count: 0 })
renderPage ()
fireEvent.change (screen.getByLabelText ('ニコニコタグ'), {
target: { value: 'source' },
})
fireEvent.change (screen.getByLabelText ('連携タグ'), {
target: { value: 'destination' },
})
fireEvent.change (screen.getByLabelText ('連携状態'), {
target: { value: 'unlinked' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: expect.objectContaining ({
page: 1,
name: 'source',
linked_tag: 'destination',
link_status: 'unlinked',
}) },
)
})
})
it ('updates links from a tag card', async () => {
api.apiGet
.mockResolvedValueOnce ({
tags: [buildNicoTag ({ id: 7, name: 'nico:source' })],
count: 1,
})
.mockResolvedValueOnce ({
tags: [
buildNicoTag ({
id: 7,
name: 'nico:source',
linkedTags: [buildTag ({ id: 8, name: '連携先' })],
}),
],
count: 1,
})
api.apiPut.mockResolvedValueOnce ([buildTag ({ id: 8, name: '連携先' })])
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編集' }))
fireEvent.change (screen.getByLabelText ('連携する広場タグ'), {
target: { value: '連携先' },
})
fireEvent.click (screen.getByRole ('button', { name: '保存' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/nico/7',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
})
expect (await screen.findByText ('1 件')).toBeInTheDocument ()
})
it ('asks before discarding changes when editing another tag', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [
buildNicoTag ({ id: 1, name: 'nico:first' }),
buildNicoTag ({ id: 2, name: 'nico:second' }),
],
count: 2,
})
renderPage ()
dialogue.confirm.mockResolvedValueOnce (false)
const editButtons = await screen.findAllByRole ('button', { name: '編集' })
fireEvent.click (editButtons[0])
fireEvent.change (screen.getByLabelText ('連携する広場タグ'), {
target: { value: '入力中' },
})
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalledWith ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})
})
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中')
})
it ('switches editing rows without confirmation when unchanged', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [
buildNicoTag ({ id: 1, name: 'nico:first' }),
buildNicoTag ({ id: 2, name: 'nico:second' }),
],
count: 2,
})
renderPage ()
const editButtons = await screen.findAllByRole ('button', { name: '編集' })
fireEvent.click (editButtons[0])
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
expect (dialogue.confirm).not.toHaveBeenCalled ()
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
})
it ('shows tags field validation errors inside the edited card', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [buildNicoTag ({ id: 7, name: 'nico:source' })],
count: 1,
})
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: { tags: ['タグ名を確認してください.'] },
base_errors: [],
},
},
})
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編集' }))
fireEvent.click (screen.getByRole ('button', { name: '保存' }))
expect (await screen.findByText ('タグ名を確認してください.')).toBeInTheDocument ()
expect (screen.getByLabelText ('連携する広場タグ')).toHaveAttribute ('aria-invalid', 'true')
})
})
+341 -147
ファイルの表示
@@ -1,120 +1,179 @@
import type { FC } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import SortHeader from '@/components/SortHeader'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors' import { extractValidationError } from '@/lib/apiErrors'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags'
import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import type { NicoTag, Tag, User } from '@/types' import type { FC, FormEvent } from 'react'
import type { FetchNicoTagsOrder, FetchNicoTagsOrderField, NicoTag, Tag, User } from '@/types'
type LinkStatus = 'all' | 'linked' | 'unlinked'
type Props = { user: User | null } type Props = { user: User | null }
const NicoTagListPage: FC<Props> = ({ user }) => { const setIf = (qs: URLSearchParams, key: string, value: string) => {
const [cursor, setCursor] = useState ('') const trimmed = value.trim ()
const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) if (trimmed)
const [errorsByTagId, setErrorsByTagId] = useState<Record<number, string[]>> ({ }) qs.set (key, trimmed)
const [loading, setLoading] = useState (false) }
const [nicoTags, setNicoTags] = useState<NicoTag[]> ([])
const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ })
const loaderRef = useRef<HTMLDivElement | null> (null)
const NicoTagListPage: FC<Props> = ({ user }) => {
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const queryClient = useQueryClient ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qName = query.get ('name') ?? ''
const qLinkedTag = query.get ('linked_tag') ?? ''
const qLinkStatus = (query.get ('link_status') || 'all') as LinkStatus
const order = (query.get ('order') || 'updated_at:desc') as FetchNicoTagsOrder
const [editingId, setEditingId] = useState<number | null> (null)
const [errorsByTagId, setErrorsByTagId] = useState<Record<number, string[]>> ({ })
const [linkStatus, setLinkStatus] = useState<LinkStatus> ('all')
const [linkedTag, setLinkedTag] = useState ('')
const [name, setName] = useState ('')
const [rawTags, setRawTags] = useState<Record<number, string>> ({ })
const [savingId, setSavingId] = useState<number | null> (null)
const keys = {
name: qName, linkedTag: qLinkedTag, linkStatus: qLinkStatus, page, limit, order }
const { data, isError, isLoading: loading } = useQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
const nicoTags = data?.tags ?? []
const count = data?.count ?? 0
const editable = canEditContent (user) const editable = canEditContent (user)
const totalPages = Math.ceil (count / limit)
const applyLoadedTags = useCallback ((data: { tags: NicoTag[]; nextCursor: string }, const handleSearch = (e: FormEvent) => {
withCursor: boolean) => { e.preventDefault ()
setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags])
setCursor (data.nextCursor)
const newEditing = Object.fromEntries (data.tags.map (t => [t.id, false])) const qs = new URLSearchParams ()
setEditing (editing => ({ ...editing, ...newEditing })) setIf (qs, 'name', name)
setIf (qs, 'linked_tag', linkedTag)
const newRawTags = Object.fromEntries ( if (linkStatus !== 'all')
data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) qs.set ('link_status', linkStatus)
setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) qs.set ('page', '1')
}, []) qs.set ('limit', String (limit))
qs.set ('order', order)
const loadInitial = useCallback (async () => { navigate (`${ location.pathname }?${ qs.toString () }`)
setLoading (true)
const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ('/tags/nico')
applyLoadedTags (data, false)
setLoading (false)
}, [applyLoadedTags])
const loadMore = useCallback (async () => {
setLoading (true)
const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> (
'/tags/nico', { params: { cursor } })
applyLoadedTags (data, true)
setLoading (false)
}, [applyLoadedTags, cursor])
const handleEdit = async (id: number) => {
if (editing[id])
{
const formData = new FormData
formData.append ('tags', rawTags[id])
try
{
const data = await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setNicoTags (nicoTags => {
nicoTags.find (t => t.id === id)!.linkedTags = data
return [...nicoTags]
})
setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') }))
setErrorsByTagId (errors => ({ ...errors, [id]: [] }))
toast ({ title: '更新しました.' })
}
catch (e)
{
const validationError = extractValidationError<'tags'> (e)
setErrorsByTagId (errors => ({ ...errors,
[id]: validationError?.fieldErrors.tags ?? [] }))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
return
}
}
setEditing (editing => ({ ...editing, [id]: !(editing[id]) }))
} }
useEffect(() => { const defaultDirection = {
const observer = new IntersectionObserver (entries => { name: 'asc',
if (entries[0].isIntersecting && !(loading) && cursor) created_at: 'desc',
loadMore () updated_at: 'desc',
}, { threshold: 1 }) } as const
const target = loaderRef.current const beginEdit = async (tag: NicoTag) => {
if (target) const editingTag = nicoTags.find (tag => tag.id === editingId)
observer.observe (target) const editingValue = editingTag?.linkedTags.map (tag => tag.name).join (' ') ?? ''
const editingChanged = editingId != null && rawTags[editingId] !== editingValue
return () => { if (editingId != null && editingId !== tag.id && editingChanged
if (target) && !(await dialogue.confirm ({
observer.unobserve (target) title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})))
return
setEditingId (tag.id)
setRawTags (rawTags => ({
...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
}
const cancelEdit = (tag: NicoTag) => {
setEditingId (null)
setRawTags (rawTags => ({
...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
}
const saveLinks = async (id: number) => {
const formData = new FormData
formData.append ('tags', rawTags[id] ?? '')
setSavingId (id)
try
{
await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setErrorsByTagId (errors => ({ ...errors, [id]: [] }))
setEditingId (null)
await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
toast ({ description: '連携を更新しました.' })
} }
}, [cursor, loadMore, loading]) catch (e)
{
const validationError = extractValidationError<'tags'> (e)
setErrorsByTagId (errors => ({
...errors,
[id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors
?? ['更新できませんでした.'],
}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
{
setSavingId (null)
}
}
useEffect (() => { useEffect (() => {
setNicoTags ([]) setName (qName)
loadInitial () setLinkedTag (qLinkedTag)
}, [loadInitial]) setLinkStatus (qLinkStatus)
setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => {
if (!(data))
return
setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
])))
}, [data])
useEffect (() => {
if (isError)
toast ({ title: '読込失敗', description: 'ニコニコ連携を読み込めませんでした.' })
}, [isError])
return ( return (
<MainArea> <MainArea>
@@ -124,66 +183,201 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
<div className="max-w-xl"> <div className="max-w-xl">
<PageTitle></PageTitle> <PageTitle></PageTitle>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-300">
</p>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="ニコニコタグ">
{({ invalid }) => (
<input
type="text"
aria-label="ニコニコタグ"
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携タグ">
{({ invalid }) => (
<input
type="text"
aria-label="連携タグ"
value={linkedTag}
onChange={e => setLinkedTag (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携状態">
{({ invalid }) => (
<select
aria-label="連携状態"
value={linkStatus}
onChange={e => setLinkStatus (e.target.value as LinkStatus)}
className={inputClass (invalid)}>
<option value="all"></option>
<option value="linked"></option>
<option value="unlinked"></option>
</select>)}
</FormField>
<div className="py-3">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white">
</button>
</div>
</form>
</div> </div>
<div className="mt-4"> {loading
{nicoTags.length > 0 && ( ? 'Loading...'
<table className="table-auto w-full border-collapse mb-4"> : (
<thead className="border-b-2 border-black dark:border-white"> <div className="mt-6">
<tr> <div className="mb-3 flex items-baseline justify-between gap-4">
<th className="p-2 text-left"></th> <h2 className="text-lg font-bold"></h2>
<th className="p-2 text-left"></th> <span className="text-sm text-gray-500 dark:text-gray-400">{count} </span>
{editable && <th></th>} </div>
</tr>
</thead> {nicoTags.length > 0
<tbody> ? (
{nicoTags.map ((tag, i) => ( <div className="overflow-x-auto">
<tr key={i} className="even:bg-gray-100 dark:even:bg-gray-700"> <table className="w-full min-w-[800px] table-fixed border-collapse">
<td className="p-2"> <colgroup>
<TagLink tag={tag} withWiki={false} withCount={false}/> <col className="w-64"/>
</td> <col className="w-[48rem]"/>
<td className="p-2"> <col className="w-56"/>
{editing[tag.id] <col className="w-56"/>
? ( {editable && <col className="w-24"/>}
<> </colgroup>
<TextArea <thead className="border-b-2 border-black dark:border-white">
value={rawTags[tag.id]} <tr>
invalid={(errorsByTagId[tag.id] ?? []).length > 0} <th className="p-2 text-left whitespace-nowrap">
onChange={ev => { <SortHeader<FetchNicoTagsOrderField>
setRawTags (rawTags => ({ by="name"
...rawTags, label="ニコニコタグ"
[tag.id]: ev.target.value })) currentOrder={order}
}}/> defaultDirection={defaultDirection}/>
<FieldError messages={errorsByTagId[tag.id]}/> </th>
</>) <th className="p-2 text-left"></th>
: tag.linkedTags.map((lt, j) => ( <th className="p-2 text-left whitespace-nowrap">
<span key={j} className="mr-2"> <SortHeader<FetchNicoTagsOrderField>
<TagLink tag={lt} by="created_at"
linkFlg={false} label="最初に記載された日時"
withCount={false}/> currentOrder={order}
</span>))} defaultDirection={defaultDirection}/>
</td> </th>
{editable && ( <th className="p-2 text-left whitespace-nowrap">
<td className="p-2"> <SortHeader<FetchNicoTagsOrderField>
<a href="#" onClick={ev => { by="updated_at"
ev.preventDefault () label="最近記載された日時"
handleEdit (tag.id) currentOrder={order}
}}> defaultDirection={defaultDirection}/>
{editing[tag.id] </th>
? ( {editable && <th className="p-2"></th>}
<span className="text-red-600 hover:text-red-400 </tr>
dark:text-red-300 dark:hover:text-red-100"> </thead>
<tbody>
</span>) {nicoTags.map (tag => {
: <span></span>} const isEditing = editingId === tag.id
</a>
</td>)} return [
</tr>))} <tr
</tbody> key={tag.id}
</table>)} className={cn (
{loading && 'Loading...'} 'border-b border-gray-200 dark:border-gray-700',
<div ref={loaderRef} className="h-12"></div> isEditing
</div> ? 'bg-rose-50 dark:bg-rose-950/30'
: 'even:bg-gray-100 dark:even:bg-gray-800')}>
<td className="p-2 align-top font-semibold">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</td>
<td className="p-2 align-top">
{tag.linkedTags.map ((linkedTag, i) => (
<span key={linkedTag.id}>
{i > 0 && ' '}
<TagLink
tag={linkedTag}
linkFlg={false}
withCount={false}/>
</span>))}
</td>
<td className="p-2 align-top whitespace-nowrap">
{dateString (tag.createdAt)}
</td>
<td className="p-2 align-top whitespace-nowrap">
{tag.recentPostTagCreatedAt && dateString (tag.recentPostTagCreatedAt)}
</td>
{editable && (
<td className="p-2 text-right align-top">
{!(isEditing) && (
<button
type="button"
onClick={() => beginEdit (tag)}
className="inline-flex items-center gap-1 text-sm text-blue-700
hover:underline dark:text-blue-300">
<Pencil className="size-3.5"/>
</button>)}
</td>)}
</tr>,
isEditing && (
<tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30">
<td colSpan={editable ? 5 : 4} className="p-3">
<div className="space-y-2">
<label htmlFor={`nico-links-${ tag.id }`}
className="block text-sm font-semibold">
</label>
<TextArea
id={`nico-links-${ tag.id }`}
value={rawTags[tag.id] ?? ''}
invalid={(errorsByTagId[tag.id] ?? []).length > 0}
className="min-h-24 resize-y"
placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value,
}))}/>
<FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2">
<button
type="button"
disabled={savingId === tag.id}
onClick={() => cancelEdit (tag)}
className="inline-flex items-center gap-1 rounded border
border-gray-300 px-3 py-1.5 text-sm
disabled:opacity-50 dark:border-gray-700">
<X className="size-3.5"/>
</button>
<button
type="button"
disabled={savingId === tag.id}
onClick={() => saveLinks (tag.id)}
className="inline-flex items-center gap-1 rounded bg-rose-700
px-3 py-1.5 text-sm text-white disabled:opacity-50">
{savingId === tag.id
? <LoaderCircle className="size-3.5 animate-spin"/>
: <Check className="size-3.5"/>}
</button>
</div>
</div>
</td>
</tr>),
]
})}
</tbody>
</table>
</div>)
: <p></p>}
<Pagination page={page} totalPages={totalPages}/>
</div>)}
</MainArea>) </MainArea>)
} }
+33 -3
ファイルの表示
@@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagDetailPage from '@/pages/tags/TagDetailPage' import TagDetailPage from '@/pages/tags/TagDetailPage'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
@@ -11,7 +11,8 @@ const tagsApi = vi.hoisted (() => ({
})) }))
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -28,9 +29,14 @@ const renderPage = () =>
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
</Routes>, </Routes>,
{ route: '/tags/7' }, { route: '/tags/7' },
) )
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('loads and displays an editable tag', async () => { it ('loads and displays an editable tag', async () => {
tagsApi.fetchTag.mockResolvedValueOnce ( tagsApi.fetchTag.mockResolvedValueOnce (
buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }), buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }),
@@ -68,4 +74,28 @@ describe ('TagDetailPage', () => {
expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled () expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled ()
}) })
it ('shows validation errors returned for tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { category: ['ニコタグは変更できません.'] },
base_errors: [],
},
},
})
renderPage ()
await screen.findByDisplayValue ('old')
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
expect (await screen.findByText ('ニコタグは変更できません.')).toBeInTheDocument ()
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
})
}) })
+32 -2
ファイルの表示
@@ -1,12 +1,13 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -23,6 +24,11 @@ vi.mock ('@/components/users/InheritDialogue', () => ({
})) }))
describe ('SettingPage', () => { describe ('SettingPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows loading when user is absent', () => { it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
@@ -51,4 +57,28 @@ describe ('SettingPage', () => {
expect (setUser).toHaveBeenCalled () expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
}) })
it ('shows validation errors returned for the name field', async () => {
const user = buildUser ({ id: 11, name: 'old' })
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { name: ['名前は必須です.'] },
base_errors: [],
},
},
})
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
})
}) })
+15 -2
ファイルの表示
@@ -52,6 +52,18 @@ export type FetchTagsParams = {
limit: number limit: number
order: FetchTagsOrder } order: FetchTagsOrder }
export type FetchNicoTagsParams = {
name: string
linkedTag: string
linkStatus: 'all' | 'linked' | 'unlinked'
page: number
limit: number
order: FetchNicoTagsOrder }
export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc' }`
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type Material = { export type Material = {
id: number id: number
tag: Tag tag: Tag
@@ -83,8 +95,9 @@ export type MenuVisibleItem = {
subMenu: SubMenuItem[] } subMenu: SubMenuItem[] }
export type NicoTag = Tag & { export type NicoTag = Tag & {
category: 'nico' category: 'nico'
linkedTags: Tag[] } linkedTags: Tag[]
recentPostTagCreatedAt: string | null }
export type NiconicoMetadata = { export type NiconicoMetadata = {
currentTime: number currentTime: number