#321 #321 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/330feature/200
| @@ -0,0 +1,92 @@ | |||
| class TagVersionsController < ApplicationController | |||
| def index | |||
| tag_id = params[:id].presence | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| page = 1 if page < 1 | |||
| limit = 1 if limit < 1 | |||
| offset = (page - 1) * limit | |||
| q = TagVersion.joins(<<~SQL.squish) | |||
| LEFT JOIN | |||
| tag_versions prev | |||
| ON | |||
| prev.tag_id = tag_versions.tag_id | |||
| AND prev.version_no = tag_versions.version_no - 1 | |||
| SQL | |||
| .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', | |||
| 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') | |||
| q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id | |||
| count = q.except(:select, :order, :limit, :offset).count | |||
| versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_versions.id DESC')) | |||
| .limit(limit) | |||
| .offset(offset) | |||
| render json: { versions: serialise_versions(versions), count: } | |||
| end | |||
| private | |||
| def serialise_versions rows | |||
| user_ids = rows.map(&:created_by_user_id).compact.uniq | |||
| users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h | |||
| rows.map do |row| | |||
| cur_aliases = split_values(row.aliases) | |||
| prev_aliases = split_values(row.attributes['prev_aliases']) | |||
| cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) | |||
| prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) | |||
| all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids) | |||
| tags_by_id = | |||
| Tag | |||
| .includes(:tag_name, :materials, { tag_name: :wiki_page }) | |||
| .where(id: all_parent_tag_ids) | |||
| .index_by(&:id) | |||
| parent_tags = | |||
| build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) | |||
| .map do |h| | |||
| { tag: TagRepr.base(tags_by_id[h[:tag_id]]), | |||
| type: h[:type] } | |||
| end | |||
| { tag_id: row.tag_id, | |||
| version_no: row.version_no, | |||
| event_type: row.event_type, | |||
| name: { current: row.name, prev: row.attributes['prev_name'] }, | |||
| category: { current: row.category, prev: row.attributes['prev_category'] }, | |||
| aliases: build_version_values(cur_aliases, prev_aliases, key: :name), | |||
| parent_tags:, | |||
| created_at: row.created_at.iso8601, | |||
| created_by_user: row.created_by_user_id && | |||
| { id: row.created_by_user_id, | |||
| name: users_by_id[row.created_by_user_id] } } | |||
| end | |||
| end | |||
| def build_version_values cur_values, prev_values, key: | |||
| (cur_values | prev_values).map do |value| | |||
| type = | |||
| if cur_values.include?(value) && prev_values.include?(value) | |||
| 'context' | |||
| elsif cur_values.include?(value) | |||
| 'added' | |||
| else | |||
| 'removed' | |||
| end | |||
| { key => value, type: } | |||
| end | |||
| end | |||
| def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) | |||
| def split_parent_tag_ids(values) = split_values(values).map(&:to_i) | |||
| end | |||
| @@ -10,6 +10,7 @@ Rails.application.routes.draw do | |||
| collection do | |||
| get :autocomplete | |||
| get :'with-depth', action: :with_depth | |||
| get :versions, to: 'tag_versions#index' | |||
| scope :name do | |||
| get ':name/deerjikists', action: :deerjikists_by_name | |||
| @@ -0,0 +1,218 @@ | |||
| require 'rails_helper' | |||
| RSpec.describe 'TagVersions API', type: :request do | |||
| let(:member) { create(:user, :member, name: 'version member') } | |||
| let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } | |||
| let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } | |||
| let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } | |||
| let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } | |||
| let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } | |||
| let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } | |||
| let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } | |||
| let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } | |||
| let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } | |||
| def create_tag_version!( | |||
| tag:, | |||
| version_no:, | |||
| event_type:, | |||
| name:, | |||
| category:, | |||
| aliases: [], | |||
| parent_tags: [], | |||
| created_by_user:, | |||
| created_at: | |||
| ) | |||
| TagVersion.create!( | |||
| tag: tag, | |||
| version_no: version_no, | |||
| event_type: event_type, | |||
| name: name, | |||
| category: category, | |||
| aliases: Array(aliases).join(' '), | |||
| parent_tag_ids: Array(parent_tags).map(&:id).join(' '), | |||
| created_by_user: created_by_user, | |||
| created_at: created_at | |||
| ) | |||
| end | |||
| let!(:v1) do | |||
| create_tag_version!( | |||
| tag: tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: 'old_tag_name', | |||
| category: 'general', | |||
| aliases: ['alias_shared', 'alias_old'], | |||
| parent_tags: [parent_shared, parent_old], | |||
| created_by_user: member, | |||
| created_at: t_v1 | |||
| ) | |||
| end | |||
| let!(:v2) do | |||
| create_tag_version!( | |||
| tag: tag, | |||
| version_no: 2, | |||
| event_type: 'update', | |||
| name: 'new_tag_name', | |||
| category: 'meme', | |||
| aliases: ['alias_shared', 'alias_new'], | |||
| parent_tags: [parent_shared, parent_new], | |||
| created_by_user: member, | |||
| created_at: t_v2 | |||
| ) | |||
| end | |||
| let!(:other_v1) do | |||
| create_tag_version!( | |||
| tag: other_tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: 'other_tag_name', | |||
| category: 'general', | |||
| aliases: ['other_alias'], | |||
| parent_tags: [other_parent], | |||
| created_by_user: member, | |||
| created_at: t_other | |||
| ) | |||
| end | |||
| describe 'GET /tags/versions' do | |||
| it 'returns all versions in reverse chronological order when id is omitted' do | |||
| get '/tags/versions' | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include('versions', 'count') | |||
| expect(json.fetch('count')).to eq(3) | |||
| versions = json.fetch('versions') | |||
| expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([ | |||
| [other_tag.id, 1], | |||
| [tag.id, 2], | |||
| [tag.id, 1] | |||
| ]) | |||
| end | |||
| it 'returns versions for the specified tag with diffs' do | |||
| get '/tags/versions', params: { id: tag.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to include('versions', 'count') | |||
| expect(json.fetch('count')).to eq(2) | |||
| versions = json.fetch('versions') | |||
| expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id]) | |||
| expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) | |||
| latest = versions.first | |||
| expect(latest).to include( | |||
| 'tag_id' => tag.id, | |||
| 'version_no' => 2, | |||
| 'event_type' => 'update', | |||
| 'created_by_user' => { | |||
| 'id' => member.id, | |||
| 'name' => member.name | |||
| } | |||
| ) | |||
| expect(latest.fetch('name')).to eq( | |||
| 'current' => 'new_tag_name', | |||
| 'prev' => 'old_tag_name' | |||
| ) | |||
| expect(latest.fetch('category')).to eq( | |||
| 'current' => 'meme', | |||
| 'prev' => 'general' | |||
| ) | |||
| expect(latest.fetch('aliases')).to include( | |||
| { 'name' => 'alias_shared', 'type' => 'context' }, | |||
| { 'name' => 'alias_new', 'type' => 'added' }, | |||
| { 'name' => 'alias_old', 'type' => 'removed' } | |||
| ) | |||
| expect(latest.fetch('parent_tags')).to include( | |||
| a_hash_including( | |||
| 'type' => 'context', | |||
| 'tag' => a_hash_including( | |||
| 'id' => parent_shared.id | |||
| ) | |||
| ), | |||
| a_hash_including( | |||
| 'type' => 'added', | |||
| 'tag' => a_hash_including( | |||
| 'id' => parent_new.id | |||
| ) | |||
| ), | |||
| a_hash_including( | |||
| 'type' => 'removed', | |||
| 'tag' => a_hash_including( | |||
| 'id' => parent_old.id | |||
| ) | |||
| ) | |||
| ) | |||
| expect(latest.fetch('created_at')).to eq(t_v2.iso8601) | |||
| first = versions.second | |||
| expect(first).to include( | |||
| 'tag_id' => tag.id, | |||
| 'version_no' => 1, | |||
| 'event_type' => 'create', | |||
| 'created_by_user' => { | |||
| 'id' => member.id, | |||
| 'name' => member.name | |||
| } | |||
| ) | |||
| expect(first.fetch('name')).to eq( | |||
| 'current' => 'old_tag_name', | |||
| 'prev' => nil | |||
| ) | |||
| expect(first.fetch('category')).to eq( | |||
| 'current' => 'general', | |||
| 'prev' => nil | |||
| ) | |||
| expect(first.fetch('aliases')).to include( | |||
| { 'name' => 'alias_shared', 'type' => 'added' }, | |||
| { 'name' => 'alias_old', 'type' => 'added' } | |||
| ) | |||
| expect(first.fetch('parent_tags')).to include( | |||
| a_hash_including( | |||
| 'type' => 'added', | |||
| 'tag' => a_hash_including( | |||
| 'id' => parent_shared.id | |||
| ) | |||
| ), | |||
| a_hash_including( | |||
| 'type' => 'added', | |||
| 'tag' => a_hash_including( | |||
| 'id' => parent_old.id | |||
| ) | |||
| ) | |||
| ) | |||
| expect(first.fetch('created_at')).to eq(t_v1.iso8601) | |||
| end | |||
| it 'returns empty when the specified tag has no versions' do | |||
| fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) | |||
| get '/tags/versions', params: { id: fresh_tag.id } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json.fetch('versions')).to eq([]) | |||
| expect(json.fetch('count')).to eq(0) | |||
| end | |||
| it 'clamps page and limit to at least 1' do | |||
| get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 } | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json.fetch('count')).to eq(2) | |||
| versions = json.fetch('versions') | |||
| expect(versions.size).to eq(1) | |||
| expect(versions.first['version_no']).to eq(2) | |||
| end | |||
| end | |||
| end | |||
| @@ -27,6 +27,7 @@ import PostSearchPage from '@/pages/posts/PostSearchPage' | |||
| import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import SettingPage from '@/pages/users/SettingPage' | |||
| import TagDetailPage from '@/pages/tags/TagDetailPage' | |||
| import TagHistoryPage from '@/pages/tags/TagHistoryPage' | |||
| import TagListPage from '@/pages/tags/TagListPage' | |||
| import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' | |||
| import WikiDetailPage from '@/pages/wiki/WikiDetailPage' | |||
| @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||
| <Route path="/tags" element={<TagListPage/>}/> | |||
| <Route path="/tags/:id" element={<TagDetailPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/tags/changes" element={<TagHistoryPage/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | |||
| <Route path="/materials" element={<MaterialBasePage/>}> | |||
| <Route index element={<MaterialListPage/>}/> | |||
| @@ -41,12 +41,14 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||
| { name: 'タグ', to: '/tags', subMenu: [ | |||
| { name: 'マスタ', to: '/tags' }, | |||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | |||
| { name: '履歴', to: '/tags/changes' }, | |||
| { 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: `/tags/changes?id=${ tag?.id }`, | |||
| visible: tagFlg && tag?.category !== 'nico' }] }, | |||
| { name: '素材', to: '/materials', visible: false, subMenu: [ | |||
| { name: '一覧', to: '/materials' }, | |||
| { name: '検索', to: '/materials/search', visible: false }, | |||
| @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' | |||
| import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | |||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||
| import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' | |||
| import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' | |||
| import { fetchWikiPage, | |||
| fetchWikiPageByTitle, | |||
| fetchWikiPages } from '@/lib/wiki' | |||
| @@ -183,6 +183,17 @@ const prefetchTagShow: Prefetcher = async (qc, url) => { | |||
| } | |||
| const prefetchTagChanges: Prefetcher = async (qc, url) => { | |||
| const id = url.searchParams.get ('id') | |||
| const page = Number (url.searchParams.get ('page') || 1) | |||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||
| await qc.prefetchQuery ({ | |||
| queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), | |||
| queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) | |||
| } | |||
| export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | |||
| { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), | |||
| run: prefetchPostsIndex }, | |||
| @@ -195,8 +206,10 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] | |||
| && Boolean (mWiki (u.pathname))), | |||
| run: prefetchWikiPageShow }, | |||
| { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, | |||
| { test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), | |||
| run: prefetchTagShow }] | |||
| { test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname)) | |||
| && Boolean (mTag (u.pathname))), | |||
| run: prefetchTagShow }, | |||
| { test: u => u.pathname === '/tags/changes', run: prefetchTagChanges }] | |||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | |||
| @@ -9,9 +9,11 @@ export const postsKeys = { | |||
| ['posts', 'changes', p] as const } | |||
| export const tagsKeys = { | |||
| root: ['tags'] as const, | |||
| index: (p: FetchTagsParams) => ['tags', 'index', p] as const, | |||
| show: (name: string) => ['tags', name] as const } | |||
| root: ['tags'] as const, | |||
| index: (p: FetchTagsParams) => ['tags', 'index', p] as const, | |||
| show: (name: string) => ['tags', name] as const, | |||
| changes: (p: { id?: string; page: number; limit: number }) => | |||
| ['tags', 'changes', p] as const } | |||
| export const wikiKeys = { | |||
| root: ['wiki'] as const, | |||
| @@ -1,6 +1,6 @@ | |||
| import { apiGet } from '@/lib/api' | |||
| import type { FetchTagsParams, Tag } from '@/types' | |||
| import type { FetchTagsParams, Tag, TagVersion } from '@/types' | |||
| export const fetchTags = async ( | |||
| @@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => { | |||
| return null | |||
| } | |||
| } | |||
| export const fetchTagChanges = async ( | |||
| { id, page, limit }: { | |||
| id?: string | |||
| page: number | |||
| limit: number }, | |||
| ): Promise<{ | |||
| versions: TagVersion[] | |||
| count: number }> => | |||
| await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) | |||
| @@ -0,0 +1,212 @@ | |||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||
| import { useEffect } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useLocation } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { CATEGORY_NAMES } from '@/consts' | |||
| import { apiPut } from '@/lib/api' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTagChanges } from '@/lib/tags' | |||
| import { cn, dateString } from '@/lib/utils' | |||
| import type { FC } from 'react' | |||
| const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||
| <> | |||
| {(diff.prev && diff.prev !== diff.current) && ( | |||
| <> | |||
| <del className="text-red-600 dark:text-red-400"> | |||
| {diff.prev} | |||
| </del> | |||
| {diff.current && <br/>} | |||
| </>)} | |||
| {diff.current} | |||
| </>) | |||
| export default (() => { | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const id = query.get ('id') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), | |||
| queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) | |||
| const changes = data?.versions ?? [] | |||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||
| const qc = useQueryClient () | |||
| useEffect (() => { | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| return ( | |||
| <MainArea> | |||
| <Helmet> | |||
| <title>{`タグ定義変更履歴 | ${ SITE_TITLE }`}</title> | |||
| </Helmet> | |||
| <PageTitle> | |||
| タグ定義変更履歴 | |||
| {id && <>: タグ {<PrefetchLink to={`/tags/${ id }`}>#{id}</PrefetchLink>}</>} | |||
| </PageTitle> | |||
| {loading ? 'Loading...' : ( | |||
| <> | |||
| <div className="overflow-x-auto"> | |||
| <table className="w-full min-w-[1200px] table-fixed border-collapse"> | |||
| <colgroup> | |||
| {/* 版 */} | |||
| <col className="w-40"/> | |||
| {/* 名称 */} | |||
| <col className="w-96"/> | |||
| {/* カテゴリ */} | |||
| <col className="w-96"/> | |||
| {/* 別名 */} | |||
| <col className="w-[48rem]"/> | |||
| {/* 上位タグ */} | |||
| <col className="w-96"/> | |||
| {/* 更新日時 */} | |||
| <col className="w-64"/> | |||
| {/* (差戻ボタン) */} | |||
| <col className="w-20"/> | |||
| </colgroup> | |||
| <thead className="border-b-2 border-black dark:border-white"> | |||
| <tr> | |||
| <th className="p-2 text-left">版</th> | |||
| <th className="p-2 text-left">名称</th> | |||
| <th className="p-2 text-left">カテゴリ</th> | |||
| <th className="p-2 text-left">別名</th> | |||
| <th className="p-2 text-left">上位タグ</th> | |||
| <th className="p-2 text-left">更新日時</th> | |||
| <th className="p-2"/> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {changes.map (change => ( | |||
| <tr key={`${ change.tagId }.${ change.versionNo }`} | |||
| className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}> | |||
| <td className="p-2">{change.tagId}.{change.versionNo}</td> | |||
| <td className="p-2 break-all">{renderDiff (change.name)}</td> | |||
| <td className="p-2 break-all"> | |||
| {renderDiff ({ | |||
| current: CATEGORY_NAMES[change.category.current], | |||
| prev: (change.category.prev | |||
| && CATEGORY_NAMES[change.category.prev]) })} | |||
| </td> | |||
| <td className="p-2"> | |||
| {change.aliases.map ((tag, i) => ( | |||
| tag.type === 'added' | |||
| ? ( | |||
| <ins | |||
| key={i} | |||
| className="mr-2 text-green-600 dark:text-green-400"> | |||
| {tag.name} | |||
| </ins>) | |||
| : ( | |||
| tag.type === 'removed' | |||
| ? ( | |||
| <del | |||
| key={i} | |||
| className="mr-2 text-red-600 dark:text-red-400"> | |||
| {tag.name} | |||
| </del>) | |||
| : ( | |||
| <span key={i} className="mr-2"> | |||
| {tag.name} | |||
| </span>))))} | |||
| </td> | |||
| <td className="p-2"> | |||
| {change.parentTags.map ((tag, i) => ( | |||
| tag.type === 'added' | |||
| ? ( | |||
| <ins | |||
| key={i} | |||
| className="mr-2 text-green-600 dark:text-green-400"> | |||
| {tag.tag.name} | |||
| </ins>) | |||
| : ( | |||
| tag.type === 'removed' | |||
| ? ( | |||
| <del | |||
| key={i} | |||
| className="mr-2 text-red-600 dark:text-red-400"> | |||
| {tag.tag.name} | |||
| </del>) | |||
| : ( | |||
| <span key={i} className="mr-2"> | |||
| {tag.tag.name} | |||
| </span>))))} | |||
| </td> | |||
| <td className="p-2"> | |||
| {change.createdByUser | |||
| ? ( | |||
| <PrefetchLink to={`/users/${ change.createdByUser.id }`}> | |||
| {change.createdByUser.name | |||
| || `名もなきニジラー(#${ change.createdByUser.id })`} | |||
| </PrefetchLink>) | |||
| : 'bot 操作'} | |||
| <br/> | |||
| {dateString (change.createdAt)} | |||
| </td> | |||
| <td className="p-2"> | |||
| <a | |||
| href="#" | |||
| onClick={async e => { | |||
| e.preventDefault () | |||
| if (!(confirm ( | |||
| `タグ『${ change.name.current }』を版 ${ | |||
| change.versionNo } に差戻します.\nよろしいですか?`))) | |||
| return | |||
| try | |||
| { | |||
| await apiPut ( | |||
| `/tags/${ change.tagId }`, | |||
| { name: change.name.current, | |||
| category: change.category.current, | |||
| aliases: | |||
| change.aliases | |||
| .filter (t => t.type !== 'removed') | |||
| .map (t => t.name) | |||
| .join (' '), | |||
| parent_tags: | |||
| change.parentTags | |||
| .filter (t => t.type !== 'removed') | |||
| .map (t => t.tag.name) | |||
| .join (' ') }) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| toast ({ description: '差戻しました.' }) | |||
| } | |||
| catch | |||
| { | |||
| toast ({ description: '差戻に失敗……' }) | |||
| } | |||
| }}> | |||
| 復元 | |||
| </a> | |||
| </td> | |||
| </tr>))} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>)} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| @@ -175,6 +175,17 @@ export type Tag = { | |||
| children?: Tag[] | |||
| matchedAlias?: string | null } | |||
| export type TagVersion = { | |||
| tagId: number | |||
| versionNo: number | |||
| eventType: 'create' | 'update' | 'discard' | 'restore' | |||
| name: { current: string; prev: string | null } | |||
| category: { current: Category; prev: Category | null } | |||
| aliases: { name: string; type: 'context' | 'added' | 'removed' }[] | |||
| parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] | |||
| createdAt: string | |||
| createdByUser: { id: number; name: string | null } | null } | |||
| export type Theatre = { | |||
| id: number | |||
| name: string | null | |||