コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 1b22ed43bd AI: address review on PR #470 2026-11-13 12:48:30 +09:00
Codex Night Worker b46d53e347 AI: address issue #273 2026-11-13 12:36:02 +09:00
20個のファイルの変更、351行の追加、401行の削除
-7
ファイルの表示
@@ -6,11 +6,6 @@ class WikiPagesController < ApplicationController
def index
locale = resolve_locale!
resource_type = params[:resource_type]
unless resource_type.nil? || ['tag_description', 'tag_group'].include?(resource_type)
return head :bad_request
end
title = params[:title].to_s.strip
descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
@@ -19,8 +14,6 @@ class WikiPagesController < ApplicationController
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
descriptions = descriptions.none if resource_type == 'tag_group'
tag_groups = tag_groups.none if resource_type == 'tag_description'
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
+1 -7
ファイルの表示
@@ -166,13 +166,7 @@ export const pageRoutes = (
robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <LocaleRedirect to="/tag-descriptions"/>,
handle: { redirectTo: '/tag-descriptions' } },
{ path: 'tag-descriptions',
element: <WikiSearchPage key="tag_description" resourceType="tag_description"/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'tag-groups',
element: <WikiSearchPage key="tag_group" resourceType="tag_group"/>,
{ path: 'wiki', element: <WikiSearchPage/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
+22 -10
ファイルの表示
@@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => {
it ('updates text and fetches autocomplete for the selected token', async () => {
it ('replaces the selected token and restores the caret without losing other tags', async () => {
const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
const { rerender } = renderWithProviders (
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
const textarea = screen.getByRole ('textbox')
const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox')
fireEvent.focus (textarea)
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
fireEvent.change (textarea, { target: { value: '虹夏' } })
fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } })
rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>)
fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) },
)
'/tags/autocomplete', {
locale: 'ja', params: { q: '虹な', nico: '0' },
signal: expect.any (AbortSignal) })
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続')
fireEvent.mouseDown (await screen.findByText ('虹夏'))
expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続')
rerender (<PostFormTagsArea tags="ぼっち 虹夏 後続" setTags={setTags}/>)
await waitFor (() => {
expect (textarea).toHaveFocus ()
expect (textarea.selectionStart).toBe (7)
expect (textarea.selectionEnd).toBe (7)
})
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+9 -62
ファイルの表示
@@ -1,13 +1,10 @@
// TODO: TagSearch と共通化する.
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useUITranslation } from '@/lib/useUITranslation'
import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
@@ -41,24 +38,13 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | '
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false)
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
const suggestions = (result.locale === locale && result.request === request
&& request.query === currentToken) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue)
@@ -70,52 +56,18 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
})
}
const { inputProps, suggestionProps, requestSuggestions } =
useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect })
const recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
requestSuggestions (token)
if (!(token.trim ()))
{
setSuggestionsVsbl (false)
return
}
return
setBounds ({ start, end })
}
useEffect (() => {
if (request.query.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale])
return (
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
{({ describedBy, invalid }) => (
@@ -134,15 +86,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
inputProps.onBlur ()
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
<TagSearchBox {...suggestionProps}/>)}
</>)}
</FormField>)
}
+38 -1
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,6 +8,8 @@ import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { Tag } from '@/types'
const LocationOutput = () => {
const location = useLocation ()
@@ -117,4 +119,39 @@ describe ('TagSearch', () => {
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
it ('aborts obsolete requests and ignores their late suggestions', async () => {
const responses: ((tags: Tag[]) => void)[] = []
vi.mocked (api.apiGet).mockImplementation (() => new Promise<Tag[]> (resolve => {
responses.push (resolve)
}))
const { unmount } = renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹' } })
const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
expect (previousSignal).toBeInstanceOf (AbortSignal)
expect (previousSignal?.aborted).toBe (false)
fireEvent.change (input, { target: { value: 'ぼ' } })
expect (previousSignal?.aborted).toBe (true)
expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', {
locale: 'ja', params: { q: 'ぼ', nico: '1' },
signal: expect.any (AbortSignal) })
const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
await act (async () => {
responses[1] ([buildTag ({ name: 'ぼっち' })])
})
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
await act (async () => {
responses[0] ([buildTag ({ name: '虹夏' })])
})
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
unmount ()
expect (currentSignal?.aborted).toBe (true)
})
})
+29 -131
ファイルの表示
@@ -1,82 +1,46 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox'
import { useLocaleNavigate } from '@/lib/useLocale'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
import type { ChangeEvent, FC, FormEvent } from 'react'
import type { Tag } from '@/types'
const TagSearch: FC = () => {
const { message } = useUITranslation ('shared')
const locale = useLocale ()
const location = useLocation ()
const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const suggestions = result.locale === locale && result.request === request
? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
const value = ev.target.value
const query = value.trim ().split (' ').at (-1) ?? ''
setSearch (value)
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
const handleTagSelect = (tag: Tag) => {
const parts = search.split (' ')
parts[parts.length - 1] = (
(parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:')
? ('not:' + tag.name)
: tag.name)
setSearch (parts.join (' ') + ' ')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
const { inputProps, suggestionProps, requestSuggestions,
clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
token: search.trim ().split (' ').at (-1) ?? '',
nicoFlg: true,
onSelect: handleTagSelect })
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [locale])
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearch (value)
requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
}
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault ()
if (compositionFlgRef.current)
return
@@ -89,61 +53,7 @@ const TagSearch: FC = () => {
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
{
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break
ev.preventDefault ()
handleTagSelect (selected)
}
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
}
const handleTagSelect = (tag: Tag) => {
const parts = search.split (' ')
parts[parts.length - 1] = (
(parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:')
? ('not:' + tag.name)
: tag.name)
setSearch (parts.join (' ') + ' ')
setRequest ({ query: '' })
setSuggestionsVsbl (false)
setActiveIndex (-1)
clearSuggestions ()
}
useEffect (() => {
@@ -151,33 +61,21 @@ const TagSearch: FC = () => {
const anyFlg = query.get ('match') === 'any'
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
setSearch (tagsQuery)
setRequest ({ query: '' })
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [location.search])
clearSuggestions ()
}, [location.search, clearSuggestions])
return (
<form onSubmit={handleSubmit} className="relative w-full">
<input type="text"
{...inputProps}
enterKeyHint="search"
data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text}
value={search}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
<TagSearchBox {...suggestionProps}/>
</form>)
}
+14 -29
ファイルの表示
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = {
user: User | null
@@ -39,11 +39,10 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: {
{ tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
wikiResourceType?: WikiPage['resourceType']
user: User | null
pathName: string },
): Menu => {
@@ -100,30 +99,17 @@ export const menuOutline = (
visible: materialFlg && Boolean (material?.tag) },
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
...([
{ key: 'tagDescriptions', path: '/tag-descriptions', type: 'tag_description' },
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const)
.map (({ key, path, type }): MenuVisibleItem => {
const current = (wikiResourceType ?? 'tag_description') === type
const detailFlg = wikiPageFlg && current
const creationPath =
type === 'tag_group'
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new'
return { ...message (key), to: path,
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path,
subMenu: [
{ ...message ('search'), to: path },
{ ...message ('create'), to: creationPath, visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: detailFlg },
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
visible: detailFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: detailFlg && editable }] }
}),
{ ...message ('wiki'), to: '/wiki', subMenu: [
{ ...message ('search'), to: '/wiki' },
{ ...message ('create'), to: '/wiki/new', visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' },
@@ -243,8 +229,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId,
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item =>
+53 -10
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
@@ -20,18 +20,20 @@ describe ('TagInput', () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
const { rerender } = render (
<TagInput value="ぼっち 虹" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
rerender (<TagInput value="ぼっち 虹夏" setValue={setValue}/>)
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) },
)
'/tags/autocomplete', {
locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) })
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
expect (await screen.findByText ('虹夏')).toBeInTheDocument ()
})
it ('does not fetch when the last token is blank', () => {
@@ -54,10 +56,51 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) },
)
'/tags/autocomplete', {
locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) })
})
})
it ('preserves negation and blocks suggestion selection during composition', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })])
const { rerender } = render (<TagInput value="" setValue={setValue}/>)
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } })
rerender (<TagInput value="ぼっち NOT:虹" setValue={setValue}/>)
await screen.findByText ('虹夏')
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/autocomplete', {
locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) })
fireEvent.keyDown (input, { key: 'ArrowDown' })
fireEvent.compositionStart (input)
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false)
expect (setValue).toHaveBeenCalledTimes (1)
fireEvent.compositionEnd (input)
fireEvent.keyDown (input, { key: 'Enter' })
expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => {
const tag = buildTag ({ name: '虹夏', postCount: 0 })
const response = Promise.resolve ([tag])
api.apiGet.mockReturnValueOnce (response)
const props = { setValue: vi.fn (), includeUnused: unusedFlg }
const { rerender } = render (<TagInput {...props} value=""/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } })
rerender (<TagInput {...props} value="虹"/>)
await act (async () => {
await response
})
expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg)
})
})
+16 -116
ファイルの表示
@@ -1,11 +1,8 @@
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
import type { FC, ChangeEvent } from 'react'
import type { Tag } from '@/types'
@@ -19,136 +16,39 @@ type Props = {
setValue: (value: string) => void }
const TagInput: FC<Props> = ({
includeNico = false,
includeUnused = false,
describedBy,
invalid,
value,
setValue,
}) => {
const locale = useLocale ()
const [activeIndex, setActiveIndex] = useState (-1)
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
includeNico: boolean
request: { query: string }
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
const suggestions = (result.locale === locale && result.includeNico === includeNico
&& result.request === request
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal,
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
if (active)
{
setResult ({ locale, includeNico, request,
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, includeNico, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, includeNico, includeUnused])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale, includeNico])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const TagInput: FC<Props> = (
{ includeNico: nicoFlg = false,
includeUnused: unusedFlg = false,
describedBy, invalid, value, setValue },
) => {
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
const negative = /^not:/i.test (parts[parts.length - 1])
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
setValue (parts.join (' ') + ' ')
setRequest ({ query: '' })
setActiveIndex (-1)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
const { inputProps, suggestionProps, requestSuggestions } =
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
nicoFlg, unusedFlg, onSelect: handleTagSelect })
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
{
ev.preventDefault ()
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected)
}
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
setValue (event.target.value)
const token = event.target.value.trim ().split (' ').at (-1) ?? ''
requestSuggestions (token, token.replace (/^not:/i, ''))
}
return (
<div className="relative">
<input
{...inputProps}
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className={inputClass (invalid)}/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
<TagSearchBox {...suggestionProps}/>
</div>)
}
+3 -5
ファイルの表示
@@ -100,11 +100,10 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? ''
const resourceType = url.pathname === '/tag-groups' ? 'tag_group' : 'tag_description'
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title, resourceType }, locale),
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) })
queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title }, locale) })
}
@@ -311,8 +310,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mPost (u.pathname))),
run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
{ test: u => ['/wiki', '/tag-descriptions', '/tag-groups'].includes (u.pathname),
run: prefetchWikiPagesIndex },
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow },
+1 -2
ファイルの表示
@@ -5,7 +5,6 @@ import type {
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
WikiPage,
WikiTagReference } from '@/types'
export const localesKeys = {
@@ -131,7 +130,7 @@ export const wikiKeys = {
autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const,
index: (
p: { title?: string, resourceType?: WikiPage['resourceType'] },
p: { title?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const,
show: (
+11
ファイルの表示
@@ -11,6 +11,17 @@ import type { Deerjikist,
TagVersion } from '@/types'
export const fetchTagAutocomplete = async (
query: string,
nicoFlg: boolean,
locale: LocaleCode,
signal: AbortSignal,
): Promise<Tag[]> =>
await apiGet ('/tags/autocomplete', {
locale, signal,
params: { q: query, nico: nicoFlg ? '1' : '0' } })
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated,
+142
ファイルの表示
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchTagAutocomplete } from '@/lib/tags'
import { useLocale } from '@/lib/useLocale'
import type { KeyboardEvent } from 'react'
import type { Tag } from '@/types'
export const useTagAutocomplete = (
{ token, onSelect, nicoFlg = false, unusedFlg = false }: {
token: string
onSelect: (tag: Tag) => void
nicoFlg?: boolean
unusedFlg?: boolean },
) => {
const locale = useLocale ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [visible, setVisible] = useState (false)
const [request, setRequest] = useState ({ token: '', query: '' })
const [result, setResult] = useState<{
locale: string
nicoFlg: boolean
unusedFlg: boolean
request: { token: string; query: string }
tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] })
const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg
&& result.unusedFlg === unusedFlg && result.request === request
&& request.token === token) ? result.tags : []
const requestSuggestions = (token: string, query: string = token) => {
setRequest ({ token, query })
setActiveIndex (-1)
setVisible (token.trim () !== '')
}
const clearSuggestions = useCallback (() => {
setRequest ({ token: '', query: '' })
setActiveIndex (-1)
setVisible (false)
}, [])
useEffect (() => {
if (request.token.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await fetchTagAutocomplete (
request.query, nicoFlg, locale, controller.signal)
if (active)
{
setResult ({ locale, nicoFlg, unusedFlg, request,
tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, nicoFlg, unusedFlg])
useEffect (() => {
clearSuggestions ()
}, [locale, nicoFlg, clearSuggestions])
const handleTagSelect = (tag: Tag) => {
clearSuggestions ()
onSelect (tag)
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| event.nativeEvent.isComposing
|| event.nativeEvent.keyCode === 229)
{
if (event.key === 'Enter')
event.preventDefault ()
return
}
switch (event.key)
{
case 'ArrowDown':
event.preventDefault ()
setActiveIndex (index => Math.min (index + 1, suggestions.length - 1))
setVisible (true)
break
case 'ArrowUp':
event.preventDefault ()
setActiveIndex (index => Math.max (index - 1, -1))
setVisible (true)
break
case 'Enter':
{
const selected = visible ? suggestions[activeIndex] : null
if (selected == null)
{
setVisible (false)
break
}
event.preventDefault ()
handleTagSelect (selected)
}
break
case 'Escape':
event.preventDefault ()
setVisible (false)
break
}
}
return {
requestSuggestions, clearSuggestions, compositionFlgRef,
inputProps: {
onFocus: () => setVisible (true),
onBlur: () => setVisible (false),
onKeyDown: handleKeyDown,
onCompositionStart: () => {
compositionFlgRef.current = true
},
onCompositionEnd: () => {
compositionFlgRef.current = false
} },
suggestionProps: {
suggestions: visible ? suggestions : [],
activeIndex,
onSelect: handleTagSelect } }
}
+2 -2
ファイルの表示
@@ -24,10 +24,10 @@ export const fetchWikiChanges = (
export const fetchWikiPages = async (
{ title, resourceType }: { title?: string, resourceType?: WikiPage['resourceType'] },
{ title }: { title?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { locale, params: { title, resource_type: resourceType } })
await apiGet ('/wiki', { locale, params: { title } })
export const fetchWikiPage = async (
-2
ファイルの表示
@@ -14,8 +14,6 @@
"materials": "チカㇻペ",
"suppressions": "アエアイカㇷ゚テㇷ゚",
"wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "アシㇼ",
"edit": "イヌイェㇷ゚",
"entertainment": "シノッ",
-2
ファイルの表示
@@ -13,8 +13,6 @@
"materials": "素材",
"suppressions": "抑止",
"wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "新規",
"edit": "編輯",
"entertainment": "おたのしみ",
+1 -1
ファイルの表示
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
<TabGroup>
{material.tag && (
<Tab name={label ('navigation:tagDescriptions')}>
<Tab name={label ('navigation:wiki')}>
<WikiBody
title={material.tag.name}
language={localeLanguage (locale)}
+1 -1
ファイルの表示
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
{loading && label ('common:loading')}
</Tab>
{tags.length === 1 && (
<Tab name={label ('navigation:tagDescriptions')}>
<Tab name={label ('navigation:wiki')}>
<WikiBody title={tags[0]} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>
+1 -2
ファイルの表示
@@ -51,8 +51,7 @@ const WikiDetailPage: FC = () => {
queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity })
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ()
.replace (/^(?:ヘルプ|手引):/, '')
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
+7 -11
ファイルの表示
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { fetchWikiPages } from '@/lib/wiki'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
@@ -17,20 +17,16 @@ import type { FormEvent , FC } from 'react'
import type { WikiPage } from '@/types'
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
{ resourceType = 'tag_description' },
) => {
const WikiSearchPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const heading =
resourceType === 'tag_group'
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale),
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) })
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => {
ev.preventDefault ()
@@ -46,10 +42,10 @@ const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
return (
<MainArea>
<PageHead title={message (heading)}/>
<PageHead title={message ('navigation:wiki')}/>
<div className="max-w-xl">
<PageTitle>{label (heading)}</PageTitle>
<PageTitle>{label ('navigation:wiki')}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label={label ('labels.title')}>