コミットを比較

..
4 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 3232c57cc4 AI: address review on PR #476 2026-11-14 18:03:05 +09:00
みてるぞ 084bc95102 Merge branch 'main' into feature/447 2026-11-14 17:59:39 +09:00
テツノニジカとCodex Night Worker e13a52db9a AI: #454 広場のタグ検索,スマホ版だとサゼストが途中で見切れる問題 (#456)
Refs #454

## Automated implementation (requires human review)
[SidebarComponent.tsx](frontend/src/components/layout/SidebarComponent.tsx) の1行を修正しました。`overflow-y-auto` を `md` 以上に限定し、スマホでサゼストがサイドバーの領域外で切られる原因を取り除きました。

差分を確認済みです。指定に従い、テスト・lint・ビルドは未実行です。実画面でのスマホ表示とPC表示の確認も未実施です。

## Automated review (not a guarantee of correctness)
```text
The change restricts sidebar scrolling to medium and larger viewports. No actionable regression was identified in the current diff.

```

## Verification
- Tests/builds/migrations were not authorized and were not invoked by the controller.
- Codex was instructed not to run them; its command transcript should be inspected.
- Do not merge before manual acceptance and required checks.

---------

Co-authored-by: Codex Night Worker <codex-night@localhost>
Reviewed-on: #456
Co-authored-by: テツノニジカ <969+codex-bot@noreply.localhost>
2026-11-14 17:09:38 +09:00
Codex Night Worker 225f55a3b9 AI: address issue #447 2026-11-14 00:47:58 +09:00
28個のファイルの変更、310行の追加、279行の削除
-1
ファイルの表示
@@ -5,7 +5,6 @@ class MaterialVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-1
ファイルの表示
@@ -14,7 +14,6 @@ class MaterialsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-1
ファイルの表示
@@ -15,7 +15,6 @@ class NicoTagsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
post_tag_max_sql = post_tag_max_sql =
PostExternalTag PostExternalTag
-1
ファイルの表示
@@ -11,7 +11,6 @@ class PostVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-1
ファイルの表示
@@ -34,7 +34,6 @@ class PostsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-1
ファイルの表示
@@ -7,7 +7,6 @@ class TagVersionsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-1
ファイルの表示
@@ -33,7 +33,6 @@ class TagsController < ApplicationController
page = 1 if page < 1 page = 1 if page < 1
limit = 1 if limit < 1 limit = 1 if limit < 1
limit = 100 if limit > 100
offset = (page - 1) * limit offset = (page - 1) * limit
-2
ファイルの表示
@@ -2,7 +2,6 @@ class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 20 if limit <= 0 limit = 20 if limit <= 0
limit = 100 if limit > 100
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt < 0 no_gt = 0 if no_gt < 0
@@ -10,7 +9,6 @@ class TheatreCommentsController < ApplicationController
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.includes(:user)
.order(no: :desc) .order(no: :desc)
.limit(limit) .limit(limit)
+1 -2
ファイルの表示
@@ -2,7 +2,6 @@ class TheatreProgrammesController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 100 if limit <= 0 limit = 100 if limit <= 0
limit = 100 if limit > 100
position_gt = params[:position_gt].to_i position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0 position_gt = 0 if position_gt < 0
@@ -11,7 +10,7 @@ class TheatreProgrammesController < ApplicationController
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt) .where('position > ?', position_gt)
.includes(:post) .includes(:post)
.order(position: :desc) .order(position: :desc).limit(100)
.limit(limit) .limit(limit)
render json: programmes.map { |programme| render json: programmes.map { |programme|
-1
ファイルの表示
@@ -2,7 +2,6 @@ class TheatreSkipEventsController < ApplicationController
def index def index
limit = params[:limit].to_i limit = params[:limit].to_i
limit = 50 if limit <= 0 limit = 50 if limit <= 0
limit = 100 if limit > 100
events = events =
TheatreSkipEvent TheatreSkipEvent
+79 -12
ファイルの表示
@@ -6,8 +6,14 @@ class CountsCandidates
def initialize(params) def initialize(params)
QueryState.validate!(params, KEYS) QueryState.validate!(params, KEYS)
raise ArgumentError unless params.fetch('resource') == 'posts' definition = CountsResourceDefinition.new(params.fetch('resource'))
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field')) fields = ['resource_id', 'user']
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)
@@ -18,25 +24,27 @@ 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)
external = CountsPopulation.new(options.merge('resource' => 'nico_tags', groups_flg = @params['field'] == 'members'
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, 'external', 'NULL', 'JSON_OBJECT()']] [external, groups_flg ? 'group' : '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)
if @params['query'].present? identity = "v.#{ population.definition.keys.first }"
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%" scope = search(scope, identity, 'v.name', names)
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" + value = ['tags.id', 'members'].include?(@params['field']) ?
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " + "CAST(#{ identity } AS CHAR)" : "CONCAT('#{ kind }:', #{ identity })"
"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(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " + "#{ value } AS value, #{ identity } 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
@@ -55,4 +63,63 @@ 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
-174
ファイルの表示
@@ -1,174 +0,0 @@
require 'rails_helper'
RSpec.describe 'List API limits', type: :request do
shared_examples 'a capped list' do |default_count, minimum_count, paginated|
def response_rows
body = json
body.is_a?(Array) ? body : body.fetch(collection_key)
end
it 'caps oversized limits at 100 and preserves the 100 boundary' do
get path, params: { locale: 'ja', limit: 100 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(100)
boundary_rows = response_rows
[101, 10_000].each do |limit|
get path, params: { locale: 'ja', limit: }
expect(response).to have_http_status(:ok)
expect(response_rows).to eq(boundary_rows)
end
end
it 'preserves the default, nonpositive and small positive limits' do
get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(default_count)
[0, -1].each do |limit|
get path, params: { locale: 'ja', limit: }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(minimum_count)
end
get path, params: { locale: 'ja', limit: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(2)
end
if paginated
it 'uses the capped limit for page offsets and preserves total count' do
get path, params: { locale: 'ja', limit: 100 }
first_page = response_rows
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(101)
get path, params: { locale: 'ja', limit: 100, page: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows.size).to eq(1)
second_page = response_rows
expect(first_page & second_page).to be_empty
get path, params: { locale: 'ja', limit: 101, page: 2 }
expect(response).to have_http_status(:ok)
expect(response_rows).to eq(second_page)
expect(json.fetch('count')).to eq(101)
end
end
end
context 'posts' do
let(:path) { '/posts' }
let(:collection_key) { 'posts' }
before { create_list(:post, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'tags' do
let(:path) { '/tags' }
let(:collection_key) { 'tags' }
before { create_list(:tag, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'nico tags' do
let(:path) { '/tags/nico' }
let(:collection_key) { 'tags' }
before { create_list(:external_tag, 101) }
include_examples 'a capped list', 20, 1, true
end
context 'materials' do
let(:path) { '/materials' }
let(:collection_key) { 'materials' }
before do
101.times { |index| Material.create!(url: "https://example.com/material-#{ index }") }
end
include_examples 'a capped list', 20, 1, true
end
context 'post versions' do
let(:path) { '/posts/versions' }
let(:collection_key) { 'versions' }
let(:post) { create(:post) }
before do
101.times do |index|
PostVersion.create!(post:, version_no: index + 1, event_type: 'update',
url: post.url, tags: '', tags_json: [], parent_post_ids: '')
end
end
include_examples 'a capped list', 20, 1, true
end
context 'tag versions' do
let(:path) { '/tags/versions' }
let(:collection_key) { 'versions' }
let(:tag) { create(:tag) }
before do
101.times do |index|
TagVersion.create!(tag:, version_no: index + 1, event_type: 'update',
name: 'limit tag', category: 'general', aliases: '',
parent_tag_ids: '', names: {
ja: { name: 'limit tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } })
end
end
include_examples 'a capped list', 20, 1, true
end
context 'material versions' do
let(:path) { '/materials/versions' }
let(:collection_key) { 'versions' }
let(:material) { Material.create!(url: 'https://example.com/material') }
before do
101.times do |index|
MaterialVersion.create!(material:, version_no: index + 1,
event_type: 'update', url: material.url)
end
end
include_examples 'a capped list', 20, 1, true
end
context 'theatre lists' do
let(:theatre) { create(:theatre) }
let(:post) { create(:post) }
context 'comments' do
let(:path) { "/theatres/#{ theatre.id }/comments" }
before { create_list(:theatre_comment, 101, theatre:, user: theatre.created_by_user) }
include_examples 'a capped list', 20, 20, false
end
context 'programmes' do
let(:path) { "/theatres/#{ theatre.id }/programmes" }
before do
101.times do |index|
TheatreProgramme.create!(theatre:, post:, position: index + 1)
end
end
include_examples 'a capped list', 100, 100, false
end
context 'skip events' do
let(:path) { "/theatres/#{ theatre.id }/skip_events" }
before do
101.times do |index|
TheatreSkipEvent.create!(theatre:, post:,
skipped_by_user: theatre.created_by_user,
created_at: Time.utc(2026, 1, 1) + index.seconds)
end
end
include_examples 'a capped list', 50, 50, false
end
end
end
+7 -1
ファイルの表示
@@ -1,5 +1,6 @@
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'
@@ -184,7 +185,12 @@ 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}
@@ -10,16 +10,19 @@ 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 { CountsCandidate, CountsDefinition, CountsFieldMetadata } import type { CountsResource } from '@/lib/counts'
from '@/lib/countsDefinitions' import type { CountsCandidate } from '@/lib/countsDefinitions'
const CountsCandidatePicker = ( type CandidateSource = {
{ field, definition, params, initial, maximum, controls, save }: { resource: CountsResource; field: string; params?: URLSearchParams }
field: CountsFieldMetadata; params: URLSearchParams
definition: CountsDefinition const CandidatePicker = (
{ 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; save: (values: string[]) => void }, controls: DialogueFormControls; onSelect: (values: string[]) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const locale = useLocale () const locale = useLocale ()
@@ -29,49 +32,58 @@ const CountsCandidatePicker = (
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 request = new URLSearchParams ({ field: field.key, query: search, page: String (page) }) const resource = source?.resource ?? 'posts'
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 = field.candidateSource === 'tags' const dynamic = source != null
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({ const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic, queryKey: countsKeys.candidates (resource, 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 (pinned, locale, signal) return fetchCountsCandidates (resource, pinned, locale, signal)
}, retry: false }) }, retry: false, staleTime: 0, gcTime: 0 })
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: () => save ([...selected]) }]) onSelect: () => onSelect ([...selected]) }])
}, [controls, label, save, selected]) }, [controls, label, onSelect, selected])
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({ const rows = dynamic ? data?.rows ?? [] : choices.filter (row =>
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 available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name'] const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '')
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') return row.category == null ? '' if (column === 'category')
: message (`common:categories.${row.category}`).text return row.category == null ? '' : message (`common:categories.${row.category}`).text
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text if (column === 'kind')
{
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} aria-label={message ('common:search').text} value={draft} maxLength={1000}
onChange={event => setDraft (event.target.value)} onChange={event => {
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 ()
@@ -81,7 +93,7 @@ const CountsCandidatePicker = (
<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>
{dynamic && <select className={inputClass (false, 'h-9 w-full')} {categories.length > 0 && <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=""/>
@@ -108,12 +120,13 @@ const CountsCandidatePicker = (
{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.value} className="border-t dark:border-slate-700"> <tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`}
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={!selected.has (row.value) && selected.size >= maximum} disabled={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum}
onChange={event => setSelected (previous => { onChange={event => setSelected (previous => {
const next = new Set (previous) const next = new Set (maximum === 1 ? [] : 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
@@ -128,7 +141,8 @@ const CountsCandidatePicker = (
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" disabled={!data?.more || fetching} <Button type="button" variant="outline"
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>}
@@ -136,4 +150,5 @@ const CountsCandidatePicker = (
} }
export default CountsCandidatePicker export default CandidatePicker
export type { CandidateSource }
+27
ファイルの表示
@@ -0,0 +1,27 @@
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
+17 -6
ファイルの表示
@@ -2,9 +2,10 @@ 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 { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields, import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel,
countsSetOperators, useCountsDefinition } countsFilterFields, 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'
@@ -15,7 +16,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState' import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsResult } from '@/lib/counts' import type { CountsResource, 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'
@@ -94,7 +95,9 @@ const ConditionEditor = (
} }
const CountsAdditionalConditions = ({ params, update, conditionLabels }: { const CountsAdditionalConditions = (
{ 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 },
@@ -149,11 +152,19 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
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 ?? '' })}/>
return <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank} const source = countsCandidateSource (resource, condition.field, params)
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')}
@@ -182,7 +193,7 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
</Button> </Button>
{countsSetOperators.includes (condition.operator) ? ( {countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`} <CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition} condition={condition} resource={resource}
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) =>
+20 -4
ファイルの表示
@@ -1,18 +1,22 @@
import { useState } from 'react' import { useState } from 'react'
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker' import CandidatePicker from '@/components/common/CandidatePicker'
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 = (
{ condition, field, definition, params, maximum, change }: { { resource, 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 },
@@ -21,11 +25,21 @@ 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 => <CountsCandidatePicker field={field} definition={definition} body: controls => <CandidatePicker
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> }) source={
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}>
@@ -73,6 +87,8 @@ 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}
+10 -3
ファイルの表示
@@ -7,6 +7,7 @@ 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,
@@ -223,11 +224,17 @@ 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')}>
{() => <input value={draft.get (key) ?? ''} {() => <div className="flex flex-wrap items-center gap-1">
<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')}>
@@ -263,7 +270,7 @@ const CountsControls = (
</>} </>}
</div>} </div>}
</FormField>} </FormField>}
<CountsAdditionalConditions params={draft} update={update} <CountsAdditionalConditions resource={resource} 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,11 +227,13 @@ 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,6 +70,16 @@ 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 overflow-y-auto p-4 sidebar"> <div className="h-full md:overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
+7 -1
ファイルの表示
@@ -1,3 +1,4 @@
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'
@@ -88,7 +89,12 @@ 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
+5 -1
ファイルの表示
@@ -34,6 +34,8 @@ 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}
@@ -44,8 +46,10 @@ const PostTextField: FC<Props> = (
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid, className)}/> className={inputClass (invalid, className)}/>
<FieldWarning messages={warnings}/> </div>
{after} {after}
</div>
<FieldWarning messages={warnings}/>
</>)} </>)}
</FormField>) </FormField>)
+26 -3
ファイルの表示
@@ -89,18 +89,19 @@ export const fetchCountsDefinition = (
.then (definition => localiseCountsDefinition (definition, locale)) .then (definition => localiseCountsDefinition (definition, locale))
export type CountsCandidate = { export type CountsCandidate = {
value: string; id?: number; name: string | null; category?: string | null value: string; id?: number | string; name: string | null; category?: string | null
names?: Record<string, unknown> names?: Record<string, unknown>
kind?: 'internal' | 'external' } kind?: 'internal' | 'external' | 'group' }
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/posts/candidates', { `/counts/definitions/${encodeURIComponent (resource)}/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']
@@ -155,3 +156,25 @@ 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 }
}
+2 -1
ファイルの表示
@@ -174,6 +174,7 @@ 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: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const, candidates: (resource: string, params: string, locale: LocaleCode) =>
['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 }
+8 -1
ファイルの表示
@@ -6,6 +6,7 @@ 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'
@@ -96,13 +97,19 @@ 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)}/>)} className={inputClass (invalid, 'min-w-0 flex-1')}/>
<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')}>
+8 -1
ファイルの表示
@@ -4,6 +4,7 @@ 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'
@@ -126,11 +127,17 @@ 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)}/>)} className={inputClass (invalid, 'min-w-0 flex-1')}/>
<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')}>
+9 -2
ファイルの表示
@@ -35,6 +35,10 @@ 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)
}) })
@@ -51,8 +55,11 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' })) const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] })
api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] })) tagsApi.fetchTag
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()