コミットを比較

..
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 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 = q =
filtered_posts(locale) 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[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if needs_history_join if history_join_required
history_times = HistoryAccess.visible(PostVersion.all) history_times = PostVersion.all
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) 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") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if needs_history_join if history_join_required
{ } { }
else else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id))) PostVersion.where(post_id: posts.map(&:id))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
-1
ファイルの表示
@@ -49,7 +49,6 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! 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) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+5 -6
ファイルの表示
@@ -8,12 +8,11 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion) TagDescription.where(language_code: locale.language_code)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) 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? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -41,13 +40,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) 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 }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end 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:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -411,7 +410,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title 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, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
-23
ファイルの表示
@@ -147,29 +147,6 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end 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 = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
-1
ファイルの表示
@@ -44,7 +44,6 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
-1
ファイルの表示
@@ -5,7 +5,6 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
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', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) 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' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:ok)
expect(response.body).not_to include('private body') expect(response.parsed_body.fetch('body')).to eq(description.body)
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) 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' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses only authorised history timestamps for display' do it 'uses the latest version timestamp for display regardless of role' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } 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) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end 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, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') 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) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end 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| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] : expected = [other_post.id, post_record.id]
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
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 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) 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([post_record.id])
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end
+10 -22
ファイルの表示
@@ -12,36 +12,24 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => { 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 () const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
const { rerender } = renderWithProviders ( renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox') const textarea = screen.getByRole ('textbox')
fireEvent.focus (textarea) fireEvent.focus (textarea)
fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } }) fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>) fireEvent.change (textarea, { target: { value: '虹夏' } })
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 ()
}) })
}) })
+61 -8
ファイルの表示
@@ -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 { 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'
@@ -38,13 +41,24 @@ 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)
@@ -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 recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos) const { start, end, token } = getTokenAt (v, pos)
requestSuggestions (token) setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ())) if (!(token.trim ()))
{
setSuggestionsVsbl (false)
return 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 }) => (
@@ -86,10 +134,15 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onFocus={() => setFocused (true)} onFocus={() => setFocused (true)}
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
inputProps.onBlur () setSuggestionsVsbl (false)
}}/> }}/>
{focused && ( {focused && (
<TagSearchBox {...suggestionProps}/>)} <TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</>)} </>)}
</FormField>) </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 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,8 +8,6 @@ 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 ()
@@ -119,39 +117,4 @@ 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)
})
}) })
+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 { useLocation } from 'react-router-dom'
import TagSearchBox from '@/components/TagSearchBox' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
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 type { ChangeEvent, FC, FormEvent } from 'react' import TagSearchBox from './TagSearchBox'
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 handleTagSelect = (tag: Tag) => { const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
const parts = search.split (' ') const value = ev.target.value
parts[parts.length - 1] = ( const query = value.trim ().split (' ').at (-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
setSearch (value) setSearch (value)
requestSuggestions (value.trim ().split (' ').at (-1) ?? '') setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
} }
const handleSubmit = (event: FormEvent<HTMLFormElement>) => { useEffect (() => {
event.preventDefault () 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) if (compositionFlgRef.current)
return return
@@ -53,7 +89,61 @@ const TagSearch: FC = () => {
.filter (t => t !== 'type:or') .filter (t => t !== 'type:or')
.join (' '), .join (' '),
match })).toString () }`) 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 (() => { useEffect (() => {
@@ -61,21 +151,33 @@ 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)
clearSuggestions () setRequest ({ query: '' })
}, [location.search, clearSuggestions]) setActiveIndex (-1)
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 {...suggestionProps}/> <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</form>) </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 { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
@@ -20,20 +20,18 @@ describe ('TagInput', () => {
const setValue = vi.fn () const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
const { rerender } = render ( render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
<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', () => {
@@ -56,51 +54,10 @@ 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)
})
}) })
+116 -16
ファイルの表示
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent } from 'react' import type { FC, ChangeEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -16,39 +19,136 @@ type Props = {
setValue: (value: string) => void } setValue: (value: string) => void }
const TagInput: FC<Props> = ( const TagInput: FC<Props> = ({
{ includeNico: nicoFlg = false, includeNico = false,
includeUnused: unusedFlg = false, includeUnused = false,
describedBy, invalid, value, setValue }, 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 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)
} }
const { inputProps, suggestionProps, requestSuggestions } = // TODO: TagSearch からのコピペのため,共通化を考へる.
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '', const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
nicoFlg, unusedFlg, onSelect: handleTagSelect }) switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => { case 'ArrowUp':
setValue (event.target.value) ev.preventDefault ()
const token = event.target.value.trim ().split (' ').at (-1) ?? '' setActiveIndex (i => Math.max (i - 1, -1))
requestSuggestions (token, token.replace (/^not:/i, '')) 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 ( 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 {...suggestionProps}/> <TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>) </div>)
} }
-11
ファイルの表示
@@ -11,17 +11,6 @@ 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,
-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 } }
}