Merge branch 'main' into feature/323
This commit is contained in:
@@ -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
|
collection do
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
get :'with-depth', action: :with_depth
|
get :'with-depth', action: :with_depth
|
||||||
|
get :versions, to: 'tag_versions#index'
|
||||||
|
|
||||||
scope :name do
|
scope :name do
|
||||||
get ':name/deerjikists', action: :deerjikists_by_name
|
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 ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||||
import SettingPage from '@/pages/users/SettingPage'
|
import SettingPage from '@/pages/users/SettingPage'
|
||||||
import TagDetailPage from '@/pages/tags/TagDetailPage'
|
import TagDetailPage from '@/pages/tags/TagDetailPage'
|
||||||
|
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
|
||||||
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'
|
||||||
@@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/tags" element={<TagListPage/>}/>
|
<Route path="/tags" element={<TagListPage/>}/>
|
||||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
|
<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/>}>
|
||||||
<Route index element={<MaterialListPage/>}/>
|
<Route index element={<MaterialListPage/>}/>
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
|||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
{ name: 'マスタ', to: '/tags' },
|
{ name: 'マスタ', to: '/tags' },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||||
|
{ name: '履歴', to: '/tags/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
||||||
{ component: <Separator/>, visible: tagFlg },
|
{ component: <Separator/>, visible: tagFlg },
|
||||||
{ name: `広場 (${ postCount || 0 })`,
|
{ name: `広場 (${ postCount || 0 })`,
|
||||||
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
||||||
visible: tagFlg },
|
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', visible: false, subMenu: [
|
||||||
{ name: '一覧', to: '/materials' },
|
{ name: '一覧', to: '/materials' },
|
||||||
{ name: '検索', to: '/materials/search', visible: false },
|
{ name: '検索', to: '/materials/search', visible: false },
|
||||||
|
|||||||
@@ -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, fetchTags } from '@/lib/tags'
|
import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||||
import { fetchWikiPage,
|
import { fetchWikiPage,
|
||||||
fetchWikiPageByTitle,
|
fetchWikiPageByTitle,
|
||||||
fetchWikiPages } from '@/lib/wiki'
|
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 }[] = [
|
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 },
|
||||||
@@ -195,8 +206,10 @@ 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 => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)),
|
{ test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname))
|
||||||
run: prefetchTagShow }]
|
&& Boolean (mTag (u.pathname))),
|
||||||
|
run: prefetchTagShow },
|
||||||
|
{ test: u => u.pathname === '/tags/changes', run: prefetchTagChanges }]
|
||||||
|
|
||||||
|
|
||||||
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
|
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ export const postsKeys = {
|
|||||||
['posts', 'changes', p] as const }
|
['posts', 'changes', p] as const }
|
||||||
|
|
||||||
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,
|
||||||
show: (name: string) => ['tags', name] 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 = {
|
export const wikiKeys = {
|
||||||
root: ['wiki'] as const,
|
root: ['wiki'] as const,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { FetchTagsParams, Tag } from '@/types'
|
import type { FetchTagsParams, Tag, TagVersion } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
export const fetchTags = async (
|
export const fetchTags = async (
|
||||||
@@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
|||||||
return 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[]
|
children?: Tag[]
|
||||||
matchedAlias?: string | null }
|
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 = {
|
export type Theatre = {
|
||||||
id: number
|
id: number
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user