外部タグを分離 (#419) (#420)

**本番 DB のバックアップをかならずすること**

Reviewed-on: #420
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #420 でマージされました。
このコミットが含まれているのは:
2026-09-25 01:15:06 +09:00
committed by みてるぞ
コミット f336b2f13b
58個のファイルの変更、2439行の追加、630行の削除
+27 -2
ファイルの表示
@@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
const renderRow = (
activeDndId?: string,
renderedTag = tag,
) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
tag={renderedTag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
@@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
it ('disables drag and drop for external tags', () => {
const external = buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
})
renderRow (undefined, external)
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
expect (dndKit.useDroppable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
})
})
+6 -3
ファイルの表示
@@ -38,6 +38,7 @@ const DraggableDroppableTagRow: FC<Props> = ({
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const dndId = `tag-node:${ pathKey }`
const dndDisabled = tag.category === 'nico'
const downPosRef = useRef<{ x: number; y: number } | null> (null)
const armedRef = useRef (false)
@@ -62,7 +63,8 @@ const DraggableDroppableTagRow: FC<Props> = ({
const { attributes,
listeners,
setNodeRef: setDragRef,
transform } = useDraggable ({ id: dndId,
transform } = useDraggable ({ id: dndId,
disabled: dndDisabled,
data: { kind: 'tag',
dndId,
tagId: tag.id,
@@ -70,8 +72,9 @@ const DraggableDroppableTagRow: FC<Props> = ({
nestLevel } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId,
data: { kind: 'tag', tagId: tag.id } })
id: dndId,
disabled: dndDisabled,
data: { kind: 'tag', tagId: tag.id } })
const activeDragging = activeDndId === dndId
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
+66
ファイルの表示
@@ -0,0 +1,66 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import { setClientTagRelationDisplayMode } from '@/lib/settings'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
vi.mock ('@/components/TagSearch', () => ({
default: () => null,
}))
vi.mock ('@/components/DraggableDroppableTagRow', () => ({
default: ({ tag }: { tag: { name: string } }) => (
<span>{tag.name}</span>
),
}))
vi.mock ('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: 'always' },
MouseSensor: vi.fn (),
TouchSensor: vi.fn (),
pointerWithin: vi.fn (),
useDroppable: vi.fn (() => ({
setNodeRef: vi.fn (),
isOver: false,
})),
useSensor: vi.fn (() => ({ })),
useSensors: vi.fn (() => []),
}))
describe ('TagDetailSidebar', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('keeps internal and external tags with the same numeric id in flat mode', () => {
setClientTagRelationDisplayMode ('flat')
const internal = buildTag ({
id: 7,
name: 'internal_collision',
category: 'general',
})
const external = buildTag ({
id: 7,
name: 'nico:external_collision',
category: 'nico',
})
renderWithProviders (
<TagDetailSidebar
post={buildPost ({
tags: [internal, external],
})}/>,
)
expect (screen.getByText ('internal_collision')).toBeInTheDocument ()
expect (screen.getByText ('nico:external_collision')).toBeInTheDocument ()
})
})
+4 -3
ファイルの表示
@@ -150,16 +150,17 @@ const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
const seen = new Set<string> ()
for (const category of CATEGORIES)
tagsTmp[category] = []
const visit = (tag: TagWithSections) => {
if (seen.has (tag.id))
const key = `${ tag.category }:${ tag.id }`
if (seen.has (key))
return
seen.add (tag.id)
seen.add (key)
tagsTmp[tag.category].push ({ ...tag, children: [] })
for (const child of tag.children ?? [])
+27
ファイルの表示
@@ -57,4 +57,31 @@ describe ('TagLink', () => {
expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
})
it ('does not show a missing-information marker for external tags', () => {
renderWithProviders (
<TagLink
tag={buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
})}
withCount={false}/>,
)
expect (
screen.getByRole ('link', { name: 'nico:external' }),
).toBeInTheDocument ()
expect (
screen.queryByRole ('link', { name: '!' }),
).not.toBeInTheDocument ()
expect (
screen.queryByTitle ('nico:external Wiki が存在しません.'),
).not.toBeInTheDocument ()
})
})
+1 -1
ファイルの表示
@@ -86,7 +86,7 @@ const TagLink: FC<Props> = ({ tag,
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && (
{(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && (
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
+2 -4
ファイルの表示
@@ -1,10 +1,8 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
import type { ComponentProps, FC } from 'react'
type Props = { children: React.ReactNode; className?: string }
type Props = ComponentProps<'h1'>
const PageTitle: FC<Props> = ({ children, className, ...rest }) => (
+21
ファイルの表示
@@ -116,4 +116,25 @@ describe ('posts API functions', () => {
{ params: { page: 2, limit: 50 } },
)
})
it ('maps an explicit external tag history filter to external_tag', async () => {
api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
await fetchPostChanges ({
externalTag: '7',
page: 2,
limit: 50,
})
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{
params: {
external_tag: '7',
page: 2,
limit: 50,
},
},
)
})
})
+8 -5
ファイルの表示
@@ -28,15 +28,18 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po
export const fetchPostChanges = async (
{ post, tag, page, limit }: {
post?: string
tag?: string
page: number
limit: number }): Promise<{
{ post, tag, externalTag, page, limit }: {
post?: string
tag?: string
externalTag?: string
page: number
limit: number }): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
...(tag && { tag }),
...(externalTag && {
external_tag: externalTag }),
page, limit } })
+15
ファイルの表示
@@ -137,4 +137,19 @@ describe ('prefetchForURL', () => {
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
})
it ('prefetches external tag post history without treating it as an internal tag', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
)
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '12',
page: 2,
limit: 50,
})
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
})
})
+3
ファイルの表示
@@ -128,6 +128,7 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
const prefetchPostChanges: Prefetcher = async (qc, url) => {
const id = url.searchParams.get ('id')
const tag = url.searchParams.get ('tag')
const externalTag = url.searchParams.get ('external_tag')
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
@@ -141,9 +142,11 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
await qc.prefetchQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }) })
}
+6 -1
ファイルの表示
@@ -11,7 +11,11 @@ export const postsKeys = {
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const,
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
changes: (p: { post?: string
tag?: string
externalTag?: string
page: number
limit: number }) =>
['posts', 'changes', p] as const }
export const gekanatorKeys = {
@@ -26,6 +30,7 @@ export const tagsKeys = {
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
externalShow: (id: string) => ['tags', 'nico', id] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const,
+12
ファイルの表示
@@ -53,6 +53,18 @@ export const fetchTag = async (id: string): Promise<Tag | null> => {
}
export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
try
{
return await apiGet (`/tags/nico/${ id }`)
}
catch
{
return null
}
}
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
try
{
+63
ファイルの表示
@@ -0,0 +1,63 @@
import { screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const postsApi = vi.hoisted (() => ({
fetchPostChanges: vi.fn (),
updatePost: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTag: vi.fn (),
fetchExternalTag: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/tags', () => tagsApi)
describe ('PostHistoryPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
postsApi.fetchPostChanges.mockResolvedValue ({
versions: [],
count: 0,
})
})
it ('shows the external tag name when filtering by external_tag', async () => {
const external = buildTag ({
id: 7,
name: 'nico:external_history',
category: 'nico',
})
tagsApi.fetchExternalTag.mockResolvedValue (external)
renderWithProviders (
<PostHistoryPage/>,
{ route: '/posts/changes?external_tag=7' },
)
await waitFor (() => {
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '7',
page: 1,
limit: 20,
})
})
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7')
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
expect (
await screen.findByRole ('heading', {
level: 1,
name: '耕作履歴(nico:external_history)',
}),
).toBeInTheDocument ()
})
})
+20 -3
ファイルの表示
@@ -16,7 +16,7 @@ import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { fetchExternalTag, fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
@@ -51,6 +51,7 @@ const PostHistoryPage: FC = () => {
const query = new URLSearchParams (location.search)
const id = query.get ('id')
const tagId = query.get ('tag')
const externalTagId = query.get ('external_tag')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
@@ -63,15 +64,27 @@ const PostHistoryPage: FC = () => {
queryKey: tagsKeys.show (tagQueryId),
queryFn: () => fetchTag (tagQueryId) })
const externalTagQueryId = externalTagId ?? ''
const { data: externalTag } = useQuery ({
enabled: Boolean (externalTagId),
queryKey: tagsKeys.externalShow (externalTagQueryId),
queryFn: () => fetchExternalTag (externalTagQueryId) })
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
...(externalTagId && { externalTag: externalTagId }),
page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
...(externalTagId && {
externalTag: externalTagId }),
page, limit }) })
const changes = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
const displayedTag = externalTag ?? tag
const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${
displayedTag ? `(${ displayedTag.name })` : '' }`
const qc = useQueryClient ()
@@ -134,10 +147,14 @@ const PostHistoryPage: FC = () => {
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>
<PageTitle aria-label={pageTitleLabel}>
耕作履歴
{id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>}
{tag && <>(<TagLink tag={tag} withWiki={false} withCount={false}/>)</>}
{displayedTag && (
<>(<TagLink
tag={displayedTag}
withWiki={false}
withCount={false}/>)</>)}
</PageTitle>
{loading ? 'Loading...' : (
+46 -1
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage'
@@ -127,4 +127,49 @@ describe ('TagListPage', () => {
)
})
})
it ('keeps colliding internal and external tags on their own routes', async () => {
const internal = buildTag ({
id: 7,
name: 'internal_collision',
category: 'general',
})
const external = buildTag ({
id: 7,
name: 'nico:external_collision',
category: 'nico',
})
tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [internal, external],
count: 2,
})
renderWithProviders (<TagListPage/>, { route: '/tags' })
const internalLink =
await screen.findByRole ('link', { name: 'internal_collision' })
const externalLink =
screen.getByRole ('link', { name: 'nico:external_collision' })
expect (internalLink).toHaveAttribute ('href', '/tags/7')
expect (externalLink).toHaveAttribute (
'href',
'/tags/nico?name=nico%3Aexternal_collision',
)
const internalRow = internalLink.closest ('tr')
const externalRow = externalLink.closest ('tr')
expect (internalRow).not.toBeNull ()
expect (externalRow).not.toBeNull ()
expect (
within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?tag=7')
expect (
within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?external_tag=7')
})
})
+10 -3
ファイルの表示
@@ -406,11 +406,15 @@ const TagListPage: FC = () => {
<tbody>
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<tr
key={`${ row.category }:${ row.id }`}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink
tag={row}
to={`/tags/${ encodeURIComponent (row.id) }`}
to={row.category === 'nico'
? `/tags/nico?name=${ encodeURIComponent (row.name) }`
: `/tags/${ encodeURIComponent (row.id) }`}
withCount={false}/>
</td>
<td className="p-2 text-right">{row.postCount}</td>
@@ -429,7 +433,10 @@ const TagListPage: FC = () => {
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
<td className="p-2">
<PrefetchLink to={`/posts/changes?tag=${ row.id }`}>
<PrefetchLink
to={row.category === 'nico'
? `/posts/changes?external_tag=${ row.id }`
: `/posts/changes?tag=${ row.id }`}>
耕作履歴
</PrefetchLink>
</td>