このコミットが含まれているのは:
2026-11-01 00:20:32 +09:00
コミット 74c5242828
22個のファイルの変更、202行の追加、232行の削除
+4 -2
ファイルの表示
@@ -200,9 +200,11 @@ export const pageRoutes = (
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/nico-tags', element: <CountsResourcePage resource="nico_tags"/>,
+7 -1
ファイルの表示
@@ -43,6 +43,12 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
const countsQuery = new URLSearchParams ({ tags: tagsQuery })
for (const [key, value] of query)
{
if (key === 'match')
countsQuery.append (key, value)
}
useEffect (() => {
const tagsTmp: TagByCategory = { }
@@ -120,7 +126,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</a>
</li>
<li>
<PrefetchLink to={`/counts/posts?tags=${ encodeURIComponent (tagsQuery) }`}>
<PrefetchLink to={`/counts/posts?${ countsQuery }`}>
{label ('actions.count')}
</PrefetchLink>
</li>
+4 -9
ファイルの表示
@@ -68,22 +68,17 @@ describe ('Counts filter draft', () => {
expect (screen.queryByText ('pattern.input')).not.toBeInTheDocument ()
})
it ('retains stable references only for surviving search tokens', () => {
const params = appliedParams ({ tags: 'alpha beta' })
setCountState (params, 'tag_refs', encodeCountState ('tag_refs', [
{ name: 'alpha', tag_id: 7 }, { name: 'beta', external_tag_id: 8 }]))
const { execute } = renderControls (params)
it ('submits only the edited tag names', () => {
const { execute } = renderControls (appliedParams ({ tags: 'alpha beta' }))
const tags = screen.getByDisplayValue ('alpha beta')
fireEvent.change (tags, { target: { value: 'not:beta gamma alpha' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([
{ name: 'alpha', tag_id: 7 }, { name: 'not:beta', external_tag_id: 8, negative: true }])
expect (execute.mock.lastCall![0].get ('tags')).toBe ('not:beta gamma alpha')
fireEvent.change (tags, { target: { value: 'gamma' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([])
expect (execute.mock.lastCall![0].get ('tags')).toBe ('gamma')
expect (screen.queryByText (/internal:7|external:8/)).not.toBeInTheDocument ()
})
})
+2 -24
ファイルの表示
@@ -17,7 +17,7 @@ import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState'
import { readCountState, setCountState } from '@/lib/queryState'
import type { ReactNode } from 'react'
@@ -97,27 +97,6 @@ const CountsControls = (
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
setCountState (next, key, value)
if (key === 'tags')
{
const tokens = value.split (/\s+/).filter (token => token
&& token.toLowerCase () !== 'type:or')
const remaining = [...tokens]
const references = readCountState<Record<string, string | number | boolean>[]> (
previous, 'tag_refs', []).flatMap (reference => {
const name = String (reference.name)
const exact = remaining.indexOf (name)
const index = exact >= 0 ? exact : remaining.findIndex (token =>
token.replace (/^not:/i, '') === name.replace (/^not:/i, ''))
if (index < 0) return []
const [token] = remaining.splice (index, 1)
return [token === name ? reference : { ...reference, name: token,
negative: token.toLowerCase ().startsWith ('not:') }]
})
for (const name of [...next.keys ()])
if (name === 'tag_refs' || name.startsWith ('tag_refs.')) next.delete (name)
if (references.length > 0)
setCountState (next, 'tag_refs', encodeCountState ('tag_refs', references))
}
if (key === 'limit') next.delete ('page')
if (next.toString () === previous.toString ()) return previous
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
@@ -278,8 +257,7 @@ const CountsControls = (
aria-label={message ('remove').text} onClick={() => setDraft (previous => {
const next = new URLSearchParams (previous)
for (const key of [...next.keys ()])
if (['tags', 'tag', 'kind', 'match', 'tag_refs', 'page'].includes (key)
|| key.startsWith ('tag_refs.')) next.delete (key)
if (['tags', 'tag', 'kind', 'match', 'page'].includes (key)) next.delete (key)
return next
})}>×</Button>
</>}
+17 -1
ファイルの表示
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchPostChanges, fetchPosts, toggleViewedFlg, updatePost } from '@/lib/posts'
import { fetchPostChanges, fetchPosts, switchPostFilterLocale, toggleViewedFlg, updatePost }
from '@/lib/posts'
import type { FetchPostsParams } from '@/types'
@@ -34,6 +35,21 @@ describe ('posts API functions', () => {
vi.clearAllMocks ()
})
it ('switches current tag names without adding identity state to the URL', async () => {
api.apiGet.mockResolvedValueOnce ({ tags: 'シロシ not:nico:shared' })
const path = await switchPostFilterLocale ({
location: { pathname: '/ja/counts/posts',
search: '?tags=source+not%3Anico%3Ashared&match=any', hash: '#table' },
params: {}, locale: 'ja', targetLocale: 'ain-Kana' })
expect (api.apiGet).toHaveBeenCalledWith ('/posts/tag_filters', {
locale: 'ja', params: { tags: 'source not:nico:shared', target_locale: 'ain-Kana' } })
const url = new URL (path, 'https://example.test')
expect (url.pathname).toBe ('/ain-Kana/counts/posts')
expect (Object.fromEntries (url.searchParams)).toEqual ({
tags: 'シロシ not:nico:shared', match: 'any' })
expect (url.hash).toBe ('#table')
})
it ('maps post search parameters to backend snake_case names', async () => {
api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 })
+4 -18
ファイルの表示
@@ -1,5 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
@@ -8,7 +7,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo,
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja',
): Promise<{
@@ -18,7 +17,6 @@ export const fetchPosts = async (
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -39,21 +37,9 @@ export const switchPostFilterLocale = async (
if (tags == null || tags.trim () === '')
return switchLocalePath (location, targetLocale)
const { references } = await apiGet<{ references: {
name: string
tagId?: number
externalTagId?: number
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
...queryRecordParams (query, 'tag_refs') } })
query.set ('tags', references.map (reference => reference.name).join (' '))
setQueryRecords (query, 'tag_refs', references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) })))
const result = await apiGet<{ tags: string }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale } })
query.set ('tags', result.tags)
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
-3
ファイルの表示
@@ -9,7 +9,6 @@ import { getHelpArticle,
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { queryRecordParams } from '@/lib/queryState'
import { isCountsPath } from '@/lib/routeLocales'
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates,
@@ -178,8 +177,6 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.has ('tag_refs') && {
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
+3 -4
ファイルの表示
@@ -4,7 +4,6 @@ const schemas: Record<string, string[]> = {
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
condition_values: ['condition', 'value', 'value_type'],
drill: ['field', 'grain', 'value', 'value_type'],
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
column_order: ['value', 'value_type'],
measures: ['key', 'axis', 'value', 'value_type'] }
@@ -24,8 +23,8 @@ export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[
const index = Number (parts[0])
const record = indexed.get (index) ?? { }
const field = parts[1]
record[field] = ['blank', 'negative', 'missing', 'values'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis', 'condition'].includes (field)
record[field] = ['blank', 'values'].includes (field)
? value === '1' : (['axis', 'condition'].includes (field)
? Number (value) : value)
indexed.set (index, record)
}
@@ -59,7 +58,7 @@ export const setQueryRecords = (
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
if (value == null) continue
params.set (name, typeof value === 'boolean'
? (['blank', 'negative', 'missing', 'values'].includes (field)
? (['blank', 'values'].includes (field)
? (value ? '1' : '0') : String (value))
: String (value))
}
+8 -5
ファイルの表示
@@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
@@ -36,6 +36,9 @@ const execute = () => fireEvent.submit (
screen.getByText ('集計実行').closest ('form')!)
const openConditions = () => fireEvent.click (
screen.getByText ('集計条件').closest ('button')!)
const resourceInput = () => within (
screen.getByText ('抽出条件', { selector: 'legend' }).closest ('fieldset')!,
).getByRole ('textbox', { name: 'Id.' })
const renderPage = (extra = '') => renderWithProviders (<>
<CountsResourcePage resource="posts"/><Location/>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
@@ -149,7 +152,7 @@ describe ('Counts execution and URL state', () => {
await waitForResult (queryClient)
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions ()
const input = screen.getByRole ('textbox', { name: 'Id.' })
const input = resourceInput ()
fireEvent.change (input, { target: { value: '7' } })
fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), {
target: { value: '20' } })
@@ -167,7 +170,7 @@ describe ('Counts execution and URL state', () => {
await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2))
expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7')
expect (currentParams ().toString ()).toBe (oldURL)
expect (screen.getByRole ('textbox', { name: 'Id.' })).toBe (input)
expect (resourceInput ()).toBe (input)
await act (async () => complete ({ ...detailResult,
executionAt: '2024-03-02T00:00:00.000000Z', limit: 20 }))
await waitFor (() => expect (currentParams ().get ('resource_id')).toBe ('7'))
@@ -182,14 +185,14 @@ describe ('Counts execution and URL state', () => {
await waitForResult (queryClient)
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions ()
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), {
fireEvent.change (resourceInput (), {
target: { value: '99' } })
fetchCounts.mockRejectedValueOnce (new Error ('offline'))
execute ()
await screen.findByRole ('alert')
expect (fetchCounts).toHaveBeenCalledTimes (2)
expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('99')
expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99')
expect (resourceInput ()).toHaveValue ('99')
expect (currentParams ().has ('resource_id')).toBe (false)
})
-2
ファイルの表示
@@ -1,4 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
@@ -62,7 +61,6 @@ const PostListPage: FC = () => {
const keys = {
tags: tagsKey, match, page, limit,
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const
-2
ファイルの表示
@@ -1,4 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
@@ -127,7 +126,6 @@ const PostSearchPage: FC = () => {
const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit,
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: qURL,
title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom,
-1
ファイルの表示
@@ -20,7 +20,6 @@ export type FetchPostsParams = {
url: string
title: string
tags: string
tagReferences?: string
match: 'all' | 'any'
originalCreatedFrom: string
originalCreatedTo: string