コミットを比較

...

7 コミット

作成者 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
みてるぞ cb21525698 #90 2026-06-03 23:34:33 +09:00
みてるぞ b5834976d2 #90 2026-06-03 23:05:46 +09:00
56個のファイルの変更2185行の追加804行の削除
+40 -2
ファイルの表示
@@ -84,12 +84,14 @@ cd frontend
npm run dev npm run dev
npm run build npm run build
npm run lint npm run lint
npm run test
npm run test:run
npm run preview npm run preview
``` ```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`. `npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`. `npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
## Coding style ## Coding style
@@ -122,13 +124,49 @@ Do not write or report `npm test` as a repository command unless a `test` script
- Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere. - Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions. - Match existing Tailwind, component, and import alias conventions.
### Frontend TSX style
- Preserve the local TSX formatting style. Do not normalize TSX to common Prettier-style React formatting unless explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent nested tag attributes with one tab relative to the tag line. With the project tab width, this visually appears as 4 spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag spans multiple lines. Do not put `/>` or `>` on its own line unless the existing surrounding code does so.
- Keep JSX closing parentheses in the existing compact style, for example `</div>)` rather than moving `)` onto a separate line.
Preferred:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow ## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists. - First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue. - Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. - Do not scan or summarize dependency/generated/runtime directories such as `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects. - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands that apply: `npm run build` and `npm run lint`. - If frontend code changes, run the existing frontend verification commands that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`. - If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure. - If a verification command cannot be run or fails, report the exact command and failure.
+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
+9 -11
ファイルの表示
@@ -28,19 +28,17 @@ class ApplicationController < ActionController::API
end end
end end
def render_bad_request message = 'リクエストが不正です.', field: nil, code: :bad_request def render_bad_request message = 'リクエストが不正です.'
render_error(:bad_request, message, field:, code:) render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end end
def render_unprocessable_entity message = '入力を確認してください.', field: nil, code: :invalid def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_error(:unprocessable_entity, message, field:, code:) render_validation_error(fields: field ? { field => [message] } : { },
end base: field ? [] : [message])
def render_error status, message, field: nil, code: status
error = { code: code.to_s, message: }
error[:field] = field.to_s if field.present?
render json: { errors: [error] }, status:
end end
def render_record_invalid error def render_record_invalid error
+7 -7
ファイルの表示
@@ -2,8 +2,8 @@ class DeerjikistsController < ApplicationController
def show def show
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.', field: :platform) if platform.blank? return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.', field: :code) if code.blank? return render_bad_request('code は必須です.') if code.blank?
deerjikist = Deerjikist deerjikist = Deerjikist
.joins(:tag) .joins(:tag)
@@ -23,9 +23,9 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i tag_id = params[:tag_id].to_i
return render_bad_request('platform は必須です.', field: :platform) if platform.blank? return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.', field: :code) if code.blank? return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.', field: :tag_id) if tag_id <= 0 return render_bad_request('tag_id が不正です.') if tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id d.tag_id = tag_id
@@ -41,8 +41,8 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.', field: :platform) if platform.blank? return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.', field: :code) if code.blank? return render_bad_request('code は必須です.') if code.blank?
Deerjikist.find([platform, code]).destroy! Deerjikist.find([platform, code]).destroy!
+12 -6
ファイルの表示
@@ -40,8 +40,11 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_bad_request('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
return render_bad_request('ファイルまたは URL は必須です.') if file.blank? && url.blank? if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -55,7 +58,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_model_errors(material) render_validation_error material
end end
end end
@@ -69,8 +72,11 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_bad_request('タグは必須です.', field: :tag) if tag_name_raw.blank? return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
return render_bad_request('ファイルまたは URL は必須です.') if file.blank? && url.blank? if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -86,7 +92,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
else else
render_model_errors(material) render_validation_error material
end end
end end
+82 -21
ファイルの表示
@@ -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
@@ -30,16 +73,18 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i id = params[:id].to_i
tag = Tag.find(id) tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.', field: :id) 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_bad_request('ニコニコ・タグ同士は連携できません.', 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
+7 -7
ファイルの表示
@@ -192,7 +192,7 @@ class PostsController < ApplicationController
return render_bad_request('force と merge は同時に指定できません.') if force && merge return render_bad_request('force と merge は同時に指定できません.') if force && merge
base_version_no = parse_base_version_no base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.', field: :base_version_no) if !(force) && !(base_version_no) return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
@@ -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
@@ -671,12 +671,12 @@ class PostsController < ApplicationController
end end
def render_post_form_record_invalid record def render_post_form_record_invalid record
if e.record.is_a?(TagName) || e.record.is_a?(Tag) if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error(fields: { tags: e.record.errors.full_messages.map { |message| render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ e.record.name }”: #{ message }" "タグ名 “#{ record.name }”: #{ message }"
} }) } }
else else
render_validation_error(record) render_validation_error record
end end
end end
end end
+8 -5
ファイルの表示
@@ -4,7 +4,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.', field: :url) unless url.present? return render_bad_request('URL は必須です.') unless url.present?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -16,7 +16,7 @@ class PreviewController < ApplicationController
render json: { title: title } render json: { title: title }
rescue => e rescue => e
render_bad_request(e.message, field: :url) render_bad_request(e.message)
end end
def thumbnail def thumbnail
@@ -25,7 +25,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.', field: :url) if url.blank? return render_bad_request('URL は必須です.') if url.blank?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -40,8 +40,11 @@ class PreviewController < ApplicationController
File.delete(path) rescue nil File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline' send_file image.path, type: 'image/png', disposition: 'inline'
else else
render_error(:internal_server_error, 'サムネールを生成できませんでした.', render json: { type: 'internal_server_error',
code: :thumbnail_generation_failed) message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end end
end end
end end
+4 -4
ファイルの表示
@@ -5,8 +5,8 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.', field: :parent_id) if parent_id.blank? return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.', field: :child_id) if child_id.blank? return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
@@ -28,8 +28,8 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.', field: :parent_id) if parent_id.blank? return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.', field: :child_id) if child_id.blank? return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
+29 -7
ファイルの表示
@@ -168,7 +168,7 @@ class TagsController < ApplicationController
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.', field: :name) if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -192,7 +192,7 @@ class TagsController < ApplicationController
def deerjikists_by_name def deerjikists_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.', field: :name) if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
@@ -214,21 +214,24 @@ class TagsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.deerjikists = [] tag.deerjikists = []
params[:_json].each do params[:_json].each.with_index do |item, i|
platform = _1[:platform] platform = item[:platform]
code = normalise_deerjikist_code(platform, _1[:code]) code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag deerjikist.tag = tag
deerjikist.save! render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end end
end end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists) render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.', field: :name) if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -435,4 +438,23 @@ class TagsController < ApplicationController
rescue rescue
nil nil
end end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end end
+2 -2
ファイルの表示
@@ -42,12 +42,12 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i return head :unauthorized if user&.id != params[:id].to_i
name = params[:name] name = params[:name]
return render_bad_request('名前は必須です.', field: :name) if name.blank? return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else else
render_model_errors(user) render_validation_error user
end end
end end
+2 -2
ファイルの表示
@@ -46,7 +46,7 @@ class WikiPagesController < ApplicationController
def diff def diff
id = params[:id] id = params[:id]
return render_bad_request('id は必須です.', field: :id) if id.blank? return render_bad_request('id は必須です.') if id.blank?
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
@@ -103,7 +103,7 @@ class WikiPagesController < ApplicationController
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_model_errors(e.record) render_validation_error e.record
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
render_record_not_unique render_record_not_unique
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
+27 -36
ファイルの表示
@@ -3,22 +3,21 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Label from '@/components/common/Label' import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { FieldErrors } from '@/lib/apiErrors'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
type PostFormField = type PostFormField =
'parentPostId' | 'tags' | 'originalCreatedFrom' | 'originalCreatedBefore' 'parentPostIds' | 'tags' | 'originalCreatedAt'
const tagsToStr = (tags: Tag[]): string => { const tagsToStr = (tags: Tag[]): string => {
@@ -41,9 +40,9 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [baseErrors, setBaseErrors] = useState<string[]> ([])
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors> ({ }) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -56,8 +55,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (...args: Parameters<typeof updatePost>) => {
setFieldErrors ({ }) clearValidationErrors ()
setBaseErrors ([])
try try
{ {
@@ -79,12 +77,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (response?.status !== 409) if (response?.status !== 409)
{ {
const validationError = extractValidationError<PostFormField> (e) if (applyValidationError (e))
if (validationError)
{ {
setFieldErrors (validationError.fieldErrors)
setBaseErrors (validationError.baseErrors)
toast ({ description: '更新はできなかったよ……' }) toast ({ description: '更新はできなかったよ……' })
return return
} }
@@ -148,31 +142,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タイトル */} {/* タイトル */}
<div> <FormField label="タイトル">
<Label></Label> {({ invalid }) => (
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
className={inputClass ()} className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/> onChange={ev => setTitle (ev.target.value)}/>)}
</div> </FormField>
{/* 親投稿 */} {/* 親投稿 */}
<div> <FormField label="親投稿" messages={fieldErrors.parentPostIds}>
<Label invalid={fieldErrors.parentPostIds && fieldErrors.parentPostIds.length > 0}> {({ describedBy, invalid }) => (
稿 <input
</Label> type="text"
<input disabled={disabled}
type="text" value={parentPostIds}
disabled={disabled} onChange={e => setParentPostIds (e.target.value)}
value={parentPostIds} aria-describedby={describedBy}
onChange={e => setParentPostIds (e.target.value)} aria-invalid={invalid}
aria-invalid={fieldErrors.parentPostIds && fieldErrors.parentPostIds.length > 0} className={inputClass (invalid)}/>)}
className={inputClass (fieldErrors.parentPostIds </FormField>
&& fieldErrors.parentPostIds.length > 0)}/>
<FieldError messages={fieldErrors.parentPostIds}/>
</div>
{/* タグ */} {/* タグ */}
<PostFormTagsArea <PostFormTagsArea
+29 -28
ファイルの表示
@@ -3,8 +3,7 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
@@ -75,32 +74,34 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
} }
return ( return (
<div className="relative w-full"> <FormField className="relative w-full" label="タグ" messages={errors}>
<Label invalid={errors && errors.length > 0}></Label> {({ describedBy, invalid }) => (
<TextArea <>
{...rest} <TextArea
ref={ref} {...rest}
value={tags} ref={ref}
invalid={errors && errors.length > 0} value={tags}
onChange={ev => setTags (ev.target.value)} aria-describedby={describedBy}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { invalid={invalid}
const pos = (ev.target as HTMLTextAreaElement).selectionStart onChange={ev => setTags (ev.target.value)}
await recompute (pos) onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
}} const pos = (ev.target as HTMLTextAreaElement).selectionStart
onFocus={() => setFocused (true)} await recompute (pos)
onBlur={() => { }}
setFocused (false) onFocus={() => setFocused (true)}
setSuggestionsVsbl (false) onBlur={() => {
}}/> setFocused (false)
{focused && ( setSuggestionsVsbl (false)
<TagSearchBox }}/>
suggestions={suggestionsVsbl && suggestions.length > 0 {focused && (
? suggestions <TagSearchBox
: [] as Tag[]} suggestions={suggestionsVsbl && suggestions.length > 0
activeIndex={-1} ? suggestions
onSelect={handleTagSelect}/>)} : [] as Tag[]}
<FieldError messages={errors}/> activeIndex={-1}
</div>) onSelect={handleTagSelect}/>)}
</>)}
</FormField>)
} }
export default PostFormTagsArea export default PostFormTagsArea
+63 -63
ファイルの表示
@@ -1,6 +1,5 @@
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import { FieldError } from '@/components/common/FieldError' import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC } from 'react'
@@ -22,68 +21,69 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props, errors }: Props,
) => ( ) => (
<div> <FormField label="オリジナルの作成日時" messages={errors}>
<Label invalid={errors && errors.length > 0}></Label> {({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
<div className="my-1 flex"> const d = new Date (v)
<div className="w-80"> if (d.getMinutes () === 0 && d.getHours () === 0)
<DateTimeField d.setDate (d.getDate () + 1)
className="mr-2" else
disabled={disabled ?? false} d.setMinutes (d.getMinutes () + 1)
aria-invalid={errors && errors.length > 0} setOriginalCreatedBefore (d.toISOString ())
invalid={errors && errors.length > 0} }}/>
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom} </div>
onBlur={ev => { <div>
const v = ev.target.value <Button
if (!(v)) className="bg-gray-600 text-white rounded"
return disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
const d = new Date (v) <div className="my-1 flex">
if (d.getMinutes () === 0 && d.getHours () === 0) <div className="w-80">
d.setDate (d.getDate () + 1) <DateTimeField
else className="mr-2"
d.setMinutes (d.getMinutes () + 1) disabled={disabled}
setOriginalCreatedBefore (d.toISOString ()) aria-describedby={describedBy}
}}/> aria-invalid={invalid}
invalid={invalid}
</div> value={originalCreatedBefore ?? undefined}
<div> onChange={setOriginalCreatedBefore}/>
<Button
className="bg-gray-600 text-white rounded" </div>
disabled={disabled} <div>
onClick={() => { <Button
setOriginalCreatedFrom (null) className="bg-gray-600 text-white rounded"
}}> disabled={disabled}
onClick={() => {
</Button> setOriginalCreatedBefore (null)
</div> }}>
</div>
</Button>
<div className="my-1 flex"> </div>
<div className="w-80"> </div>
<DateTimeField </>)}
className="mr-2" </FormField>)
disabled={disabled}
aria-invalid={errors && errors.length > 0}
invalid={errors && errors.length > 0}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
<FieldError messages={errors}/>
</div>)
export default PostOriginalCreatedTimeField export default PostOriginalCreatedTimeField
+4 -2
ファイルの表示
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
@@ -110,11 +111,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/> className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</div>) </div>)
} }
export default TagSearch export default TagSearch
+4 -3
ファイルの表示
@@ -1,14 +1,15 @@
import type { FC } from 'react' import type { FC } from 'react'
type Props = { messages?: string[] } type Props = { id?: string
messages?: string[] }
export const FieldError: FC<Props> = ({ messages }: Props) => { export const FieldError: FC<Props> = ({ id, messages }: Props) => {
if (!(messages) || messages.length === 0) if (!(messages) || messages.length === 0)
return null return null
return ( return (
<ul className="mt-1 space-y-1 text-red-700 dark:text-red-300"> <ul id={id} className="mt-1 space-y-1 text-red-700 dark:text-red-300">
{messages.map ((message, i) => <li key={i}>{message}</li>)} {messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>) </ul>)
} }
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { useId } from 'react'
import FieldError from '@/components/common/FieldError'
import Label from '@/components/common/Label'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type FieldState = { describedBy?: string
invalid: boolean }
type Props = {
children: (state: FieldState) => ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
const id = useId ()
const invalid = messages != null && messages.length > 0
const errorId = invalid ? `${ id }-error` : undefined
return (
<div className={cn (className)}>
<Label checkBox={checkBox} invalid={invalid}>{label}</Label>
{children ({ describedBy: errorId, invalid })}
<FieldError id={errorId} messages={messages}/>
</div>)
}
export default FormField
+11 -5
ファイルの表示
@@ -2,6 +2,7 @@ import { useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent, KeyboardEvent } from 'react' import type { FC, ChangeEvent, KeyboardEvent } from 'react'
@@ -9,10 +10,13 @@ import type { Tag } from '@/types'
type Props = { type Props = {
value: string describedBy?: string
setValue: (value: string) => void } invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ value, setValue }) => {
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const [activeIndex, setActiveIndex] = useState (-1) const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([]) const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -85,12 +89,14 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
value={value} value={value}
onChange={whenChanged} onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/> className={inputClass (invalid)}/>
<TagSearchBox <TagSearchBox
suggestions={ suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]} suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
@@ -99,4 +105,4 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
</div>) </div>)
} }
export default TagInput export default TagInput
+1 -1
ファイルの表示
@@ -16,7 +16,7 @@ export default forwardRef<HTMLTextAreaElement, Props> (
(invalid (invalid
? ['border-red-500 bg-red-50 text-red-900', ? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none focus:ring-2', 'focus:border-red-500 focus:outline-none focus:ring-2',
'foucs:ring-red-200', 'focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100'] 'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300', : ['border-gray-300',
'focus:border-blue-500 focus:outline-none focus:ring-2', 'focus:border-blue-500 focus:outline-none focus:ring-2',
+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
{ {
+28
ファイルの表示
@@ -0,0 +1,28 @@
import { useState } from 'react'
import { extractValidationError } from '@/lib/apiErrors'
import type { FieldErrors } from '@/lib/apiErrors'
export const useValidationErrors = <T extends string> () => {
const [baseErrors, setBaseErrors] = useState<string[]> ([])
const [fieldErrors, setFieldErrors] = useState<FieldErrors<T>> ({ })
const clearValidationErrors = () => {
setBaseErrors ([])
setFieldErrors ({ })
}
const applyValidationError = (error: unknown): boolean => {
const validationError = extractValidationError<T> (error)
if (!(validationError))
return false
setBaseErrors (validationError.baseErrors)
setFieldErrors (validationError.fieldErrors)
return true
}
return { baseErrors, fieldErrors, clearValidationErrors, applyValidationError }
}
+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')
})
})
+55 -35
ファイルの表示
@@ -3,7 +3,8 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label' 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 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'
@@ -11,12 +12,16 @@ import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import { fetchDeerjikistsByTag } from '@/lib/tags' import { fetchDeerjikistsByTag } from '@/lib/tags'
import { cn } from '@/lib/utils' import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Deerjikist, Platform } from '@/types' import type { Deerjikist, Platform } from '@/types'
type DeerjikistFormField =
'deerjikists' | `deerjikists${ number }Platform` | `deerjikists${ number }Code`
const DeerjikistDetailPage: FC = () => { const DeerjikistDetailPage: FC = () => {
const { id } = useParams () const { id } = useParams ()
@@ -31,11 +36,14 @@ const DeerjikistDetailPage: FC = () => {
const [data, setData] = const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true) const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<DeerjikistFormField> ()
const qc = useQueryClient () const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
clearValidationErrors ()
try try
{ {
@@ -46,8 +54,9 @@ const DeerjikistDetailPage: FC = () => {
toast ({ description: '更新しました.' }) toast ({ description: '更新しました.' })
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
} }
finally finally
@@ -76,6 +85,9 @@ const DeerjikistDetailPage: FC = () => {
</PageTitle> </PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2"> <form onSubmit={handleSubmit} className="my-4 space-y-2">
<FieldError messages={baseErrors}/>
<FieldError messages={fieldErrors.deerjikists}/>
{data.map ((datum, i) => ( {data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300 <fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4"> dark:border-gray-700 p-4">
@@ -91,40 +103,48 @@ const DeerjikistDetailPage: FC = () => {
</legend> </legend>
{/* プラットフォーム */} {/* プラットフォーム */}
<div> <FormField
<Label></Label> label="プラットフォーム"
<select messages={fieldErrors[`deerjikists${ i }Platform`]}>
className="w-full border p-2 rounded" {({ describedBy, invalid }) => (
disabled={disabled} <select
value={datum.platform ?? ''} disabled={disabled}
onChange={e => setData (prev => { value={datum.platform ?? ''}
const rtn = [...prev] aria-describedby={describedBy}
rtn[i] = { ...rtn[i], aria-invalid={invalid}
platform: (e.target.value || null) as Platform | null } className={inputClass (invalid)}
return rtn onChange={e => setData (prev => {
})}> const rtn = [...prev]
<option value="">&nbsp;</option> rtn[i] = { ...rtn[i],
{PLATFORMS.map (p => ( platform: (e.target.value || null) as Platform | null }
<option key={p} value={p}> return rtn
{PLATFORM_NAMES[p]} })}>
</option>))} <option value="">&nbsp;</option>
</select> {PLATFORMS.map (p => (
</div> <option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>)}
</FormField>
{/* コード */} {/* コード */}
<div> <FormField
<Label></Label> label="コード"
<input messages={fieldErrors[`deerjikists${ i }Code`]}>
type="text" {({ describedBy, invalid }) => (
disabled={disabled} <input
className="w-full border p-2 rounded" type="text"
value={datum.code} disabled={disabled}
onChange={e => setData (prev => { value={datum.code}
const rtn = [...prev] aria-describedby={describedBy}
rtn[i] = { ...rtn[i], code: e.target.value } aria-invalid={invalid}
return rtn className={inputClass (invalid)}
})}/> onChange={e => setData (prev => {
</div> const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>)}
</FormField>
</fieldset> </fieldset>
))} ))}
+68 -46
ファイルの表示
@@ -4,7 +4,8 @@ import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody' import WikiBody from '@/components/WikiBody'
import Label from '@/components/common/Label' 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 TabGroup, { Tab } from '@/components/common/TabGroup' import TabGroup, { Tab } from '@/components/common/TabGroup'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
@@ -13,6 +14,8 @@ import { Button } from '@/components/ui/button'
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 { apiGet, apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
@@ -20,6 +23,8 @@ import type { Material, Tag } from '@/types'
type MaterialWithTag = Material & { tag: Tag } type MaterialWithTag = Material & { tag: Tag }
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialDetailPage: FC = () => { const MaterialDetailPage: FC = () => {
const { id } = useParams () const { id } = useParams ()
@@ -31,8 +36,12 @@ const MaterialDetailPage: FC = () => {
const [sending, setSending] = useState (false) const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('') const [tag, setTag] = useState ('')
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData const formData = new FormData
if (tag.trim ()) if (tag.trim ())
formData.append ('tag', tag) formData.append ('tag', tag)
@@ -48,8 +57,9 @@ const MaterialDetailPage: FC = () => {
setMaterial (data) setMaterial (data)
toast ({ title: '更新成功!' }) toast ({ title: '更新成功!' })
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '更新失敗……', description: '入力を見直してください.' }) toast ({ title: '更新失敗……', description: '入力を見直してください.' })
} }
finally finally
@@ -118,54 +128,66 @@ const MaterialDetailPage: FC = () => {
<Tab name="編輯"> <Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4"> <div className="max-w-wl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タグ */} {/* タグ */}
<div> <FormField label="タグ" messages={fieldErrors.tag}>
<Label></Label> {({ describedBy, invalid }) => (
<TagInput value={tag} setValue={setTag}/> <TagInput
</div> describedBy={describedBy}
invalid={invalid}
value={tag}
setValue={setTag}/>)}
</FormField>
{/* ファイル */} {/* ファイル */}
<div> <FormField label="ファイル" messages={fieldErrors.file}>
<Label></Label> {({ describedBy, invalid }) => (
<input <>
type="file" <input
accept="image/*,video/*,audio/*" type="file"
onChange={e => { accept="image/*,video/*,audio/*"
const f = e.target.files?.[0] aria-describedby={describedBy}
setFile (f ?? null) aria-invalid={invalid}
setFilePreview (f ? URL.createObjectURL (f) : '') onChange={e => {
}}/> const f = e.target.files?.[0]
{(file && filePreview) && ( setFile (f ?? null)
(/image\/.*/.test (file.type) && ( setFilePreview (f ? URL.createObjectURL (f) : '')
<img }}/>
src={filePreview} {(file && filePreview) && (
alt="preview" (/image\/.*/.test (file.type) && (
className="mt-2 max-h-48 rounded border"/>)) <img
|| (/video\/.*/.test (file.type) && ( src={filePreview}
<video alt="preview"
src={filePreview} className="mt-2 max-h-48 rounded border"/>))
controls || (/video\/.*/.test (file.type) && (
className="mt-2 max-h-48 rounded border"/>)) <video
|| (/audio\/.*/.test (file.type) && ( src={filePreview}
<audio controls
src={filePreview} className="mt-2 max-h-48 rounded border"/>))
controls || (/audio\/.*/.test (file.type) && (
className="mt-2 max-h-48"/>)) <audio
|| ( src={filePreview}
<p className="text-red-600 dark:text-red-400"> controls
className="mt-2 max-h-48"/>))
</p>))} || (
</div> <p className="text-red-600 dark:text-red-400">
</p>))}
</>)}
</FormField>
{/* 参考 URL */} {/* 参考 URL */}
<div> <FormField label="参考 URL" messages={fieldErrors.url}>
<Label> URL</Label> {({ describedBy, invalid }) => (
<input <input
type="url" type="url"
value={url} value={url}
onChange={e => setURL (e.target.value)} onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */} {/* 送信 */}
<Button <Button
@@ -181,4 +203,4 @@ const MaterialDetailPage: FC = () => {
</MainArea>) </MainArea>)
} }
export default MaterialDetailPage export default MaterialDetailPage
+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')
})
}) })
+67 -46
ファイルの表示
@@ -2,8 +2,9 @@ import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import Label from '@/components/common/Label' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -11,9 +12,13 @@ import { Button } from '@/components/ui/button'
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 { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialNewPage: FC = () => { const MaterialNewPage: FC = () => {
const location = useLocation () const location = useLocation ()
@@ -27,8 +32,12 @@ const MaterialNewPage: FC = () => {
const [sending, setSending] = useState (false) const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery) const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData const formData = new FormData
if (tag) if (tag)
formData.append ('tag', tag) formData.append ('tag', tag)
@@ -44,8 +53,9 @@ const MaterialNewPage: FC = () => {
toast ({ title: '送信成功!' }) toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`) navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '送信失敗……', description: '入力を見直してください.' }) toast ({ title: '送信失敗……', description: '入力を見直してください.' })
} }
finally finally
@@ -62,55 +72,66 @@ const MaterialNewPage: FC = () => {
<Form> <Form>
<PageTitle></PageTitle> <PageTitle></PageTitle>
<FieldError messages={baseErrors}/>
{/* タグ */} {/* タグ */}
<div> <FormField label="タグ" messages={fieldErrors.tag}>
<Label></Label> {({ describedBy, invalid }) => (
<TagInput value={tag} setValue={setTag}/> <TagInput
</div> describedBy={describedBy}
invalid={invalid}
value={tag}
setValue={setTag}/>)}
</FormField>
{/* ファイル */} {/* ファイル */}
<div> <FormField label="ファイル" messages={fieldErrors.file}>
<Label></Label> {({ describedBy, invalid }) => (
<input <>
type="file" <input
accept="image/*,video/*,audio/*" type="file"
onChange={e => { accept="image/*,video/*,audio/*"
const f = e.target.files?.[0] aria-describedby={describedBy}
setFile (f ?? null) aria-invalid={invalid}
setFilePreview (f ? URL.createObjectURL (f) : '') onChange={e => {
}}/> const f = e.target.files?.[0]
{(file && filePreview) && ( setFile (f ?? null)
(/image\/.*/.test (file.type) && ( setFilePreview (f ? URL.createObjectURL (f) : '')
<img }}/>
src={filePreview} {(file && filePreview) && (
alt="preview" (/image\/.*/.test (file.type) && (
className="mt-2 max-h-48 rounded border"/>)) <img
|| (/video\/.*/.test (file.type) && ( src={filePreview}
<video alt="preview"
src={filePreview} className="mt-2 max-h-48 rounded border"/>))
controls || (/video\/.*/.test (file.type) && (
className="mt-2 max-h-48 rounded border"/>)) <video
|| (/audio\/.*/.test (file.type) && ( src={filePreview}
<audio controls
src={filePreview} className="mt-2 max-h-48 rounded border"/>))
controls || (/audio\/.*/.test (file.type) && (
className="mt-2 max-h-48"/>)) <audio
|| ( src={filePreview}
<p className="text-red-600 dark:text-red-400"> controls
className="mt-2 max-h-48"/>))
</p>))} || (
</div> <p className="text-red-600 dark:text-red-400">
</p>))}
</>)}
</FormField>
{/* 参考 URL */} {/* 参考 URL */}
<div> <FormField label="参考 URL" messages={fieldErrors.url}>
<Label> URL</Label> {({ describedBy, invalid }) => (
<input <input
type="url" type="url"
value={url} value={url}
onChange={e => setURL (e.target.value)} onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */} {/* 送信 */}
<Button <Button
@@ -123,4 +144,4 @@ const MaterialNewPage: FC = () => {
</MainArea>) </MainArea>)
} }
export default MaterialNewPage export default MaterialNewPage
+14 -14
ファイルの表示
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import Label from '@/components/common/Label' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -29,23 +29,23 @@ const MaterialSearchPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タグ */} {/* タグ */}
<div> <FormField label="タグ">
<Label></Label> {() => (
<TagInput <TagInput
value={tagName} value={tagName}
setValue={setTagName}/> setValue={setTagName}/>)}
</div> </FormField>
{/* 親タグ */} {/* 親タグ */}
<div> <FormField label="親タグ">
<Label></Label> {() => (
<TagInput <TagInput
value={parentTagName} value={parentTagName}
setValue={setParentTagName}/> setValue={setParentTagName}/>)}
</div> </FormField>
</form> </form>
</div> </div>
</MainArea>) </MainArea>)
} }
export default MaterialSearchPage export default MaterialSearchPage
+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')
})
}) })
+91 -65
ファイルの表示
@@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import Label from '@/components/common/Label' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -13,6 +14,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
@@ -21,12 +24,18 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const navigate = useNavigate () const navigate = useNavigate ()
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
@@ -44,6 +53,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData const formData = new FormData
formData.append ('title', title) formData.append ('title', title)
formData.append ('url', url) formData.append ('url', url)
@@ -62,8 +73,9 @@ const PostNewPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate ('/posts') navigate ('/posts')
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' }) toast ({ title: '投稿失敗', description: '入力を確認してください。' })
} }
} }
@@ -127,85 +139,99 @@ const PostNewPage: FC<Props> = ({ user }) => {
</Helmet> </Helmet>
<Form> <Form>
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
{/* URL */} {/* URL */}
<div> <FormField label="URL" messages={fieldErrors.url}>
<Label>URL</Label> {({ describedBy, invalid }) => (
<input type="url" <input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..." placeholder="例:https://www.nicovideo.jp/watch/..."
value={url} value={url}
onChange={e => setURL (e.target.value)} onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded" aria-describedby={describedBy}
onBlur={handleURLBlur}/> aria-invalid={invalid}
</div> className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
</FormField>
{/* タイトル */} {/* タイトル */}
<div> <FormField
<Label checkBox={{ checkBox={{
label: '自動', label: '自動',
checked: titleAutoFlg, checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}> onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
</Label> messages={fieldErrors.title}>
<input type="text" {({ describedBy, invalid }) => (
className="w-full border rounded p-2" <input type="text"
value={title} aria-describedby={describedBy}
placeholder={titleLoading ? 'Loading...' : ''} aria-invalid={invalid}
onChange={ev => setTitle (ev.target.value)} className={inputClass (invalid)}
disabled={titleAutoFlg}/> value={title}
</div> placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>)}
</FormField>
{/* サムネール */} {/* サムネール */}
<div> <FormField
<Label checkBox={{ checkBox={{
label: '自動', label: '自動',
checked: thumbnailAutoFlg, checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}> onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
</Label> messages={fieldErrors.thumbnail}>
{thumbnailAutoFlg {({ describedBy, invalid }) => (
? (thumbnailLoading <>
? <p className="text-gray-500 text-sm">Loading...</p> {thumbnailAutoFlg
: !(thumbnailPreview) && ( ? (thumbnailLoading
<p className="text-gray-500 text-sm"> ? <p className="text-gray-500 text-sm">Loading...</p>
URL : !(thumbnailPreview) && (
</p>)) <p className="text-gray-500 text-sm">
: ( URL
<input type="file" </p>))
accept="image/*" : (
onChange={e => { <input type="file"
const file = e.target.files?.[0] accept="image/*"
if (file) aria-describedby={describedBy}
{ aria-invalid={invalid}
setThumbnailFile (file) onChange={e => {
setThumbnailPreview (URL.createObjectURL (file)) const file = e.target.files?.[0]
} if (file)
}}/>)} {
{thumbnailPreview && ( setThumbnailFile (file)
<img src={thumbnailPreview} setThumbnailPreview (URL.createObjectURL (file))
alt="preview" }
className="mt-2 max-h-48 rounded border"/>)} }}/>)}
</div> {thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</>)}
</FormField>
{/* 親投稿 */} {/* 親投稿 */}
<div> <FormField label="親投稿" messages={fieldErrors.parentPostIds}>
<Label>稿</Label> {({ describedBy, invalid }) => (
<input <input
type="text" type="text"
value={parentPostIds} value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)} onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/> <PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/> setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
+60 -52
ファイルの表示
@@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader' import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label' 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 Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
@@ -16,7 +16,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -138,31 +138,33 @@ const PostSearchPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
<div> <FormField label="タイトル">
<Label></Label> {({ invalid }) => (
<input <input
type="text" type="text"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/> className={inputClass (invalid)}/>)}
</div> </FormField>
{/* URL */} {/* URL */}
<div> <FormField label="URL">
<Label>URL</Label> {({ invalid }) => (
<input <input
type="text" type="text"
value={url} value={url}
onChange={e => setURL (e.target.value)} onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/> className={inputClass (invalid)}/>)}
</div> </FormField>
{/* タグ */} {/* タグ */}
<FormField label="タグ">
{() => (
<TagInput
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
<div> <div>
<Label></Label>
<TagInput
value={tagsStr}
setValue={setTagsStr}/>
<fieldset className="w-full my-2"> <fieldset className="w-full my-2">
<label></label> <label></label>
<label className="mx-2"> <label className="mx-2">
@@ -185,40 +187,46 @@ const PostSearchPage: FC = () => {
</div> </div>
{/* オリジナルの投稿日時 */} {/* オリジナルの投稿日時 */}
<div> <FormField label="オリジナルの投稿日時">
<Label>稿</Label> {() => (
<DateTimeField <>
value={originalCreatedFrom ?? undefined} <DateTimeField
onChange={setOriginalCreatedFrom}/> value={originalCreatedFrom ?? undefined}
<span className="mx-1"></span> onChange={setOriginalCreatedFrom}/>
<DateTimeField <span className="mx-1"></span>
value={originalCreatedTo ?? undefined} <DateTimeField
onChange={setOriginalCreatedTo}/> value={originalCreatedTo ?? undefined}
</div> onChange={setOriginalCreatedTo}/>
</>)}
</FormField>
{/* 投稿日時 */} {/* 投稿日時 */}
<div> <FormField label="投稿日時">
<Label>稿</Label> {() => (
<DateTimeField <>
value={createdFrom ?? undefined} <DateTimeField
onChange={setCreatedFrom}/> value={createdFrom ?? undefined}
<span className="mx-1"></span> onChange={setCreatedFrom}/>
<DateTimeField <span className="mx-1"></span>
value={createdTo ?? undefined} <DateTimeField
onChange={setCreatedTo}/> value={createdTo ?? undefined}
</div> onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 更新日時 */} {/* 更新日時 */}
<div> <FormField label="更新日時">
<Label></Label> {() => (
<DateTimeField <>
value={updatedFrom ?? undefined} <DateTimeField
onChange={setUpdatedFrom}/> value={updatedFrom ?? undefined}
<span className="mx-1"></span> onChange={setUpdatedFrom}/>
<DateTimeField <span className="mx-1"></span>
value={updatedTo ?? undefined} <DateTimeField
onChange={setUpdatedTo}/> value={updatedTo ?? undefined}
</div> onChange={setUpdatedTo}/>
</>)}
</FormField>
{/* 検索 */} {/* 検索 */}
<div className="py-3"> <div className="py-3">
+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')
})
})
+343 -126
ファイルの表示
@@ -1,105 +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 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 { 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 [loading, setLoading] = useState (false) qs.set (key, trimmed)
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])
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 (' ') }))
toast ({ title: '更新しました.' })
}
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>
@@ -109,58 +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"/>}
<TextArea value={rawTags[tag.id]} onChange={ev => { </colgroup>
setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) <thead className="border-b-2 border-black dark:border-white">
}}/>) <tr>
: 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="name"
linkFlg={false} label="ニコニコタグ"
withCount={false}/> currentOrder={order}
</span>))} defaultDirection={defaultDirection}/>
</td> </th>
{editable && ( <th className="p-2 text-left"></th>
<td className="p-2"> <th className="p-2 text-left whitespace-nowrap">
<a href="#" onClick={ev => { <SortHeader<FetchNicoTagsOrderField>
ev.preventDefault () by="created_at"
handleEdit (tag.id) label="最初に記載された日時"
}}> currentOrder={order}
{editing[tag.id] defaultDirection={defaultDirection}/>
? ( </th>
<span className="text-red-600 hover:text-red-400 <th className="p-2 text-left whitespace-nowrap">
dark:text-red-300 dark:hover:text-red-100"> <SortHeader<FetchNicoTagsOrderField>
by="updated_at"
</span>) label="最近記載された日時"
: <span></span>} currentOrder={order}
</a> defaultDirection={defaultDirection}/>
</td>)} </th>
</tr>))} {editable && <th className="p-2"></th>}
</tbody> </tr>
</table>)} </thead>
{loading && 'Loading...'} <tbody>
<div ref={loaderRef} className="h-12"></div> {nicoTags.map (tag => {
</div> const isEditing = editingId === tag.id
return [
<tr
key={tag.id}
className={cn (
'border-b border-gray-200 dark:border-gray-700',
isEditing
? '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')
})
}) })
+63 -39
ファイルの表示
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label' 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 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'
@@ -11,12 +12,15 @@ import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags' import { fetchTag } from '@/lib/tags'
import { cn } from '@/lib/utils' import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Category, Tag } from '@/types' import type { Category, Tag } from '@/types'
type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags'
const TagDetailPage: FC = () => { const TagDetailPage: FC = () => {
const { id } = useParams () const { id } = useParams ()
@@ -32,11 +36,14 @@ const TagDetailPage: FC = () => {
const [aliases, setAliases] = useState ('') const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('') const [parentTags, setParentTags] = useState ('')
const [disabled, setDisabled] = useState (true) const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TagFormField> ()
const qc = useQueryClient () const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
clearValidationErrors ()
const formData = new FormData const formData = new FormData
formData.append ('name', name) formData.append ('name', name)
@@ -57,8 +64,9 @@ const TagDetailPage: FC = () => {
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' }) toast ({ description: '更新しました.' })
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ description: '更新に失敗しました.' }) toast ({ description: '更新に失敗しました.' })
} }
} }
@@ -89,57 +97,73 @@ const TagDetailPage: FC = () => {
</PageTitle> </PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2"> <form onSubmit={handleSubmit} className="my-4 space-y-2">
<FieldError messages={baseErrors}/>
{/* 名称 */} {/* 名称 */}
<div> <FormField label="名称" messages={fieldErrors.name}>
<Label></Label> {({ describedBy, invalid }) => (
{/* TODO: 補完に対応させる */} <>
<input {/* TODO: 補完に対応させる */}
type="text" <input
disabled={disabled} type="text"
value={name} disabled={disabled}
onChange={e => setName (e.target.value)} value={name}
className="w-full border p-2 rounded"/> onChange={e => setName (e.target.value)}
</div> aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
{/* カテゴリ */} {/* カテゴリ */}
<div> <FormField label="カテゴリ" messages={fieldErrors.category}>
<Label></Label> {({ describedBy, invalid }) => (
<select <select
disabled={disabled} disabled={disabled}
value={category ?? ''} value={category ?? ''}
onChange={e => setCategory(e.target.value as Category)} onChange={e => setCategory(e.target.value as Category)}
className="w-full border p-2 rounded"> aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}>
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico') {CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
.map (cat => ( .map (cat => (
<option key={cat} value={cat}> <option key={cat} value={cat}>
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</option>))} </option>))}
</select> </select>)}
</div> </FormField>
{/* 別名 */} {/* 別名 */}
<div> <FormField label="別名" messages={fieldErrors.aliases}>
<Label></Label> {({ describedBy, invalid }) => (
{/* TODO: 補完に対応させる */} <>
<input {/* TODO: 補完に対応させる */}
type="text" <input
disabled={disabled} type="text"
value={aliases} disabled={disabled}
onChange={e => setAliases (e.target.value)} value={aliases}
className="w-full border p-2 rounded"/> onChange={e => setAliases (e.target.value)}
</div> aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
{/* 上位タグ */} {/* 上位タグ */}
<div> <FormField label="上位タグ" messages={fieldErrors.parentTags}>
<Label></Label> {({ describedBy, invalid }) => (
{/* TODO: 補完に対応させる */} <>
<input {/* TODO: 補完に対応させる */}
type="text" <input
disabled={disabled} type="text"
value={parentTags} disabled={disabled}
onChange={e => setParentTags (e.target.value)} value={parentTags}
className="w-full border p-2 rounded"/> onChange={e => setParentTags (e.target.value)}
</div> aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
<div className="py-3"> <div className="py-3">
<button <button
@@ -157,4 +181,4 @@ const TagDetailPage: FC = () => {
</MainArea>) </MainArea>)
} }
export default TagDetailPage export default TagDetailPage
+67 -59
ファイルの表示
@@ -7,7 +7,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader' import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label' 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 Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import { fetchTags } from '@/lib/tags' import { fetchTags } from '@/lib/tags'
import { dateString } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -127,71 +127,79 @@ const TagListPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */} {/* 名前 */}
<div> <FormField label="名前">
<Label></Label> {({ invalid }) => (
<input <input
type="text" type="text"
value={name} value={name}
onChange={e => setName (e.target.value)} onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/> className={inputClass (invalid)}/>)}
</div> </FormField>
{/* カテゴリ */} {/* カテゴリ */}
<div> <FormField label="カテゴリ">
<Label></Label> {({ invalid }) => (
<select <select
value={category ?? ''} value={category ?? ''}
onChange={e => setCategory((e.target.value || null) as Category | null)} onChange={e => setCategory((e.target.value || null) as Category | null)}
className="w-full border p-2 rounded"> className={inputClass (invalid)}>
<option value="">&nbsp;</option> <option value="">&nbsp;</option>
{CATEGORIES.map (cat => ( {CATEGORIES.map (cat => (
<option key={cat} value={cat}> <option key={cat} value={cat}>
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</option>))} </option>))}
</select> </select>)}
</div> </FormField>
{/* 広場の投稿数 */} {/* 広場の投稿数 */}
<div> <FormField label="広場の投稿数">
<Label>稿</Label> {({ invalid }) => (
<input <>
type="number" <input
min="0" type="number"
value={postCountGTE < 0 ? 0 : String (postCountGTE)} min="0"
onChange={e => setPostCountGTE (Number (e.target.value || 0))} value={postCountGTE < 0 ? 0 : String (postCountGTE)}
className="border rounded p-2"/> onChange={e => setPostCountGTE (Number (e.target.value || 0))}
<span className="mx-1"></span> className={inputClass (invalid, 'w-auto')}/>
<input <span className="mx-1"></span>
type="number" <input
min="0" type="number"
value={postCountLTE == null ? '' : String (postCountLTE)} min="0"
onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)} value={postCountLTE == null ? '' : String (postCountLTE)}
className="border rounded p-2"/> onChange={e => setPostCountLTE (e.target.value
</div> ? Number (e.target.value)
: null)}
className={inputClass (invalid, 'w-auto')}/>
</>)}
</FormField>
{/* はじめて記載された日時 */} {/* はじめて記載された日時 */}
<div> <FormField label="はじめて記載された日時">
<Label></Label> {() => (
<DateTimeField <>
value={createdFrom ?? undefined} <DateTimeField
onChange={setCreatedFrom}/> value={createdFrom ?? undefined}
<span className="mx-1"></span> onChange={setCreatedFrom}/>
<DateTimeField <span className="mx-1"></span>
value={createdTo ?? undefined} <DateTimeField
onChange={setCreatedTo}/> value={createdTo ?? undefined}
</div> onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 定義の更新日時 */} {/* 定義の更新日時 */}
<div> <FormField label="定義の更新日時">
<Label></Label> {() => (
<DateTimeField <>
value={updatedFrom ?? undefined} <DateTimeField
onChange={setUpdatedFrom}/> value={updatedFrom ?? undefined}
<span className="mx-1"></span> onChange={setUpdatedFrom}/>
<DateTimeField <span className="mx-1"></span>
value={updatedTo ?? undefined} <DateTimeField
onChange={setUpdatedTo}/> value={updatedTo ?? undefined}
</div> onChange={setUpdatedTo}/>
</>)}
</FormField>
<div className="py-3"> <div className="py-3">
<button <button
+14 -2
ファイルの表示
@@ -6,12 +6,14 @@ import ErrorScreen from '@/components/ErrorScreen'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagDetailSidebar from '@/components/TagDetailSidebar' import TagDetailSidebar from '@/components/TagDetailSidebar'
import FieldError from '@/components/common/FieldError'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import { dateString } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC } from 'react'
@@ -27,6 +29,8 @@ type TheatreInfo = {
postStartedAt: string | null postStartedAt: string | null
watchingUsers: { id: number; name: string }[] } watchingUsers: { id: number; name: string }[] }
type TheatreCommentField = 'content'
const INITIAL_THEATRE_INFO = const INITIAL_THEATRE_INFO =
{ hostFlg: false, { hostFlg: false,
postId: null, postId: null,
@@ -53,6 +57,8 @@ const TheatreDetailPage: FC = () => {
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
const [post, setPost] = useState<Post | null> (null) const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0) const [videoLength, setVideoLength] = useState (0)
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
useEffect (() => { useEffect (() => {
loadingRef.current = loading loadingRef.current = loading
@@ -284,22 +290,28 @@ const TheatreDetailPage: FC = () => {
try try
{ {
setSending (true) setSending (true)
clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content }) await apiPost (`/theatres/${ id }/comments`, { content })
setContent ('') setContent ('')
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
} }
catch (error)
{
applyValidationError (error)
}
finally finally
{ {
setSending (false) setSending (false)
} }
}}> }}>
<input <input
className="w-full p-2 border rounded" className={inputClass ((fieldErrors.content ?? []).length > 0)}
type="text" type="text"
placeholder="ここにコメントを入力" placeholder="ここにコメントを入力"
value={content} value={content}
onChange={e => setContent (e.target.value)} onChange={e => setContent (e.target.value)}
disabled={sending}/> disabled={sending}/>
<FieldError messages={fieldErrors.content}/>
<div <div
ref={commentsRef} ref={commentsRef}
+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')
})
}) })
+22 -5
ファイルの表示
@@ -3,7 +3,9 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -13,22 +15,30 @@ import { Button } from '@/components/ui/button'
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 { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null type Props = { user: User | null
setUser: React.Dispatch<React.SetStateAction<User | null>> } setUser: React.Dispatch<React.SetStateAction<User | null>> }
type UserFormField = 'name'
const SettingPage: FC<Props> = ({ user, setUser }) => { const SettingPage: FC<Props> = ({ user, setUser }) => {
const [name, setName] = useState ('') const [name, setName] = useState ('')
const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const [inheritVsbl, setInheritVsbl] = useState (false) const [inheritVsbl, setInheritVsbl] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<UserFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
if (!(user)) if (!(user))
return return
clearValidationErrors ()
const formData = new FormData const formData = new FormData
formData.append ('name', name) formData.append ('name', name)
@@ -40,8 +50,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setUser (user => ({ ...user, ...data })) setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' }) toast ({ title: '設定を更新しました.' })
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: 'しっぱい……' }) toast ({ title: 'しっぱい……' })
} }
} }
@@ -65,11 +76,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{user ? ( {user ? (
<> <>
<FieldError messages={baseErrors}/>
{/* 名前 */} {/* 名前 */}
<div> <FormField label="表示名" messages={fieldErrors.name}>
<Label></Label> {({ describedBy, invalid }) => (
<>
<input type="text" <input type="text"
className="w-full border rounded p-2" aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name} value={name}
placeholder="名もなきニジラー" placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/> onChange={ev => setName (ev.target.value)}/>
@@ -77,7 +93,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<p className="mt-1 text-sm text-red-500"> <p className="mt-1 text-sm text-red-500">
30 !!!! 30 !!!!
</p>)} </p>)}
</div> </>)}
</FormField>
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
+30 -15
ファイルの表示
@@ -5,12 +5,16 @@ import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite' import MdEditor from 'react-markdown-editor-lite'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
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 { apiGet, apiPut } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import 'react-markdown-editor-lite/lib/index.css'
@@ -23,6 +27,8 @@ const mdParser = new MarkdownIt
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => { const WikiEditPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
@@ -36,8 +42,12 @@ const WikiEditPage: FC<Props> = ({ user }) => {
const [body, setBody] = useState ('') const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true) const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData () const formData = new FormData ()
formData.append ('title', title) formData.append ('title', title)
formData.append ('body', body) formData.append ('body', body)
@@ -52,8 +62,9 @@ const WikiEditPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`) navigate (`/wiki/${ title }`)
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' }) toast ({ title: '投稿失敗', description: '入力を確認してください。' })
} }
} }
@@ -84,24 +95,28 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{loading ? 'Loading...' : ( {loading ? 'Loading...' : (
<> <>
<FieldError messages={baseErrors}/>
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<div> <FormField label="タイトル" messages={fieldErrors.title}>
<label className="block font-semibold mb-1"></label> {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 本文 */} {/* 本文 */}
<div> <FormField label="本文" messages={fieldErrors.body}>
<label className="block font-semibold mb-1"></label> {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/> onChange={({ text }) => setBody (text)}/>)}
</div> </FormField>
{/* 送信 */} {/* 送信 */}
<button onClick={handleSubmit} <button onClick={handleSubmit}
+29 -15
ファイルの表示
@@ -6,11 +6,15 @@ import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite' import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
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 { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import 'react-markdown-editor-lite/lib/index.css'
@@ -21,6 +25,8 @@ const mdParser = new MarkdownIt
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => { const WikiNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
@@ -33,8 +39,12 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const [title, setTitle] = useState (titleQuery) const [title, setTitle] = useState (titleQuery)
const [body, setBody] = useState ('') const [body, setBody] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData const formData = new FormData
formData.append ('title', title) formData.append ('title', title)
formData.append ('body', body) formData.append ('body', body)
@@ -46,8 +56,9 @@ const WikiNewPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`) navigate (`/wiki/${ data.title }`)
} }
catch catch (e)
{ {
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' }) toast ({ title: '投稿失敗', description: '入力を確認してください。' })
} }
} }
@@ -62,25 +73,28 @@ const WikiNewPage: FC<Props> = ({ user }) => {
</Helmet> </Helmet>
<div className="max-w-xl mx-auto p-4 space-y-4"> <div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2"> Wiki </h1> <h1 className="text-2xl font-bold mb-2"> Wiki </h1>
<FieldError messages={baseErrors}/>
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<div> <FormField label="タイトル" messages={fieldErrors.title}>
<label className="block font-semibold mb-1"></label> {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/> aria-describedby={describedBy}
</div> aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 本文 */} {/* 本文 */}
<div> <FormField label="本文" messages={fieldErrors.body}>
<label className="block font-semibold mb-1"></label> {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/> onChange={({ text }) => setBody (text)}/>)}
</div> </FormField>
{/* 送信 */} {/* 送信 */}
<button onClick={handleSubmit} <button onClick={handleSubmit}
+16 -15
ファイルの表示
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent , FC } from 'react' import type { FormEvent , FC } from 'react'
@@ -43,22 +44,22 @@ const WikiSearchPage: FC = () => {
<PageTitle>Wiki</PageTitle> <PageTitle>Wiki</PageTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
<div> <FormField label="タイトル">
<label></label><br /> {({ invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className="border p-1 w-full" /> className={inputClass (invalid)}/>)}
</div> </FormField>
{/* 内容 */} {/* 内容 */}
<div> <FormField label="内容">
<label></label><br /> {({ invalid }) => (
<input type="text" <input type="text"
value={text} value={text}
onChange={e => setText (e.target.value)} onChange={e => setText (e.target.value)}
className="border p-1 w-full" /> className={inputClass (invalid)}/>)}
</div> </FormField>
{/* 検索 */} {/* 検索 */}
<div className="py-3"> <div className="py-3">
+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