コミットを比較

..
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
17個のファイルの変更、372行の追加、353行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?)
needs_history_join = 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 history_join_required
history_times = PostVersion.all
if needs_history_join
history_times = HistoryAccess.visible(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 history_join_required
if needs_history_join
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 history_join_required
if needs_history_join
{ }
else
PostVersion.where(post_id: posts.map(&:id))
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at)
end
+1
ファイルの表示
@@ -49,6 +49,7 @@ 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 = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
end
+6 -5
ファイルの表示
@@ -8,11 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip
descriptions =
TagDescription.where(language_code: locale.language_code)
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
@@ -40,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names)
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).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(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
@@ -410,7 +411,7 @@ class WikiPagesController < ApplicationController
end
def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names)
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
end
+23
ファイルの表示
@@ -147,6 +147,29 @@ 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,6 +44,7 @@ module TagGroupRepr
end
def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first
end
+1
ファイルの表示
@@ -5,6 +5,7 @@ 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 'keeps discarded history private while exposing the current description' do
it 'denies the same discarded description through Counts, the facade and the ordinary API' 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(:ok)
expect(response.parsed_body.fetch('body')).to eq(description.body)
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('private body')
end
get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
+10 -8
ファイルの表示
@@ -69,38 +69,40 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end
it 'uses the latest version timestamp for display regardless of role' do
it 'uses only authorised history timestamps for display' 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 + 4.days
expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end
it 'uses the latest version timestamp for date bounds and count regardless of role' do
it 'uses the same visibility for date bounds and count' 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([post_record.id])
expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length)
end
it 'sorts using the latest version timestamps in both directions regardless of role' do
it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected = [other_post.id, post_record.id]
expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end
end
it 'applies latest version timestamp ordering before pagination regardless of role' do
it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id])
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('count')).to eq(2)
end
+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>)
}
+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>)
}
+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 } }
}