コミットを比較

..
5 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 688143ccfb AI: address review on PR #462 2026-11-13 12:37:13 +09:00
Codex Night Worker a02037599a AI: address review on PR #462 2026-11-12 19:49:55 +09:00
Codex Night Worker 09df6d740a AI: address review on PR #462 2026-11-12 19:47:49 +09:00
Codex Night Worker df9e98132e AI: address review on PR #462 2026-11-12 19:45:42 +09:00
Codex Night Worker 930817d077 AI: address issue #450 2026-11-12 19:13:32 +09:00
17個のファイルの変更、354行の追加、373行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?)
q =
filtered_posts(locale)
@@ -57,8 +57,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q
if needs_history_join
history_times = HistoryAccess.visible(PostVersion.all)
if history_join_required
history_times = PostVersion.all
.select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end
count_scope = q if updated_between.any?(&:present?)
if needs_history_join
if history_join_required
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset)
.to_a
latest_version_times =
if needs_history_join
if history_join_required
{ }
else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
PostVersion.where(post_id: posts.map(&:id))
.group(:post_id).maximum(:created_at)
end
-1
ファイルの表示
@@ -49,7 +49,6 @@ class TagDescriptionsController < ApplicationController
private
def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [
:tag_id,
:language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
end
+5 -6
ファイルの表示
@@ -8,12 +8,11 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip
descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
TagDescription.where(language_code: locale.language_code)
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
@@ -41,13 +40,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
names = TagDescription.joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end
candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
@@ -411,7 +410,7 @@ class WikiPagesController < ApplicationController
end
def tag_description_by_title locale, title
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
TagDescription.joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
end
-23
ファイルの表示
@@ -147,29 +147,6 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row)))
end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = [])
return true if admin?
return false if seen.include?(blob.id)
-1
ファイルの表示
@@ -44,7 +44,6 @@ module TagGroupRepr
end
def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first
end
-1
ファイルの表示
@@ -5,7 +5,6 @@ module WikiPageRepr
module_function
def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag
{ 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end
end
it 'denies the same discarded description through Counts, the facade and the ordinary API' do
it 'keeps discarded history private while exposing the current description' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('private body')
expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('body')).to eq(description.body)
end
get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
+8 -10
ファイルの表示
@@ -69,40 +69,38 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end
it 'uses only authorised history timestamps for display' do
it 'uses the latest version timestamp for display regardless of role' do
get '/posts'
expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + (role == 'admin' ? 4 : 3).days
expected = origin + 4.days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end
it 'uses the same visibility for date bounds and count' do
it 'uses the latest version timestamp for date bounds and count regardless of role' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(ids).to eq([post_record.id])
expect(response.parsed_body.fetch('count')).to eq(ids.length)
end
it 'sorts using authorised timestamps in both directions' do
it 'sorts using the latest version timestamps in both directions regardless of role' do
['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected = [other_post.id, post_record.id]
expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end
end
it 'applies authorised timestamp ordering before pagination' do
it 'applies latest version timestamp ordering before pagination regardless of role' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok)
expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id])
expect(response.parsed_body.fetch('count')).to eq(2)
end
+10 -22
ファイルの表示
@@ -12,36 +12,24 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => {
it ('replaces the selected token and restores the caret without losing other tags', async () => {
it ('updates text and fetches autocomplete for the selected token', async () => {
const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
const { rerender } = renderWithProviders (
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox')
const textarea = screen.getByRole ('textbox')
fireEvent.focus (textarea)
fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } })
rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>)
fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } })
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
fireEvent.change (textarea, { target: { value: '虹夏' } })
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 ('ぼっち 虹な 後続')
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 ()
expect (setTags).toHaveBeenCalledWith ('虹夏')
})
})
+62 -9
ファイルの表示
@@ -1,10 +1,13 @@
import { useRef, useState } from 'react'
// TODO: TagSearch と共通化する.
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useEffect, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
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'
@@ -38,13 +41,24 @@ 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)
@@ -56,18 +70,52 @@ 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)
requestSuggestions (token)
setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ()))
return
{
setSuggestionsVsbl (false)
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 }) => (
@@ -86,10 +134,15 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
inputProps.onBlur ()
setSuggestionsVsbl (false)
}}/>
{focused && (
<TagSearchBox {...suggestionProps}/>)}
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</>)}
</FormField>)
}
+1 -38
ファイルの表示
@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { 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,8 +8,6 @@ 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 ()
@@ -119,39 +117,4 @@ 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)
})
})
+132 -30
ファイルの表示
@@ -1,46 +1,82 @@
import { useEffect, useState } from 'react'
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import TagSearchBox from '@/components/TagSearchBox'
import { useLocaleNavigate } from '@/lib/useLocale'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import type { ChangeEvent, FC, FormEvent } from 'react'
import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } 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 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 (' ') + ' ')
}
const { inputProps, suggestionProps, requestSuggestions,
clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
token: search.trim ().split (' ').at (-1) ?? '',
nicoFlg: true,
onSelect: handleTagSelect })
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
const value = ev.target.value
const query = value.trim ().split (' ').at (-1) ?? ''
setSearch (value)
requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault ()
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])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
@@ -53,7 +89,61 @@ const TagSearch: FC = () => {
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
clearSuggestions ()
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)
}
useEffect (() => {
@@ -61,21 +151,33 @@ const TagSearch: FC = () => {
const anyFlg = query.get ('match') === 'any'
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
setSearch (tagsQuery)
clearSuggestions ()
}, [location.search, clearSuggestions])
setRequest ({ query: '' })
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [location.search])
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 {...suggestionProps}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</form>)
}
+10 -53
ファイルの表示
@@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
@@ -20,20 +20,18 @@ describe ('TagInput', () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
const { rerender } = render (
<TagInput value="ぼっち 虹" setValue={setValue}/>)
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', () => {
@@ -56,51 +54,10 @@ 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)
})
})
+116 -16
ファイルの表示
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent } from 'react'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
@@ -16,39 +19,136 @@ type Props = {
setValue: (value: string) => void }
const TagInput: FC<Props> = (
{ includeNico: nicoFlg = false,
includeUnused: unusedFlg = false,
describedBy, invalid, value, setValue },
) => {
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 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)
}
const { inputProps, suggestionProps, requestSuggestions } =
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
nicoFlg, unusedFlg, onSelect: handleTagSelect })
// 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 whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
setValue (event.target.value)
const token = event.target.value.trim ().split (' ').at (-1) ?? ''
requestSuggestions (token, token.replace (/^not:/i, ''))
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)
}
}
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 {...suggestionProps}/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}
-11
ファイルの表示
@@ -11,17 +11,6 @@ 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
ファイルの表示
@@ -1,142 +0,0 @@
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 } }
}