コミットを比較

..
1 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker c0f42ea0c0 AI: address issue #170 2026-11-13 12:50:57 +09:00
20個のファイルの変更、231行の追加、358行の削除
+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
+75 -9
ファイルの表示
@@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query'
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 PostList from '@/components/PostList'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField' import PostDurationField from '@/components/posts/PostDurationField'
@@ -11,14 +12,15 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { fetchPosts, updatePost } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { FetchPostsParams, Post, TagWithSections } from '@/types'
type PostFormField = type PostFormField =
'parentPostIds' | 'tags' | 'videoMs' 'parentPostIds' | 'tags' | 'videoMs'
@@ -69,6 +71,33 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags)) const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const [importVisible, setImportVisible] = useState (false)
const [sourceTitle, setSourceTitle] = useState ('')
const [sourcePage, setSourcePage] = useState (1)
const sourceParams: FetchPostsParams = {
title: sourceTitle, page: sourcePage, limit: 20,
url: '', tags: '', match: 'all', order: 'original_created_at:desc',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '' }
const { data: sources, isError: sourceError } = useQuery ({
queryKey: postsKeys.index (sourceParams, locale),
queryFn: () => fetchPosts (sourceParams, locale),
enabled: importVisible && !disabled })
const importTags = (source: Post) => {
if (disabled)
return
setTags (current => {
const existing = new Set (current.split (/\s+/))
const additions = tagsToStr (source.tags).split (/\s+/)
.filter (tag => tag !== '' && !existing.has (tag))
return additions.length > 0
? `${current}${current.trim () === '' ? '' : ' '}${additions.join (' ')}`
: current
})
setImportVisible (false)
}
const videoFlg = const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags]) [tags])
@@ -185,12 +214,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}
@@ -198,6 +222,48 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setTags={setTags} setTags={setTags}
errors={fieldErrors.tags}/> errors={fieldErrors.tags}/>
<Button
type="button"
variant="outline"
disabled={disabled}
aria-expanded={importVisible}
onClick={() => setImportVisible (visible => !visible)}>
{label ('edit.import')}
</Button>
{importVisible && (
<div className="space-y-2" onKeyDown={event => {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement)
event.preventDefault ()
}}>
<PostTextField
label={label ('shared:labels.title')}
type="search"
value={sourceTitle}
disabled={disabled}
onChange={value => {
setSourceTitle (value)
setSourcePage (1)
}}/>
<FieldError messages={sourceError ? [label ('common:fetchError')] : []}/>
{!disabled && <PostList posts={sources?.posts ?? []} onSelect={importTags}/>}
<div className="flex flex-col gap-2 md:flex-row">
<Button
type="button"
variant="outline"
disabled={disabled || sourcePage <= 1}
onClick={() => setSourcePage (page => page - 1)}>
{label ('common:pagination.previous')}
</Button>
<Button
type="button"
variant="outline"
disabled={disabled || sourcePage * 20 >= (sources?.count ?? 0)}
onClick={() => setSourcePage (page => page + 1)}>
{label ('common:pagination.next')}
</Button>
</div>
</div>)}
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled} disabled={disabled}
+30 -16
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -13,10 +14,11 @@ import type { FC, MouseEvent } from 'react'
import type { Post } from '@/types' import type { Post } from '@/types'
type Props = { posts: Post[] type Props = { posts: Post[]
onSelect?: (post: Post) => void
onClick?: (event: MouseEvent<HTMLElement>) => void } onClick?: (event: MouseEvent<HTMLElement>) => void }
const PostList: FC<Props> = ({ posts, onClick }) => { const PostList: FC<Props> = ({ posts, onClick, onSelect }) => {
const { label } = useUITranslation ('shared') const { label } = useUITranslation ('shared')
const location = useLocation () const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
@@ -39,20 +41,11 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const sharedId = `page-${ post.id }` const sharedId = `page-${ post.id }`
const layoutId = sharedId const layoutId = sharedId
return ( const card = (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layout={animationMode === 'off' ? false : true} layout={animationMode === 'off' ? false : true}
layoutId={animationMode === 'off' ? undefined : layoutId} layoutId={(onSelect || animationMode === 'off') ? undefined : layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow', className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform', 'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -87,13 +80,34 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
loading={ loading={
thumbnailMode === 'light' thumbnailMode === 'light'
? 'lazy' ? 'lazy'
: i < 12 : (i < 12 ? 'eager' : 'lazy')
? 'eager'
: 'lazy'
} }
decoding="async" decoding="async"
className="object-cover w-full h-full"/>)} className="object-cover w-full h-full"/>)}
</motion.div> </motion.div>)
return onSelect
? (
<Button
key={post.id}
type="button"
variant="ghost"
className="w-40 h-40 p-0"
aria-label={post.title || post.url}
onClick={() => onSelect (post)}>
{card}
</Button>)
: (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
{card}
</PrefetchLink>) </PrefetchLink>)
})} })}
</div>) </div>)
-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
+6 -17
ファイルの表示
@@ -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,9 +94,7 @@ const ConditionEditor = (
} }
const CountsAdditionalConditions = ( const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
{ resource, params, update, conditionLabels }: {
resource: CountsResource
conditionLabels?: CountsResult['conditionLabels'] conditionLabels?: CountsResult['conditionLabels']
params: URLSearchParams params: URLSearchParams
update: (key: string, value: string) => void }, update: (key: string, value: string) => void },
@@ -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]
return <>
<input value={condition[key] == null ? '' : String (condition[key])}
disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'} type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')} className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language} aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/> 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}
+3 -10
ファイルの表示
@@ -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
ファイルの表示
@@ -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
+1 -5
ファイルの表示
@@ -34,8 +34,6 @@ 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">
<div className="min-w-0 flex-1">
<input <input
type={type} type={type}
value={value} value={value}
@@ -46,10 +44,8 @@ const PostTextField: FC<Props> = (
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid, className)}/> className={inputClass (invalid, className)}/>
</div>
{after}
</div>
<FieldWarning messages={warnings}/> <FieldWarning messages={warnings}/>
{after}
</>)} </>)}
</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
ファイルの表示
@@ -1,4 +1,5 @@
{ {
"_untranslated": ["edit.import"],
"list": { "list": {
"tagline": "{{brand}}", "tagline": "{{brand}}",
"empty": "ミンタㇻ タ ネン カ イサㇺ", "empty": "ミンタㇻ タ ネン カ イサㇺ",
@@ -31,6 +32,7 @@
"showExternal": "ソイタㇰ ソㇱ アヌカレ" "showExternal": "ソイタㇰ ソㇱ アヌカレ"
}, },
"edit": { "edit": {
"import": "インポート",
"validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……", "validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……",
"conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!", "conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!",
"conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。", "conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。",
+1
ファイルの表示
@@ -22,6 +22,7 @@
"question": "表示しますか?", "showExternal": "外部ページを表示" "question": "表示しますか?", "showExternal": "外部ページを表示"
}, },
"edit": { "edit": {
"import": "インポート",
"validationFailed": "更新はできなかったよ……", "validationFailed": "更新はできなかったよ……",
"conflictTitle": "競合が発生しました.", "conflictTitle": "競合が発生しました.",
"conflictDescription": "ほかの耕作員が先に更新してゐます.", "conflictDescription": "ほかの耕作員が先に更新してゐます.",
+1 -8
ファイルの表示
@@ -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')}>
+1 -8
ファイルの表示
@@ -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 ()