コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 520ffa4c4c #442 2026-11-07 18:34:17 +09:00
みてるぞ 50185ca8b7 #442 2026-11-07 15:48:47 +09:00
みてるぞ dad3ac99b4 #442 2026-11-01 22:29:40 +09:00
6個のファイルの変更、27行の追加、219行の削除
-5
ファイルの表示
@@ -1,5 +0,0 @@
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
def change
add_column :nico_tag_versions, :linked_tag_ids, :text
end
end
-42
ファイルの表示
@@ -1,42 +0,0 @@
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
execute <<~SQL
DELETE
t1
FROM
nico_tag_versions t1
INNER JOIN
nico_tag_versions t2
ON
t1.tag_id = t2.tag_id
AND t2.event_type = 'discard'
SQL
MigrationNicoTagVersion.find_each do |ntv|
linked_tag_ids = ntv.linked_tags.split.map { |name|
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
MigrationTagVersion
.order(created_at: :desc, id: :desc)
.find_by("names ->> '$.ja.name' = ?", name)
&.tag_id
}.compact.uniq.sort.join(' ')
ntv.update_columns(linked_tag_ids:)
end
change_column_null :nico_tag_versions, :linked_tag_ids, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
def change
remove_column :nico_tag_versions, :linked_tags
remove_column :nico_tag_versions, :name
end
end
生成ファイル
+3 -2
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -301,9 +301,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.string "event_type", null: false t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.text "linked_tag_ids", null: false
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
-120
ファイルの表示
@@ -1,120 +0,0 @@
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'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+24 -44
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する. // TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -20,7 +20,6 @@ const TagSearch: FC = () => {
const locale = useLocale () 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 [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
@@ -75,34 +74,7 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}, [locale]) }, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { 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) switch (ev.key)
{ {
case 'ArrowDown': case 'ArrowDown':
@@ -118,12 +90,13 @@ const TagSearch: FC = () => {
break break
case 'Enter': case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
{ {
const selected = suggestionsVsbl ? suggestions[activeIndex] : null const selected = suggestions[activeIndex]
if (selected == null) if (selected)
break handleTagSelect (selected)
ev.preventDefault ()
handleTagSelect (selected)
} }
break break
@@ -132,6 +105,20 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
break break
} }
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
} }
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
@@ -157,9 +144,8 @@ const TagSearch: FC = () => {
}, [location.search]) }, [location.search])
return ( return (
<form onSubmit={handleSubmit} className="relative w-full"> <div className="relative w-full">
<input type="text" <input type="text"
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}
@@ -167,18 +153,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false, className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</form>) </div>)
} }
export default TagSearch export default TagSearch