#318 #318 #318 #318 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/328feature/321
| @@ -66,7 +66,7 @@ class TagsController < ApplicationController | |||||
| .offset(offset) | .offset(offset) | ||||
| .to_a | .to_a | ||||
| render json: { tags: TagRepr.base(tags), count: q.size } | |||||
| render json: { tags: TagRepr.many(tags), count: q.size } | |||||
| end | end | ||||
| def with_depth | def with_depth | ||||
| @@ -209,6 +209,52 @@ class TagsController < ApplicationController | |||||
| render json: build_tag_children(tag) | render json: build_tag_children(tag) | ||||
| end | end | ||||
| def update_all | |||||
| return head :unauthorized unless current_user | |||||
| return head :forbidden unless current_user.gte_member? | |||||
| tag = Tag.find_by(id: params[:id]) | |||||
| return head :not_found unless tag | |||||
| name = params[:name].to_s.strip | |||||
| category = params[:category].to_s.strip | |||||
| return head :unprocessable_entity if name.blank? || category.blank? | |||||
| if name != tag.name && | |||||
| tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) | |||||
| return render json: { error: 'システム・タグの名称は変更できません.' }, | |||||
| status: :unprocessable_entity | |||||
| end | |||||
| if tag.nico? || category == 'nico' | |||||
| return render json: { error: 'ニコタグは変更できません.' }, | |||||
| status: :unprocessable_entity | |||||
| end | |||||
| alias_names = params[:aliases].to_s.split.uniq | |||||
| parent_names = params[:parent_tags].to_s.split.uniq | |||||
| ApplicationRecord.transaction do | |||||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | |||||
| old_name = tag.name | |||||
| tag.update!(category:) | |||||
| tag.tag_name.update!(name:) | |||||
| alias_names << old_name if name != old_name | |||||
| alias_names.delete(name) | |||||
| update_aliases!(tag, alias_names) | |||||
| update_parent_tags!(tag, parent_names) | |||||
| tag.reload | |||||
| record_tag_version!(tag, event_type: :update, created_by_user: current_user) | |||||
| end | |||||
| render json: TagRepr.base(tag.reload) | |||||
| end | |||||
| def update | def update | ||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.gte_member? | return head :forbidden unless current_user.gte_member? | ||||
| @@ -218,8 +264,8 @@ class TagsController < ApplicationController | |||||
| tag = Tag.find(params[:id]) | tag = Tag.find(params[:id]) | ||||
| if category.present? && tag.nico? != (category == 'nico') | |||||
| return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, | |||||
| if tag.nico? || (category.present? && category == 'nico') | |||||
| return render json: { error: 'ニコタグは変更できません.' }, | |||||
| status: :unprocessable_entity | status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -237,7 +283,7 @@ class TagsController < ApplicationController | |||||
| private | private | ||||
| def build_tag_children(tag) | |||||
| def build_tag_children tag | |||||
| material = tag.materials.first | material = tag.materials.first | ||||
| file = nil | file = nil | ||||
| content_type = nil | content_type = nil | ||||
| @@ -251,11 +297,45 @@ class TagsController < ApplicationController | |||||
| material: material.as_json&.merge(file:, content_type:)) | material: material.as_json&.merge(file:, content_type:)) | ||||
| end | end | ||||
| def record_tag_version!(tag, event_type:, created_by_user:) | |||||
| def record_tag_version! tag, event_type:, created_by_user: | |||||
| if tag.nico? | if tag.nico? | ||||
| NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | ||||
| else | else | ||||
| TagVersionRecorder.record!(tag:, event_type:, created_by_user:) | TagVersionRecorder.record!(tag:, event_type:, created_by_user:) | ||||
| end | end | ||||
| end | end | ||||
| def update_aliases! tag, alias_names | |||||
| current_aliases = tag.tag_name.aliases.to_a | |||||
| current_aliases.each do |alias_tag_name| | |||||
| next if alias_names.include?(alias_tag_name.name) | |||||
| alias_tag_name.update!(canonical: nil) | |||||
| end | |||||
| alias_names.each do |alias_name| | |||||
| alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) | |||||
| alias_tag_name.update!(canonical: tag.tag_name) | |||||
| end | |||||
| end | |||||
| def update_parent_tags! tag, parent_names | |||||
| parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, | |||||
| with_no_deerjikist: false, | |||||
| deny_nico: true) | |||||
| old_parent_tags = tag.parents.to_a | |||||
| TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq, | |||||
| created_by_user: current_user) | |||||
| tag.reversed_tag_implications.destroy_all | |||||
| parent_tags.each do |parent_tag| | |||||
| next if parent_tag == tag | |||||
| TagImplication.create!(tag:, parent_tag:) | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -8,10 +8,9 @@ module TagRepr | |||||
| module_function | module_function | ||||
| def base tag | def base tag | ||||
| tag.as_json(BASE) | |||||
| tag.as_json(BASE).merge(aliases: tag.snapshot_aliases, | |||||
| parents: tag.parents.map { _1.as_json(BASE) }) | |||||
| end | end | ||||
| def many tags | |||||
| tags.map { |t| base(t) } | |||||
| end | |||||
| def many(tags) = tags.map { |t| base(t) } | |||||
| end | end | ||||
| @@ -6,7 +6,7 @@ Rails.application.routes.draw do | |||||
| delete ':child_id', action: :destroy | delete ':child_id', action: :destroy | ||||
| end | end | ||||
| resources :tags, only: [:index, :show, :update] do | |||||
| resources :tags, only: [:index, :show] do | |||||
| collection do | collection do | ||||
| get :autocomplete | get :autocomplete | ||||
| get :'with-depth', action: :with_depth | get :'with-depth', action: :with_depth | ||||
| @@ -19,6 +19,9 @@ Rails.application.routes.draw do | |||||
| end | end | ||||
| member do | member do | ||||
| put '', action: :update_all | |||||
| patch '', action: :update | |||||
| get :deerjikists | get :deerjikists | ||||
| end | end | ||||
| end | end | ||||
| @@ -1,7 +1,6 @@ | |||||
| require 'cgi' | require 'cgi' | ||||
| require 'rails_helper' | require 'rails_helper' | ||||
| RSpec.describe 'Tags API', type: :request do | RSpec.describe 'Tags API', type: :request do | ||||
| let!(:tn) { TagName.create!(name: 'spec_tag') } | let!(:tn) { TagName.create!(name: 'spec_tag') } | ||||
| let!(:tag) { Tag.create!(tag_name: tn, category: :general) } | let!(:tag) { Tag.create!(tag_name: tn, category: :general) } | ||||
| @@ -197,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response_tags.size).to eq(1) | expect(response_tags.size).to eq(1) | ||||
| expect(response_names).to eq(['norm_a']) | expect(response_names).to eq(['norm_a']) | ||||
| end | end | ||||
| it 'returns aliases and parent tags' do | |||||
| parent_tag = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'index_parent_tag'), | |||||
| category: :meme | |||||
| ) | |||||
| TagImplication.create!(tag:, parent_tag:) | |||||
| get '/tags', params: { name: 'spec_tag' } | |||||
| expect(response).to have_http_status(:ok) | |||||
| row = response_tags.find { |t| t['name'] == 'spec_tag' } | |||||
| expect(row['aliases']).to include('unko') | |||||
| expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag') | |||||
| parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' } | |||||
| expect(parent).to include( | |||||
| 'id' => parent_tag.id, | |||||
| 'name' => 'index_parent_tag', | |||||
| 'category' => 'meme' | |||||
| ) | |||||
| end | |||||
| end | end | ||||
| describe 'GET /tags/:id' do | describe 'GET /tags/:id' do | ||||
| @@ -220,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(json).to have_key('created_at') | expect(json).to have_key('created_at') | ||||
| expect(json).to have_key('updated_at') | expect(json).to have_key('updated_at') | ||||
| end | end | ||||
| it 'returns aliases and parent tags' do | |||||
| parent_tag = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'show_parent_tag'), | |||||
| category: :character | |||||
| ) | |||||
| TagImplication.create!(tag:, parent_tag:) | |||||
| request | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json['aliases']).to include('unko') | |||||
| expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag') | |||||
| parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' } | |||||
| expect(parent).to include( | |||||
| 'id' => parent_tag.id, | |||||
| 'name' => 'show_parent_tag', | |||||
| 'category' => 'character' | |||||
| ) | |||||
| end | |||||
| end | end | ||||
| context 'when tag does not exist' do | context 'when tag does not exist' do | ||||
| @@ -359,9 +404,9 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(tag.category).to eq('meta') | expect(tag.category).to eq('meta') | ||||
| end | end | ||||
| it '存在しない id だと RecordNotFound になる(通常は 404)' do | |||||
| it '存在しない id なら 404 を返す' do | |||||
| patch '/tags/999999999', params: { name: 'x' } | patch '/tags/999999999', params: { name: 'x' } | ||||
| expect(response.status).to be_in([404, 500]) | |||||
| expect(response).to have_http_status(:not_found) | |||||
| end | end | ||||
| it 'nico category への変更は 422 を返す' do | it 'nico category への変更は 422 を返す' do | ||||
| @@ -401,21 +446,18 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(tag.reload.category).to eq('general') | expect(tag.reload.category).to eq('general') | ||||
| end | end | ||||
| it 'creates nico tag versions when updating nico tag name' do | |||||
| it 'returns 422 when updating nico tag name' do | |||||
| nico_tag_name = TagName.create!(name: 'nico:tags_spec_source') | nico_tag_name = TagName.create!(name: 'nico:tags_spec_source') | ||||
| nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) | nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) | ||||
| expect { | expect { | ||||
| patch "/tags/#{nico_tag.id}", params: { name: 'nico:tags_spec_renamed' } | |||||
| }.to change(NicoTagVersion, :count).by(2) | |||||
| patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' } | |||||
| }.not_to change(NicoTagVersion, :count) | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| versions = nico_tag.reload.nico_tag_versions.order(:version_no) | |||||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||||
| expect(versions.first.name).to eq('nico:tags_spec_source') | |||||
| expect(versions.second.name).to eq('nico:tags_spec_renamed') | |||||
| expect(versions.second.created_by_user_id).to eq(member_user.id) | |||||
| expect(nico_tag.reload.name).to eq('nico:tags_spec_source') | |||||
| expect(nico_tag.category).to eq('nico') | |||||
| end | end | ||||
| it 'returns 422 when changing nico tag category to normal category' do | it 'returns 422 when changing nico tag category to normal category' do | ||||
| @@ -571,4 +613,269 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response).to have_http_status(:not_found) | expect(response).to have_http_status(:not_found) | ||||
| end | end | ||||
| end | end | ||||
| describe 'PUT /tags/:id' do | |||||
| context '未ログイン' do | |||||
| before { stub_current_user(nil) } | |||||
| it '401 を返す' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'new', | |||||
| category: 'general', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:unauthorized) | |||||
| end | |||||
| end | |||||
| context 'ログインしてゐるが member でない' do | |||||
| before { stub_current_user(non_member_user) } | |||||
| it '403 を返す' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'new', | |||||
| category: 'general', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:forbidden) | |||||
| end | |||||
| end | |||||
| context 'member' do | |||||
| before { stub_current_user(member_user) } | |||||
| it '存在しない id なら 404 を返す' do | |||||
| put '/tags/999999999', params: { | |||||
| name: 'new', | |||||
| category: 'general', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:not_found) | |||||
| end | |||||
| it 'name が空なら 422 を返す' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: '', | |||||
| category: 'general', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(tag.reload.name).to eq('spec_tag') | |||||
| end | |||||
| it 'category が空なら 422 を返す' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'new', | |||||
| category: '', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(tag.reload.name).to eq('spec_tag') | |||||
| expect(tag.category).to eq('general') | |||||
| end | |||||
| it 'name, category, aliases, parent tags をまとめて更新できる' do | |||||
| old_parent = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'put_old_parent'), | |||||
| category: :general | |||||
| ) | |||||
| kept_parent = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'put_kept_parent'), | |||||
| category: :general | |||||
| ) | |||||
| TagImplication.create!(tag:, parent_tag: old_parent) | |||||
| TagImplication.create!(tag:, parent_tag: kept_parent) | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_renamed_tag', | |||||
| category: 'meme', | |||||
| aliases: 'put_alias_a put_alias_b put_alias_a', | |||||
| parent_tags: 'put_kept_parent put_new_parent', | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| expect(tag.name).to eq('put_renamed_tag') | |||||
| expect(tag.category).to eq('meme') | |||||
| expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) | |||||
| expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) | |||||
| old_name_alias = TagName.find_by(name: 'spec_tag') | |||||
| expect(old_name_alias).to be_present | |||||
| expect(old_name_alias.canonical).to eq(tag.tag_name) | |||||
| expect(alias_tn.reload.canonical).to be_nil | |||||
| expect(tag.parents.map(&:name)).to contain_exactly( | |||||
| 'put_kept_parent', | |||||
| 'put_new_parent' | |||||
| ) | |||||
| expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist | |||||
| expect(json['name']).to eq('put_renamed_tag') | |||||
| expect(json['category']).to eq('meme') | |||||
| expect(json['aliases']).to contain_exactly( | |||||
| 'put_alias_a', | |||||
| 'put_alias_b', | |||||
| 'spec_tag' | |||||
| ) | |||||
| expect(json['parents'].map { |t| t['name'] }).to contain_exactly( | |||||
| 'put_kept_parent', | |||||
| 'put_new_parent' | |||||
| ) | |||||
| end | |||||
| it 'aliases に現在名を指定しても alias には残さない' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'spec_tag', | |||||
| category: 'general', | |||||
| aliases: 'spec_tag put_alias_self_test', | |||||
| parent_tags: '', | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| tag.reload | |||||
| expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) | |||||
| expect(json['aliases']).to include('put_alias_self_test') | |||||
| expect(json['aliases']).not_to include('spec_tag') | |||||
| end | |||||
| it 'parent_tags に自分自身を指定しても自己参照は作らない' do | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'spec_tag', | |||||
| category: 'general', | |||||
| aliases: 'unko', | |||||
| parent_tags: 'spec_tag', | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist | |||||
| expect(tag.reload.parents).to eq([]) | |||||
| end | |||||
| it 'initial and update tag versions を作成する' do | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'put_versioned_tag', | |||||
| category: 'meta', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| }.to change(TagVersion, :count).by(2) | |||||
| expect(response).to have_http_status(:ok) | |||||
| versions = tag.reload.tag_versions.order(:version_no) | |||||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||||
| expect(versions.first.name).to eq('spec_tag') | |||||
| expect(versions.first.category).to eq('general') | |||||
| expect(versions.first.aliases.split).to include('unko') | |||||
| expect(versions.second.name).to eq('put_versioned_tag') | |||||
| expect(versions.second.category).to eq('meta') | |||||
| expect(versions.second.aliases.split).to include('spec_tag') | |||||
| expect(versions.second.created_by_user_id).to eq(member_user.id) | |||||
| end | |||||
| it 'parent tag の snapshot も作成する' do | |||||
| old_parent = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'put_snapshot_old_parent'), | |||||
| category: :general | |||||
| ) | |||||
| new_parent = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'put_snapshot_new_parent'), | |||||
| category: :general | |||||
| ) | |||||
| TagImplication.create!(tag:, parent_tag: old_parent) | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'spec_tag', | |||||
| category: 'general', | |||||
| aliases: 'unko', | |||||
| parent_tags: new_parent.name, | |||||
| } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create') | |||||
| expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create') | |||||
| end | |||||
| it 'normal tag を nico category には変更できない' do | |||||
| expect { | |||||
| put "/tags/#{ tag.id }", params: { | |||||
| name: 'spec_tag', | |||||
| category: 'nico', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| }.not_to change(TagVersion, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(tag.reload.name).to eq('spec_tag') | |||||
| expect(tag.category).to eq('general') | |||||
| end | |||||
| it 'nico tag は更新できない' do | |||||
| nico_tag = Tag.create!( | |||||
| tag_name: TagName.create!(name: 'nico:put_update_all_ng'), | |||||
| category: :nico | |||||
| ) | |||||
| expect { | |||||
| put "/tags/#{ nico_tag.id }", params: { | |||||
| name: 'nico:put_update_all_renamed', | |||||
| category: 'nico', | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| }.not_to change(NicoTagVersion, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(nico_tag.reload.name).to eq('nico:put_update_all_ng') | |||||
| expect(nico_tag.category).to eq('nico') | |||||
| end | |||||
| it 'system tag の name は変更できない' do | |||||
| system_tag = Tag.tagme | |||||
| old_name = system_tag.name | |||||
| old_category = system_tag.category | |||||
| expect { | |||||
| put "/tags/#{ system_tag.id }", params: { | |||||
| name: 'put_system_tag_renamed', | |||||
| category: old_category, | |||||
| aliases: '', | |||||
| parent_tags: '', | |||||
| } | |||||
| }.not_to change(TagVersion, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| expect(system_tag.reload.name).to eq(old_name) | |||||
| expect(system_tag.category).to eq(old_category) | |||||
| end | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -26,6 +26,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' | |||||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | import PostSearchPage from '@/pages/posts/PostSearchPage' | ||||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | import ServiceUnavailable from '@/pages/ServiceUnavailable' | ||||
| import SettingPage from '@/pages/users/SettingPage' | import SettingPage from '@/pages/users/SettingPage' | ||||
| import TagDetailPage from '@/pages/tags/TagDetailPage' | |||||
| import TagListPage from '@/pages/tags/TagListPage' | import TagListPage from '@/pages/tags/TagListPage' | ||||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | ||||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | ||||
| @@ -55,6 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||||
| <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> | ||||
| <Route path="/posts/changes" element={<PostHistoryPage/>}/> | <Route path="/posts/changes" element={<PostHistoryPage/>}/> | ||||
| <Route path="/tags" element={<TagListPage/>}/> | <Route path="/tags" element={<TagListPage/>}/> | ||||
| <Route path="/tags/:id" element={<TagDetailPage/>}/> | |||||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | ||||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | ||||
| <Route path="/materials" element={<MaterialBasePage/>}> | <Route path="/materials" element={<MaterialBasePage/>}> | ||||
| @@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TopNavUser from '@/components/TopNavUser' | import TopNavUser from '@/components/TopNavUser' | ||||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | ||||
| import { tagsKeys, wikiKeys } from '@/lib/queryKeys' | import { tagsKeys, wikiKeys } from '@/lib/queryKeys' | ||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { fetchTag, fetchTagByName } from '@/lib/tags' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import { fetchWikiPage } from '@/lib/wiki' | import { fetchWikiPage } from '@/lib/wiki' | ||||
| @@ -29,6 +29,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | ||||
| const wikiTitle = pathName.split ('/')[2] ?? '' | const wikiTitle = pathName.split ('/')[2] ?? '' | ||||
| const tagFlg = /^\/tags\/\d+/.test (pathName) | |||||
| return [ | return [ | ||||
| { name: '広場', to: '/posts', subMenu: [ | { name: '広場', to: '/posts', subMenu: [ | ||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| @@ -38,10 +40,13 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'マスタ', to: '/tags' }, | { name: 'マスタ', to: '/tags' }, | ||||
| { name: '別名タグ', to: '/tags/aliases', visible: false }, | |||||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | |||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | |||||
| { component: <Separator/>, visible: tagFlg }, | |||||
| { name: `広場 (${ postCount || 0 })`, | |||||
| to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, | |||||
| visible: tagFlg }, | |||||
| { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, | |||||
| { name: '素材', to: '/materials', visible: false, subMenu: [ | { name: '素材', to: '/materials', visible: false, subMenu: [ | ||||
| { name: '一覧', to: '/materials' }, | { name: '一覧', to: '/materials' }, | ||||
| { name: '検索', to: '/materials/search', visible: false }, | { name: '検索', to: '/materials/search', visible: false }, | ||||
| @@ -114,12 +119,14 @@ export default (({ user }: Props) => { | |||||
| queryKey: wikiKeys.show (wikiIdStr, { }), | queryKey: wikiKeys.show (wikiIdStr, { }), | ||||
| queryFn: () => fetchWikiPage (wikiIdStr, { }) }) | queryFn: () => fetchWikiPage (wikiIdStr, { }) }) | ||||
| const effectiveTitle = wikiPage?.title ?? '' | |||||
| const tagFlg = /^\/tags\/\d+/.test (location.pathname) | |||||
| const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? '' | |||||
| const { data: tag } = useQuery ({ | const { data: tag } = useQuery ({ | ||||
| enabled: Boolean (effectiveTitle), | enabled: Boolean (effectiveTitle), | ||||
| queryKey: tagsKeys.show (effectiveTitle), | queryKey: tagsKeys.show (effectiveTitle), | ||||
| queryFn: () => fetchTagByName (effectiveTitle) }) | |||||
| queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) | |||||
| const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) | const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) | ||||
| const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) | const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) | ||||
| @@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | |||||
| const mPost = match<{ id: string }> ('/posts/:id') | const mPost = match<{ id: string }> ('/posts/:id') | ||||
| const mWiki = match<{ title: string }> ('/wiki/:title') | const mWiki = match<{ title: string }> ('/wiki/:title') | ||||
| const mTag = match<{ id: string }> ('/tags/:id') | |||||
| const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { | const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { | ||||
| @@ -169,6 +170,19 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { | |||||
| } | } | ||||
| const prefetchTagShow: Prefetcher = async (qc, url) => { | |||||
| const m = mTag (url.pathname) | |||||
| if (!(m)) | |||||
| return | |||||
| const { id } = m.params | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: tagsKeys.show (id), | |||||
| queryFn: () => fetchTag (id) }) | |||||
| } | |||||
| export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | ||||
| { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), | { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), | ||||
| run: prefetchPostsIndex }, | run: prefetchPostsIndex }, | ||||
| @@ -180,7 +194,9 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] | |||||
| { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | ||||
| && 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 => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), | |||||
| run: prefetchTagShow }] | |||||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | ||||
| @@ -0,0 +1,158 @@ | |||||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { useParams } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | |||||
| import Label from '@/components/common/Label' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import MainArea from '@/components/layout/MainArea' | |||||
| import { toast } from '@/components/ui/use-toast' | |||||
| import { CATEGORIES, CATEGORY_NAMES } from '@/consts' | |||||
| import { apiPut } from '@/lib/api' | |||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||||
| import { fetchTag } from '@/lib/tags' | |||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC, FormEvent } from 'react' | |||||
| import type { Category, Tag } from '@/types' | |||||
| export default (() => { | |||||
| const { id } = useParams () | |||||
| const tagId = String (id ?? '') | |||||
| const tagKey = tagsKeys.show (tagId) | |||||
| const { data: tag, isLoading: loading } = useQuery ({ | |||||
| queryKey: tagKey, | |||||
| queryFn: () => fetchTag (tagId) }) | |||||
| const [name, setName] = useState ('') | |||||
| const [category, setCategory] = useState<Category> ('general') | |||||
| const [aliases, setAliases] = useState ('') | |||||
| const [parentTags, setParentTags] = useState ('') | |||||
| const [disabled, setDisabled] = useState (true) | |||||
| const qc = useQueryClient () | |||||
| const handleSubmit = async (e: FormEvent) => { | |||||
| e.preventDefault () | |||||
| const formData = new FormData | |||||
| formData.append ('name', name) | |||||
| formData.append ('category', category) | |||||
| formData.append ('aliases', aliases) | |||||
| formData.append ('parent_tags', parentTags) | |||||
| try | |||||
| { | |||||
| const data = await apiPut<Tag> (`/tags/${ id }`, formData) | |||||
| setName (data.name) | |||||
| setCategory (data.category as Category) | |||||
| setAliases (data.aliases.join (' ')) | |||||
| setParentTags (data.parents.map (t => t.name).join (' ')) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| toast ({ description: '更新しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '更新に失敗しました.' }) | |||||
| } | |||||
| } | |||||
| useEffect (() => { | |||||
| if (!(tag)) | |||||
| { | |||||
| setDisabled (true) | |||||
| return | |||||
| } | |||||
| setName (tag.name) | |||||
| setCategory (tag.category as Category) | |||||
| setAliases (tag.aliases.join (' ')) | |||||
| setParentTags (tag.parents.map (t => t.name).join (' ')) | |||||
| setDisabled (tag.category === 'nico') | |||||
| }, [tag]) | |||||
| return ( | |||||
| <MainArea> | |||||
| {(loading || !(tag)) ? 'Loading...' : ( | |||||
| <div className="max-w-xl"> | |||||
| <PageTitle> | |||||
| <TagLink | |||||
| tag={tag} | |||||
| withWiki={false} | |||||
| withCount={false}/> | |||||
| </PageTitle> | |||||
| <form onSubmit={handleSubmit} className="my-4 space-y-2"> | |||||
| {/* 名称 */} | |||||
| <div> | |||||
| <Label>名称</Label> | |||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | |||||
| type="text" | |||||
| disabled={disabled} | |||||
| value={name} | |||||
| onChange={e => setName (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* カテゴリ */} | |||||
| <div> | |||||
| <Label>カテゴリ</Label> | |||||
| <select | |||||
| disabled={disabled} | |||||
| value={category ?? ''} | |||||
| onChange={e => setCategory(e.target.value as Category)} | |||||
| className="w-full border p-2 rounded"> | |||||
| {CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico') | |||||
| .map (cat => ( | |||||
| <option key={cat} value={cat}> | |||||
| {CATEGORY_NAMES[cat]} | |||||
| </option>))} | |||||
| </select> | |||||
| </div> | |||||
| {/* 別名 */} | |||||
| <div> | |||||
| <Label>別名</Label> | |||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | |||||
| type="text" | |||||
| disabled={disabled} | |||||
| value={aliases} | |||||
| onChange={e => setAliases (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* 上位タグ */} | |||||
| <div> | |||||
| <Label>上位タグ</Label> | |||||
| {/* TODO: 補完に対応させる */} | |||||
| <input | |||||
| type="text" | |||||
| disabled={disabled} | |||||
| value={parentTags} | |||||
| onChange={e => setParentTags (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| <div className="py-3"> | |||||
| <button | |||||
| type="submit" | |||||
| disabled={disabled} | |||||
| className={cn ('px-4 py-2 rounded', | |||||
| (disabled | |||||
| ? 'text-gray-300 bg-gray-500' | |||||
| : 'text-white bg-blue-500'))}> | |||||
| 更新 | |||||
| </button> | |||||
| </div> | |||||
| </form> | |||||
| </div>)} | |||||
| </MainArea>) | |||||
| }) satisfies FC | |||||
| @@ -205,13 +205,15 @@ export default (() => { | |||||
| {loading ? 'Loading...' : (results.length > 0 ? ( | {loading ? 'Loading...' : (results.length > 0 ? ( | ||||
| <div className="mt-4"> | <div className="mt-4"> | ||||
| <div className="overflow-x-auto"> | <div className="overflow-x-auto"> | ||||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||||
| <table className="w-full min-w-[2000px] table-fixed border-collapse"> | |||||
| <colgroup> | <colgroup> | ||||
| <col className="w-72"/> | <col className="w-72"/> | ||||
| <col className="w-48"/> | |||||
| <col className="w-16"/> | <col className="w-16"/> | ||||
| <col className="w-44"/> | |||||
| <col className="w-44"/> | |||||
| <col className="w-48"/> | |||||
| <col className="w-72"/> | |||||
| <col className="w-48"/> | |||||
| <col className="w-56"/> | |||||
| <col className="w-56"/> | |||||
| <col className="w-16"/> | <col className="w-16"/> | ||||
| </colgroup> | </colgroup> | ||||
| @@ -226,18 +228,20 @@ export default (() => { | |||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="category" | |||||
| label="カテゴリ" | |||||
| by="post_count" | |||||
| label="件数" | |||||
| currentOrder={order} | currentOrder={order} | ||||
| defaultDirection={defaultDirection}/> | defaultDirection={defaultDirection}/> | ||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="post_count" | |||||
| label="件数" | |||||
| by="category" | |||||
| label="カテゴリ" | |||||
| currentOrder={order} | currentOrder={order} | ||||
| defaultDirection={defaultDirection}/> | defaultDirection={defaultDirection}/> | ||||
| </th> | </th> | ||||
| <th className="p-2 text-left whitespace-nowrap">別名</th> | |||||
| <th className="p-2 text-left whitespace-nowrap">上位タグ</th> | |||||
| <th className="p-2 text-left whitespace-nowrap"> | <th className="p-2 text-left whitespace-nowrap"> | ||||
| <SortHeader<FetchTagsOrderField> | <SortHeader<FetchTagsOrderField> | ||||
| by="created_at" | by="created_at" | ||||
| @@ -260,10 +264,23 @@ export default (() => { | |||||
| {results.map (row => ( | {results.map (row => ( | ||||
| <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <TagLink tag={row} withCount={false}/> | |||||
| <TagLink | |||||
| tag={row} | |||||
| to={`/tags/${ encodeURIComponent (row.id) }`} | |||||
| withCount={false}/> | |||||
| </td> | </td> | ||||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||||
| <td className="p-2 text-right">{row.postCount}</td> | <td className="p-2 text-right">{row.postCount}</td> | ||||
| <td className="p-2">{CATEGORY_NAMES[row.category]}</td> | |||||
| <td className="p-2">{row.aliases.join (' ')}</td> | |||||
| <td className="p-2"> | |||||
| {row.parents.map (t => ( | |||||
| <span key={t.id} className="mr-2"> | |||||
| <TagLink | |||||
| tag={t} | |||||
| withWiki={false} | |||||
| withCount={false}/> | |||||
| </span>))} | |||||
| </td> | |||||
| <td className="p-2">{dateString (row.createdAt)}</td> | <td className="p-2">{dateString (row.createdAt)}</td> | ||||
| <td className="p-2">{dateString (row.updatedAt)}</td> | <td className="p-2">{dateString (row.updatedAt)}</td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| @@ -114,13 +114,15 @@ export default () => { | |||||
| {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | ||||
| </h1> | </h1> | ||||
| {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} | {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} | ||||
| </article> | |||||
| {(!(version) && posts.length > 0) && ( | |||||
| <TabGroup> | |||||
| <Tab name="広場"> | |||||
| <PostList posts={posts}/> | |||||
| </Tab> | |||||
| </TabGroup>)} | |||||
| {(!(version) && posts.length > 0) && ( | |||||
| <div className="not-prose"> | |||||
| <TabGroup> | |||||
| <Tab name="広場"> | |||||
| <PostList posts={posts}/> | |||||
| </Tab> | |||||
| </TabGroup> | |||||
| </div>)} | |||||
| </article> | |||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -165,6 +165,8 @@ export type Tag = { | |||||
| id: number | id: number | ||||
| name: string | name: string | ||||
| category: Category | category: Category | ||||
| aliases: string[] | |||||
| parents: Tag[] | |||||
| postCount: number | postCount: number | ||||
| createdAt: string | createdAt: string | ||||
| updatedAt: string | updatedAt: string | ||||