コミットを比較
2
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
1b22ed43bd | ||
|
|
b46d53e347 |
@@ -314,9 +314,7 @@ class PostsController < ApplicationController
|
|||||||
def viewed
|
def viewed
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
UserPostView.create_or_find_by!(
|
current_user.viewed_posts << Post.find(params[:id])
|
||||||
user: current_user,
|
|
||||||
post: Post.find(params[:id]))
|
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2559,21 +2559,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
expect(user.reload.viewed?(post_record)).to be(true)
|
expect(user.reload.viewed?(post_record)).to be(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '204 and keeps one view on repeated requests' do
|
|
||||||
sign_in_as(user)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post "/posts/#{ post_record.id }/viewed"
|
|
||||||
}.to change(UserPostView, :count).by(1)
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post "/posts/#{ post_record.id }/viewed"
|
|
||||||
}.not_to change(UserPostView, :count)
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
expect(user.reload.viewed?(post_record)).to be(true)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE /posts/:id/viewed' do
|
describe 'DELETE /posts/:id/viewed' do
|
||||||
|
|||||||
@@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
|
||||||
describe ('PostFormTagsArea', () => {
|
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 ()
|
const setTags = vi.fn ()
|
||||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
|
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.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 (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹', nico: '0' },
|
locale: 'ja', params: { q: '虹な', nico: '0' },
|
||||||
signal: expect.any (AbortSignal) },
|
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 ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
// TODO: TagSearch と共通化する.
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import TextArea from '@/components/common/TextArea'
|
import TextArea from '@/components/common/TextArea'
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
|
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 PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||||
const { label } = useUITranslation ('navigation')
|
const { label } = useUITranslation ('navigation')
|
||||||
const locale = useLocale ()
|
|
||||||
const ref = useRef<HTMLTextAreaElement> (null)
|
const ref = useRef<HTMLTextAreaElement> (null)
|
||||||
|
|
||||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||||
const [focused, setFocused] = useState (false)
|
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 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) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
const textarea = ref.current!
|
const textarea = ref.current!
|
||||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
||||||
setTags (newValue)
|
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 recompute = (pos: number, v: string = tags) => {
|
||||||
const { start, end, token } = getTokenAt (v, pos)
|
const { start, end, token } = getTokenAt (v, pos)
|
||||||
setRequest ({ query: token })
|
requestSuggestions (token)
|
||||||
setSuggestionsVsbl (token.trim () !== '')
|
|
||||||
if (!(token.trim ()))
|
if (!(token.trim ()))
|
||||||
{
|
return
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setBounds ({ start, end })
|
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 (
|
return (
|
||||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
@@ -134,15 +86,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
onFocus={() => setFocused (true)}
|
onFocus={() => setFocused (true)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused (false)
|
setFocused (false)
|
||||||
setSuggestionsVsbl (false)
|
inputProps.onBlur ()
|
||||||
}}/>
|
}}/>
|
||||||
{focused && (
|
{focused && (
|
||||||
<TagSearchBox
|
<TagSearchBox {...suggestionProps}/>)}
|
||||||
suggestions={suggestionsVsbl && suggestions.length > 0
|
|
||||||
? suggestions
|
|
||||||
: [] as Tag[]}
|
|
||||||
activeIndex={-1}
|
|
||||||
onSelect={handleTagSelect}/>)}
|
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 userEvent from '@testing-library/user-event'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -8,6 +8,8 @@ import * as api from '@/lib/api'
|
|||||||
import { buildTag } from '@/test/factories'
|
import { buildTag } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const LocationOutput = () => {
|
const LocationOutput = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -117,4 +119,39 @@ describe ('TagSearch', () => {
|
|||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,82 +1,46 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
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 { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import TagSearchBox from './TagSearchBox'
|
import type { ChangeEvent, FC, FormEvent } from 'react'
|
||||||
|
|
||||||
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
|
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const TagSearch: FC = () => {
|
const TagSearch: FC = () => {
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const compositionFlgRef = useRef (false)
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [search, setSearch] = useState ('')
|
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 handleTagSelect = (tag: Tag) => {
|
||||||
const value = ev.target.value
|
const parts = search.split (' ')
|
||||||
const query = value.trim ().split (' ').at (-1) ?? ''
|
parts[parts.length - 1] = (
|
||||||
setSearch (value)
|
(parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:')
|
||||||
setRequest ({ query })
|
? ('not:' + tag.name)
|
||||||
setActiveIndex (-1)
|
: tag.name)
|
||||||
setSuggestionsVsbl (query !== '')
|
setSearch (parts.join (' ') + ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
const { inputProps, suggestionProps, requestSuggestions,
|
||||||
if (request.query === '')
|
clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
|
||||||
return
|
token: search.trim ().split (' ').at (-1) ?? '',
|
||||||
const controller = new AbortController ()
|
nicoFlg: true,
|
||||||
let active = true
|
onSelect: handleTagSelect })
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect (() => {
|
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
setActiveIndex (-1)
|
const value = event.target.value
|
||||||
setSuggestionsVsbl (false)
|
setSearch (value)
|
||||||
}, [locale])
|
requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
ev.preventDefault ()
|
event.preventDefault ()
|
||||||
if (compositionFlgRef.current)
|
if (compositionFlgRef.current)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -89,61 +53,7 @@ const TagSearch: FC = () => {
|
|||||||
.filter (t => t !== 'type:or')
|
.filter (t => t !== 'type:or')
|
||||||
.join (' '),
|
.join (' '),
|
||||||
match })).toString () }`)
|
match })).toString () }`)
|
||||||
setSuggestionsVsbl (false)
|
clearSuggestions ()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -151,33 +61,21 @@ const TagSearch: FC = () => {
|
|||||||
const anyFlg = query.get ('match') === 'any'
|
const anyFlg = query.get ('match') === 'any'
|
||||||
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
||||||
setSearch (tagsQuery)
|
setSearch (tagsQuery)
|
||||||
setRequest ({ query: '' })
|
clearSuggestions ()
|
||||||
setActiveIndex (-1)
|
}, [location.search, clearSuggestions])
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative w-full">
|
<form onSubmit={handleSubmit} className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
{...inputProps}
|
||||||
enterKeyHint="search"
|
enterKeyHint="search"
|
||||||
data-shortcut-focus-search="true"
|
data-shortcut-focus-search="true"
|
||||||
placeholder={message ('labels.tagSearch').text}
|
placeholder={message ('labels.tagSearch').text}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onCompositionStart={() => {
|
|
||||||
compositionFlgRef.current = true
|
|
||||||
}}
|
|
||||||
onCompositionEnd={() => {
|
|
||||||
compositionFlgRef.current = false
|
|
||||||
}}
|
|
||||||
className={inputClass (false,
|
className={inputClass (false,
|
||||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
<TagSearchBox {...suggestionProps}/>
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
</form>)
|
</form>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
@@ -20,18 +20,20 @@ describe ('TagInput', () => {
|
|||||||
const setValue = vi.fn ()
|
const setValue = vi.fn ()
|
||||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
|
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: 'ぼっち 虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
|
||||||
|
rerender (<TagInput value="ぼっち 虹夏" setValue={setValue}/>)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
|
locale: 'ja', params: { q: '虹夏', nico: '0' },
|
||||||
signal: expect.any (AbortSignal) },
|
signal: expect.any (AbortSignal) })
|
||||||
)
|
|
||||||
})
|
})
|
||||||
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
||||||
|
expect (await screen.findByText ('虹夏')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('does not fetch when the last token is blank', () => {
|
it ('does not fetch when the last token is blank', () => {
|
||||||
@@ -54,10 +56,51 @@ describe ('TagInput', () => {
|
|||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
|
locale: 'ja', params: { q: '虹夏', nico: '1' },
|
||||||
signal: expect.any (AbortSignal) },
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import { apiGet } from '@/lib/api'
|
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
|
import type { FC, ChangeEvent } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
@@ -19,136 +16,39 @@ type Props = {
|
|||||||
setValue: (value: string) => void }
|
setValue: (value: string) => void }
|
||||||
|
|
||||||
|
|
||||||
const TagInput: FC<Props> = ({
|
const TagInput: FC<Props> = (
|
||||||
includeNico = false,
|
{ includeNico: nicoFlg = false,
|
||||||
includeUnused = false,
|
includeUnused: unusedFlg = false,
|
||||||
describedBy,
|
describedBy, invalid, value, setValue },
|
||||||
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 handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
const parts = value?.split (' ')
|
const parts = value?.split (' ')
|
||||||
const negative = /^not:/i.test (parts[parts.length - 1])
|
const negative = /^not:/i.test (parts[parts.length - 1])
|
||||||
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
||||||
setValue (parts.join (' ') + ' ')
|
setValue (parts.join (' ') + ' ')
|
||||||
setRequest ({ query: '' })
|
|
||||||
setActiveIndex (-1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
const { inputProps, suggestionProps, requestSuggestions } =
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
|
||||||
switch (ev.key)
|
nicoFlg, unusedFlg, onSelect: handleTagSelect })
|
||||||
{
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
ev.preventDefault ()
|
setValue (event.target.value)
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
const token = event.target.value.trim ().split (' ').at (-1) ?? ''
|
||||||
setSuggestionsVsbl (true)
|
requestSuggestions (token, token.replace (/^not:/i, ''))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
{...inputProps}
|
||||||
type="text"
|
type="text"
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className={inputClass (invalid)}/>
|
className={inputClass (invalid)}/>
|
||||||
<TagSearchBox
|
<TagSearchBox {...suggestionProps}/>
|
||||||
suggestions={
|
|
||||||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import type { Deerjikist,
|
|||||||
TagVersion } from '@/types'
|
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 (
|
export const fetchTags = async (
|
||||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, deprecated,
|
updatedFrom, updatedTo, deprecated,
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
}
|
||||||
新しいイシューから参照
ユーザーをブロックする