コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ b9d7c4fef5 #444 2026-11-08 01:56:45 +09:00
みてるぞ 10b3717886 Merge remote-tracking branch 'origin/main' into feature/444 2026-11-07 20:06:40 +09:00
みてるぞ 9e803d473a #444 2026-11-07 15:50:00 +09:00
19個のファイルの変更、117行の追加、473行の削除
+12 -79
ファイルの表示
@@ -6,14 +6,8 @@ class CountsCandidates
def initialize(params) def initialize(params)
QueryState.validate!(params, KEYS) QueryState.validate!(params, KEYS)
definition = CountsResourceDefinition.new(params.fetch('resource')) raise ArgumentError unless params.fetch('resource') == 'posts'
fields = ['resource_id', 'user'] raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
fields += ['tags', 'tags_json', 'tags.id'] if definition.resource == 'posts'
fields << 'members' if definition.resource == 'tag_groups'
if definition.resource == 'materials'
fields += ['updated_by_user_id', 'file_blob_id', 'source_file_id', 'drive_file_id']
end
raise ArgumentError unless fields.include?(params.fetch('field'))
@params = params @params = params
@page = Integer(params.fetch('page', '1'), 10) @page = Integer(params.fetch('page', '1'), 10)
@@ -24,27 +18,25 @@ class CountsCandidates
end end
def call def call
references = ['tags', 'tags_json', 'tags.id', 'members']
return resource_candidates unless references.include?(@params['field'])
options = @params.slice('locale', 'at', 'execution_at', 'timezone') options = @params.slice('locale', 'at', 'execution_at', 'timezone')
.merge('definition' => 'latest', 'resource' => 'tags') .merge('definition' => 'latest', 'resource' => 'tags')
internal = CountsPopulation.new(options) internal = CountsPopulation.new(options)
groups_flg = @params['field'] == 'members' external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
external = CountsPopulation.new(options.merge(
'resource' => groups_flg ? 'tag_groups' : 'nico_tags',
'execution_at' => internal.at.iso8601(6))) 'execution_at' => internal.at.iso8601(6)))
connection = TagVersion.connection connection = TagVersion.connection
sources = [[internal, 'internal', 'v.category', 'v.names'], sources = [[internal, 'internal', 'v.category', 'v.names'],
[external, groups_flg ? 'group' : 'external', 'NULL', 'JSON_OBJECT()']] [external, 'external', 'NULL', 'JSON_OBJECT()']]
sources = sources.map do |population, kind, category, names| sources = sources.map do |population, kind, category, names|
scope = population.scope.except(:select) scope = population.scope.except(:select)
identity = "v.#{ population.definition.keys.first }" if @params['query'].present?
scope = search(scope, identity, 'v.name', names) pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
value = ['tags.id', 'members'].include?(@params['field']) ? scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
"CAST(#{ identity } AS CHAR)" : "CONCAT('#{ kind }:', #{ identity })" "SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end
scope.select(Arel.sql( scope.select(Arel.sql(
"#{ value } AS value, #{ identity } AS id, " + "CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind")) "v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
.to_sql .to_sql
end end
@@ -63,63 +55,4 @@ class CountsCandidates
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT, { rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
execution_at: internal.at.iso8601(6) } execution_at: internal.at.iso8601(6) }
end end
private
def search(scope, identity, name, names)
return scope if @params['query'].blank?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
scope.where("(#{ name } LIKE ? OR CAST(#{ identity } AS CHAR) = ? OR EXISTS (" +
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end
def resource_candidates
options = @params.slice('at', 'execution_at', 'timezone')
.merge('resource' => @params.fetch('resource'), 'definition' => 'latest')
actor_flg = ['user', 'updated_by_user_id'].include?(@params['field'])
options['definition'] = 'history' if actor_flg
population = CountsPopulation.new(options)
scope = population.scope.except(:select)
if actor_flg
point = population.timestamp(@params['at']) || population.at
raise ArgumentError if point > population.at
column = @params['field'] == 'user' ? 'created_by_user_id' : 'updated_by_user_id'
identities = scope.where('v.created_at <= ?', point).select("v.#{ column }")
scope = User.where(id: identities)
identity = 'users.id'
name = 'users.name'
names = 'JSON_OBJECT()'
else
definition = population.definition
column = @params['field'] == 'resource_id' ? definition.keys.first : @params['field']
if column == 'drive_file_id'
column = 'source_file_id'
scope = scope.where('v.source_kind' => 'google_drive_file')
end
identity = "v.#{ column }"
label = ['title', 'name', 'url'].find { definition.fields.key?(_1) }
name = label ? "v.#{ label }" : 'NULL'
names = definition.resource == 'tags' ? 'v.names' : 'JSON_OBJECT()'
if ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field'])
scope = scope.where.not("v.#{ column }" => nil).group(Arel.sql(identity))
name = 'v.file_filename'
end
end
scope = search(scope, identity, name, names)
title = ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field']) ?
"MIN(#{ name })" : name
query = scope.optimizer_hints('MAX_EXECUTION_TIME(10000)').distinct
.select(Arel.sql("#{ identity } AS id, #{ title } AS name, #{ names } AS names"))
.order(Arel.sql(identity)).limit(LIMIT + 1).offset((@page - 1) * LIMIT)
rows = scope.klass.connection.select_all(query.to_sql).to_a.map do |values|
values['names'] = JSON.parse(values['names']) if values['names'].is_a?(String)
values.merge('value' => values.fetch('id').to_s)
end
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
execution_at: population.at.iso8601(6) }
end
end end
+1 -7
ファイルの表示
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
@@ -185,12 +184,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
value={parentPostIds} value={parentPostIds}
disabled={disabled} disabled={disabled}
errors={fieldErrors.parentPostIds} errors={fieldErrors.parentPostIds}
onChange={setParentPostIds} onChange={setParentPostIds}/>
after={<ResourcePickerButton
source={{ resource: 'posts', field: 'resource_id' }}
values={parentPostIds.split (/\s+/).filter (Boolean)}
maximum={Number.MAX_SAFE_INTEGER} disabled={disabled}
onSelect={values => setParentPostIds (values.join (' '))}/>}/>
<PostTagsField <PostTagsField
disabled={disabled} disabled={disabled}
-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
-27
ファイルの表示
@@ -1,27 +0,0 @@
import CandidatePicker from '@/components/common/CandidatePicker'
import { Button } from '@/components/ui/button'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CandidateSource } from '@/components/common/CandidatePicker'
const ResourcePickerButton = (
{ source, values, maximum = 1, disabled, onSelect }: {
source: CandidateSource; values: (string | number | boolean)[]
maximum?: number; disabled?: boolean
onSelect: (values: string[]) => void },
) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('counts')
return <Button type="button" variant="outline" disabled={disabled || maximum === 0}
onClick={() => void dialogue.form ({
title: label ('pattern.link'), size: 'large', footerLayout: 'inline',
body: controls => <CandidatePicker source={source} initial={values}
maximum={maximum} controls={controls} onSelect={onSelect}/> })}>
{label ('pattern.selection')}
</Button>
}
export default ResourcePickerButton
+13 -24
ファイルの表示
@@ -2,10 +2,9 @@ import { useEffect, useState } from 'react'
import CountsConditionValues from '@/components/counts/CountsConditionValues' import CountsConditionValues from '@/components/counts/CountsConditionValues'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel, import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
countsFilterFields, countsSetOperators, useCountsDefinition } countsSetOperators, useCountsDefinition }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { countsConditionValue } from '@/lib/countsDisplay' import { countsConditionValue } from '@/lib/countsDisplay'
@@ -16,7 +15,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState' import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsResource, CountsResult } from '@/lib/counts' import type { CountsResult } from '@/lib/counts'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions' import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
@@ -95,12 +94,10 @@ const ConditionEditor = (
} }
const CountsAdditionalConditions = ( const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
{ resource, params, update, conditionLabels }: { conditionLabels?: CountsResult['conditionLabels']
resource: CountsResource params: URLSearchParams
conditionLabels?: CountsResult['conditionLabels'] update: (key: string, value: string) => void },
params: URLSearchParams
update: (key: string, value: string) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition () const definition = useCountsDefinition ()
@@ -152,19 +149,11 @@ const CountsAdditionalConditions = (
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank} value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
aria-label={aria.text} lang={aria.language} aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/> onChange={value => patch (index, { [key]: value ?? '' })}/>
const source = countsCandidateSource (resource, condition.field, params) return <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
const value = condition[key] type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
return <> className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
<input value={condition[key] == null ? '' : String (condition[key])} aria-label={aria.text} lang={aria.language}
disabled={condition.blank} onChange={event => patch (index, { [key]: event.target.value })}/>
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
{source != null && <ResourcePickerButton source={source} disabled={condition.blank}
values={value == null ? [] : [value]}
onSelect={values => patch (index, { [key]: values[0] ?? '' })}/>}
</>
} }
return <fieldset className="min-w-0 space-y-1"> return <fieldset className="min-w-0 space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')} <legend className="flex items-center gap-2">{label ('additionalConditions')}
@@ -193,7 +182,7 @@ const CountsAdditionalConditions = (
</Button> </Button>
{countsSetOperators.includes (condition.operator) ? ( {countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`} <CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition} resource={resource} condition={condition}
field={metadata.find (field => field.key === condition.field)!} field={metadata.find (field => field.key === condition.field)!}
definition={definition} params={params} definition={definition} params={params}
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) => maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
@@ -10,19 +10,16 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsResource } from '@/lib/counts' import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
import type { CountsCandidate } from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
type CandidateSource = { const CountsCandidatePicker = (
resource: CountsResource; field: string; params?: URLSearchParams } { field, definition, params, initial, maximum, controls, save }: {
field: CountsFieldMetadata; params: URLSearchParams
const CandidatePicker = ( definition: CountsDefinition
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
source?: CandidateSource; choices?: CountsCandidate[]
categories?: { value: string; label: string }[]
initial: (string | number | boolean)[]; maximum: number initial: (string | number | boolean)[]; maximum: number
controls: DialogueFormControls; onSelect: (values: string[]) => void }, controls: DialogueFormControls; save: (values: string[]) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const locale = useLocale () const locale = useLocale ()
@@ -32,58 +29,49 @@ const CandidatePicker = (
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
const [category, setCategory] = useState ('') const [category, setCategory] = useState ('')
const [page, setPage] = useState (1) const [page, setPage] = useState (1)
const params = source?.params ?? new URLSearchParams ()
const point = useRef (params.get ('execution_at')) const point = useRef (params.get ('execution_at'))
const resource = source?.resource ?? 'posts' const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
const request = new URLSearchParams ({
field: source?.field ?? '', query: search, page: String (page) })
for (const key of ['timezone', 'at', 'execution_at']) for (const key of ['timezone', 'at', 'execution_at'])
if (params.has (key)) request.set (key, params.get (key)!) if (params.has (key)) request.set (key, params.get (key)!)
if (countsUseLatest (params)) request.delete ('at') if (countsUseLatest (params)) request.delete ('at')
if (category) request.set ('category', category) if (category) request.set ('category', category)
const dynamic = source != null const dynamic = field.candidateSource === 'tags'
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({ const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
queryKey: countsKeys.candidates (resource, request.toString (), locale), enabled: dynamic, queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
const pinned = new URLSearchParams (request) const pinned = new URLSearchParams (request)
if (point.current != null) pinned.set ('execution_at', point.current) if (point.current != null) pinned.set ('execution_at', point.current)
return fetchCountsCandidates (resource, pinned, locale, signal) return fetchCountsCandidates (pinned, locale, signal)
}, retry: false, staleTime: 0, gcTime: 0 }) }, retry: false })
useEffect (() => { useEffect (() => {
if (data != null) point.current = data.executionAt if (data != null) point.current = data.executionAt
}, [data]) }, [data])
useEffect (() => { useEffect (() => {
controls.setActions ([{ label: label ('counts:pattern.selection'), controls.setActions ([{ label: label ('counts:pattern.selection'),
onSelect: () => onSelect ([...selected]) }]) onSelect: () => save ([...selected]) }])
}, [controls, label, onSelect, selected]) }, [controls, label, save, selected])
const rows = dynamic ? data?.rows ?? [] : choices.filter (row => const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
row.name?.includes (search)) row.name?.includes (search))
const headings: Record<string, string> = { const headings: Record<string, string> = {
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel', id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
kind: 'columns.kind' } kind: 'columns.kind' }
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '') const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
const available =
dynamic
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
const visible = available.filter (column => columns.includes (column)) const visible = available.filter (column => columns.includes (column))
const categories = definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices ?? []
const cell = (row: CountsCandidate, column: string) => { const cell = (row: CountsCandidate, column: string) => {
if (column === 'category') if (column === 'category') return row.category == null ? ''
return row.category == null ? '' : message (`common:categories.${row.category}`).text : message (`common:categories.${row.category}`).text
if (column === 'kind') if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
{
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
return row.kind == null ? '' : message (key).text
}
return column === 'id' ? row.id : row.name return column === 'id' ? row.id : row.name
} }
return <div className="min-w-0 space-y-2"> return <div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<input className={inputClass (false, 'h-9 min-w-0 flex-1')} <input className={inputClass (false, 'h-9 min-w-0 flex-1')}
aria-label={message ('common:search').text} value={draft} maxLength={1000} aria-label={message ('common:search').text} value={draft}
onChange={event => { onChange={event => setDraft (event.target.value)}
const value = event.target.value
if (new TextEncoder ().encode (value).length <= 1000) setDraft (value)
}}
onKeyDown={event => { onKeyDown={event => {
if (event.key !== 'Enter') return if (event.key !== 'Enter') return
event.preventDefault () event.preventDefault ()
@@ -93,7 +81,7 @@ const CandidatePicker = (
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}> <Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
{label ('common:search')}</Button> {label ('common:search')}</Button>
</div> </div>
{categories.length > 0 && <select className={inputClass (false, 'h-9 w-full')} {dynamic && <select className={inputClass (false, 'h-9 w-full')}
aria-label={message ('common:categoryLabel').text} value={category} aria-label={message ('common:categoryLabel').text} value={category}
onChange={event => { setCategory (event.target.value); setPage (1) }}> onChange={event => { setCategory (event.target.value); setPage (1) }}>
<option value=""/> <option value=""/>
@@ -120,13 +108,12 @@ const CandidatePicker = (
{visible.map (column => <th key={column} className="p-2"> {visible.map (column => <th key={column} className="p-2">
{label (headings[column])}</th>)} {label (headings[column])}</th>)}
</tr></thead> </tr></thead>
<tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`} <tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
className="border-t dark:border-slate-700">
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)} <td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
aria-label={row.name ?? String (row.id ?? '')} aria-label={row.name ?? String (row.id ?? '')}
disabled={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum} disabled={!selected.has (row.value) && selected.size >= maximum}
onChange={event => setSelected (previous => { onChange={event => setSelected (previous => {
const next = new Set (maximum === 1 ? [] : previous) const next = new Set (previous)
if (event.target.checked) next.add (row.value) if (event.target.checked) next.add (row.value)
else next.delete (row.value) else next.delete (row.value)
return next return next
@@ -141,8 +128,7 @@ const CandidatePicker = (
onClick={() => setPage (previous => previous - 1)}> onClick={() => setPage (previous => previous - 1)}>
{label ('common:pagination.previous')}</Button> {label ('common:pagination.previous')}</Button>
<span>{page}</span> <span>{page}</span>
<Button type="button" variant="outline" <Button type="button" variant="outline" disabled={!data?.more || fetching}
disabled={!data?.more || page >= 100_000 || fetching}
onClick={() => setPage (previous => previous + 1)}> onClick={() => setPage (previous => previous + 1)}>
{label ('common:pagination.next')}</Button> {label ('common:pagination.next')}</Button>
</div>} </div>}
@@ -150,5 +136,4 @@ const CandidatePicker = (
} }
export default CandidatePicker export default CountsCandidatePicker
export type { CandidateSource }
+4 -20
ファイルの表示
@@ -1,22 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import CandidatePicker from '@/components/common/CandidatePicker' import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsCandidateSource } from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource } from '@/lib/counts'
import type { CountsCondition, CountsDefinition, CountsFieldMetadata } import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
const CountsConditionValues = ( const CountsConditionValues = (
{ resource, condition, field, definition, params, maximum, change }: { { condition, field, definition, params, maximum, change }: {
resource: CountsResource
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
params: URLSearchParams; maximum: number params: URLSearchParams; maximum: number
change: (values: (string | number | boolean)[]) => void }, change: (values: (string | number | boolean)[]) => void },
@@ -25,21 +21,11 @@ const CountsConditionValues = (
const dialogue = useDialogue () const dialogue = useDialogue ()
const [draft, setDraft] = useState ('') const [draft, setDraft] = useState ('')
const values = condition.values ?? [] const values = condition.values ?? []
const source = countsCandidateSource (resource, field.key, params)
const pick = () => { const pick = () => {
void dialogue.form ({ title: label (field.candidateSource === 'tags' void dialogue.form ({ title: label (field.candidateSource === 'tags'
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline', ? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
body: controls => <CandidatePicker body: controls => <CountsCandidatePicker field={field} definition={definition}
source={ params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
field.candidateSource === 'tags'
? { resource: 'posts', field: field.key, params } : undefined}
choices={(field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))}
categories={
field.candidateSource === 'tags'
? definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices : undefined}
initial={values} maximum={maximum} controls={controls} onSelect={change}/> })
} }
if (condition.input === 'list' || field.candidateSource === 'tags') if (condition.input === 'list' || field.candidateSource === 'tags')
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}> return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
@@ -87,8 +73,6 @@ const CountsConditionValues = (
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button> onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
</span>)} </span>)}
{input ()} {input ()}
{source != null && <ResourcePickerButton source={source} values={values}
maximum={maximum} disabled={condition.blank} onSelect={change}/>}
<Button type="button" variant="outline" disabled={condition.blank || draft === '' <Button type="button" variant="outline" disabled={condition.blank || draft === ''
|| values.length >= maximum} onClick={append}>{label ('add')}</Button> || values.length >= maximum} onClick={append}>{label ('add')}</Button>
<Button type="button" variant="ghost" disabled={condition.blank} <Button type="button" variant="ghost" disabled={condition.blank}
+6 -13
ファイルの表示
@@ -7,7 +7,6 @@ import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover' import HelpPopover from '@/components/common/HelpPopover'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort, import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
@@ -224,17 +223,11 @@ const CountsControls = (
{['resource_id', 'user_id'].map (key => <FormField key={key} {['resource_id', 'user_id'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId' label={label (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor')}> : 'fields.actor')}>
{() => <div className="flex flex-wrap items-center gap-1"> {() => <input value={draft.get (key) ?? ''}
<input value={draft.get (key) ?? ''} aria-label={message (key === 'resource_id' ? 'columns.resourceId'
aria-label={message (key === 'resource_id' ? 'columns.resourceId' : 'fields.actor').text}
: 'fields.actor').text} className={inputClass (false, 'w-28 py-1')}
className={inputClass (false, 'w-28 py-1')} onChange={event => update (key, event.target.value)}/>}
onChange={event => update (key, event.target.value)}/>
<ResourcePickerButton source={{ resource,
field: key === 'resource_id' ? key : 'user', params: draft }}
values={draft.get (key) ? [draft.get (key)!] : []}
onSelect={values => update (key, values[0] ?? '')}/>
</div>}
</FormField>)} </FormField>)}
</div> </div>
{resource === 'posts' && <FormField label={label ('columns.tags')}> {resource === 'posts' && <FormField label={label ('columns.tags')}>
@@ -270,7 +263,7 @@ const CountsControls = (
</>} </>}
</div>} </div>}
</FormField>} </FormField>}
<CountsAdditionalConditions resource={resource} params={draft} update={update} <CountsAdditionalConditions params={draft} update={update}
conditionLabels={(labelParams != null conditionLabels={(labelParams != null
&& draft.get ('current') === labelParams.get ('current') && draft.get ('current') === labelParams.get ('current')
&& draft.get ('at') === labelParams.get ('at') && draft.get ('at') === labelParams.get ('at')
+2 -4
ファイルの表示
@@ -227,13 +227,11 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<DialogueProvider key={active.id}> {active.options.body (
{active.options.body ( formControls.current[active.id] ??= {
formControls.current[active.id] ??= {
close: () => closeRequest (active.id), close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions), setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })} confirm: options => openNestedConfirm (active.id, options) })}
</DialogueProvider>
</div> </div>
<DialogFooter <DialogFooter
-10
ファイルの表示
@@ -70,16 +70,6 @@ describe ('SidebarComponent', () => {
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
}) })
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
renderSidebar ()
// jsdom has no layout; guard the responsive classes responsible for clipping.
const sidebar = screen.getByText ('Sidebar body')
expect (sidebar).not.toHaveClass ('overflow-y-auto')
expect (sidebar).toHaveClass ('md:overflow-y-auto')
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => { it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700') localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn () const onWidthChange = vi.fn ()
+1 -1
ファイルの表示
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
</style> </style>
</Helmet> </Helmet>
<div className="h-full md:overflow-y-auto p-4 sidebar"> <div className="h-full overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
+1 -7
ファイルの表示
@@ -1,4 +1,3 @@
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField' import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField' import PostTextField from '@/components/posts/PostTextField'
@@ -89,12 +88,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
disabled={parentPostIds.disabled} disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings} warnings={parentPostIds.warnings}
errors={parentPostIds.errors} errors={parentPostIds.errors}
onChange={parentPostIds.onChange} onChange={parentPostIds.onChange}/>
after={<ResourcePickerButton
source={{ resource: 'posts', field: 'resource_id' }}
values={parentPostIds.value.split (/\s+/).filter (Boolean)}
maximum={Number.MAX_SAFE_INTEGER} disabled={parentPostIds.disabled}
onSelect={values => parentPostIds.onChange (values.join (' '))}/>}/>
</>) </>)
export default PostCoreDataFields export default PostCoreDataFields
+12 -16
ファイルの表示
@@ -34,22 +34,18 @@ const PostTextField: FC<Props> = (
<FormField label={label} messages={errors}> <FormField label={label} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="flex items-center gap-2"> <input
<div className="min-w-0 flex-1"> type={type}
<input value={value}
type={type} disabled={disabled}
value={value} placeholder={placeholder}
disabled={disabled} onBlur={onBlur}
placeholder={placeholder} onChange={ev => onChange (ev.target.value)}
onBlur={onBlur} aria-describedby={describedBy}
onChange={ev => onChange (ev.target.value)} aria-invalid={invalid}
aria-describedby={describedBy} className={inputClass (invalid, className)}/>
aria-invalid={invalid} <FieldWarning messages={warnings}/>
className={inputClass (invalid, className)}/> {after}
</div>
{after}
</div>
<FieldWarning messages={warnings}/>
</>)} </>)}
</FormField>) </FormField>)
+3 -26
ファイルの表示
@@ -89,19 +89,18 @@ export const fetchCountsDefinition = (
.then (definition => localiseCountsDefinition (definition, locale)) .then (definition => localiseCountsDefinition (definition, locale))
export type CountsCandidate = { export type CountsCandidate = {
value: string; id?: number | string; name: string | null; category?: string | null value: string; id?: number; name: string | null; category?: string | null
names?: Record<string, unknown> names?: Record<string, unknown>
kind?: 'internal' | 'external' | 'group' } kind?: 'internal' | 'external' }
export type CountsCandidatesResult = { export type CountsCandidatesResult = {
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string } rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
export const fetchCountsCandidates = ( export const fetchCountsCandidates = (
resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
locale: LocaleCode, locale: LocaleCode,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<CountsCandidatesResult> => apiGet ( ): Promise<CountsCandidatesResult> => apiGet (
`/counts/definitions/${encodeURIComponent (resource)}/candidates`, { '/counts/definitions/posts/candidates', {
params: Object.fromEntries (params), locale, signal }) params: Object.fromEntries (params), locale, signal })
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none'] export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
@@ -156,25 +155,3 @@ export const countsAxisFields = (
history: boolean, history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest'] ): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) })) .map (key => ({ key, label: countsFieldLabel (key) }))
export const countsCandidateSource = (
resource: CountsResource,
field: string,
params: URLSearchParams,
): { resource: CountsResource; field: string; params: URLSearchParams } | undefined => {
if (['resource_id', 'user', 'updated_by_user_id', 'file_blob_id', 'source_file_id']
.includes (field)) return { resource, field, params }
if (field === 'user.id') return { resource, field: 'user', params }
if ((field === 'tags.id' && resource === 'posts')
|| (field === 'members' && resource === 'tag_groups'))
return { resource, field, params }
const leaf = field.split ('.').at (-1) ?? ''
const targets: Record<string, CountsResource> = {
tagGroupId: 'tag_groups', tagId: 'tags', tag_id: 'tags', parent_id: 'materials',
parents: resource === 'posts' ? 'posts' : 'tags',
'parents.id': resource === 'posts' ? 'posts' : 'tags',
parent_post_ids: 'posts', parent_tag_ids: 'tags' }
const target = targets[field] ?? targets[leaf]
return target == null ? undefined : { resource: target, field: 'resource_id', params }
}
+1 -2
ファイルの表示
@@ -174,7 +174,6 @@ export const theatresKeys = {
export const countsKeys = { export const countsKeys = {
definition: (resource: string, locale: LocaleCode = 'ja') => definition: (resource: string, locale: LocaleCode = 'ja') =>
['counts', 'definition', resource, locale] as const, ['counts', 'definition', resource, locale] as const,
candidates: (resource: string, params: string, locale: LocaleCode) => candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
['counts', 'candidates', resource, locale, params] as const,
index: (resource: string, params: string, locale: LocaleCode) => index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const } ['counts', resource, locale, params] as const }
+2 -9
ファイルの表示
@@ -6,7 +6,6 @@ import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
@@ -97,19 +96,13 @@ const MaterialHistoryPage: FC = () => {
<div className="space-y-3"> <div className="space-y-3">
<FormField label={label ('materialId')}> <FormField label={label ('materialId')}>
{({ invalid }) => ( {({ invalid }) => (
<div className="flex items-center gap-2"> <input
<input
type="text" type="text"
value={materialIdInput} value={materialIdInput}
aria-label={message ('materialId').text} aria-label={message ('materialId').text}
lang={message ('materialId').language} lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)} onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid, 'min-w-0 flex-1')}/> className={inputClass (invalid)}/>)}
<ResourcePickerButton
source={{ resource: 'materials', field: 'resource_id' }}
values={materialIdInput === '' ? [] : [materialIdInput]}
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
</div>)}
</FormField> </FormField>
<FormField label={label ('tagName')}> <FormField label={label ('tagName')}>
+2 -9
ファイルの表示
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
@@ -127,17 +126,11 @@ const MaterialSyncSuppressionsPage: FC = () => {
<FormField label={label ('suppressions.driveFileId')}> <FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => ( {({ invalid }) => (
<div className="flex items-center gap-2"> <input
<input
type="text" type="text"
value={driveFileId} value={driveFileId}
onChange={e => setDriveFileId (e.target.value)} onChange={e => setDriveFileId (e.target.value)}
className={inputClass (invalid, 'min-w-0 flex-1')}/> className={inputClass (invalid)}/>)}
<ResourcePickerButton
source={{ resource: 'materials', field: 'drive_file_id' }}
values={driveFileId === '' ? [] : [driveFileId]}
onSelect={values => setDriveFileId (values[0] ?? '')}/>
</div>)}
</FormField> </FormField>
<FormField label={label ('suppressions.reason')}> <FormField label={label ('suppressions.reason')}>
+2 -9
ファイルの表示
@@ -35,10 +35,6 @@ const renderPage = () =>
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
tagsApi.fetchTag.mockReset ()
api.apiPut.mockReset ()
api.isApiError.mockReset ()
toastApi.toast.mockReset ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
}) })
@@ -55,11 +51,8 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] }) tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
tagsApi.fetchTag api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()