コミットを比較

..
5 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 688143ccfb AI: address review on PR #462 2026-11-13 12:37:13 +09:00
Codex Night Worker a02037599a AI: address review on PR #462 2026-11-12 19:49:55 +09:00
Codex Night Worker 09df6d740a AI: address review on PR #462 2026-11-12 19:47:49 +09:00
Codex Night Worker df9e98132e AI: address review on PR #462 2026-11-12 19:45:42 +09:00
Codex Night Worker 930817d077 AI: address issue #450 2026-11-12 19:13:32 +09:00
26個のファイルの変更、116行の追加、361行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?) history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?)
q = q =
filtered_posts(locale) filtered_posts(locale)
@@ -57,8 +57,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if needs_history_join if history_join_required
history_times = HistoryAccess.visible(PostVersion.all) history_times = PostVersion.all
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) count_scope = q if updated_between.any?(&:present?)
if needs_history_join if history_join_required
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if needs_history_join if history_join_required
{ } { }
else else
HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id))) PostVersion.where(post_id: posts.map(&:id))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
-1
ファイルの表示
@@ -49,7 +49,6 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id) groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+5 -6
ファイルの表示
@@ -8,12 +8,11 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion) TagDescription.where(language_code: locale.language_code)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc) tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -41,13 +40,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) return head :not_found unless Language.exists?(code: language)
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names) names = TagDescription.joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end end
candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name| candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -411,7 +410,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title def tag_description_by_title locale, title
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names) TagDescription.joins(tag: :tag_names)
.find_by(language_code: locale.language_code, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
-23
ファイルの表示
@@ -147,29 +147,6 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
-1
ファイルの表示
@@ -44,7 +44,6 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
-1
ファイルの表示
@@ -5,7 +5,6 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+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
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
end end
it 'denies the same discarded description through Counts, the facade and the ordinary API' do it 'keeps discarded history private while exposing the current description' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:ok)
expect(response.body).not_to include('private body') expect(response.parsed_body.fetch('body')).to eq(description.body)
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
+8 -10
ファイルの表示
@@ -69,40 +69,38 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses only authorised history timestamps for display' do it 'uses the latest version timestamp for display regardless of role' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + (role == 'admin' ? 4 : 3).days expected = origin + 4.days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end end
it 'uses the same visibility for date bounds and count' do it 'uses the latest version timestamp for date bounds and count regardless of role' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq(role == 'admin' ? [post_record.id] : []) expect(ids).to eq([post_record.id])
expect(response.parsed_body.fetch('count')).to eq(ids.length) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end end
it 'sorts using authorised timestamps in both directions' do it 'sorts using the latest version timestamps in both directions regardless of role' do
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = role == 'admin' ? [other_post.id, post_record.id] : expected = [other_post.id, post_record.id]
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
end end
it 'applies authorised timestamp ordering before pagination' do it 'applies latest version timestamp ordering before pagination regardless of role' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = role == 'admin' ? post_record.id : other_post.id expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id])
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end
+1 -7
ファイルの表示
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
@@ -185,12 +184,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
value={parentPostIds} value={parentPostIds}
disabled={disabled} disabled={disabled}
errors={fieldErrors.parentPostIds} errors={fieldErrors.parentPostIds}
onChange={setParentPostIds} onChange={setParentPostIds}/>
after={<ResourcePickerButton
source={{ resource: 'posts', field: 'resource_id' }}
values={parentPostIds.split (/\s+/).filter (Boolean)}
maximum={Number.MAX_SAFE_INTEGER} disabled={disabled}
onSelect={values => setParentPostIds (values.join (' '))}/>}/>
<PostTagsField <PostTagsField
disabled={disabled} disabled={disabled}
-27
ファイルの表示
@@ -1,27 +0,0 @@
import CandidatePicker from '@/components/common/CandidatePicker'
import { Button } from '@/components/ui/button'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CandidateSource } from '@/components/common/CandidatePicker'
const ResourcePickerButton = (
{ source, values, maximum = 1, disabled, onSelect }: {
source: CandidateSource; values: (string | number | boolean)[]
maximum?: number; disabled?: boolean
onSelect: (values: string[]) => void },
) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('counts')
return <Button type="button" variant="outline" disabled={disabled || maximum === 0}
onClick={() => void dialogue.form ({
title: label ('pattern.link'), size: 'large', footerLayout: 'inline',
body: controls => <CandidatePicker source={source} initial={values}
maximum={maximum} controls={controls} onSelect={onSelect}/> })}>
{label ('pattern.selection')}
</Button>
}
export default ResourcePickerButton
+13 -24
ファイルの表示
@@ -2,10 +2,9 @@ import { useEffect, useState } from 'react'
import CountsConditionValues from '@/components/counts/CountsConditionValues' import CountsConditionValues from '@/components/counts/CountsConditionValues'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel, import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
countsFilterFields, countsSetOperators, useCountsDefinition } countsSetOperators, useCountsDefinition }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { countsConditionValue } from '@/lib/countsDisplay' import { countsConditionValue } from '@/lib/countsDisplay'
@@ -16,7 +15,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState' import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsResource, CountsResult } from '@/lib/counts' import type { CountsResult } from '@/lib/counts'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions' import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
@@ -95,12 +94,10 @@ const ConditionEditor = (
} }
const CountsAdditionalConditions = ( const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
{ resource, params, update, conditionLabels }: { conditionLabels?: CountsResult['conditionLabels']
resource: CountsResource params: URLSearchParams
conditionLabels?: CountsResult['conditionLabels'] update: (key: string, value: string) => void },
params: URLSearchParams
update: (key: string, value: string) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition () const definition = useCountsDefinition ()
@@ -152,19 +149,11 @@ const CountsAdditionalConditions = (
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank} value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
aria-label={aria.text} lang={aria.language} aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/> onChange={value => patch (index, { [key]: value ?? '' })}/>
const source = countsCandidateSource (resource, condition.field, params) return <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
const value = condition[key] type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
return <> className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
<input value={condition[key] == null ? '' : String (condition[key])} aria-label={aria.text} lang={aria.language}
disabled={condition.blank} onChange={event => patch (index, { [key]: event.target.value })}/>
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
{source != null && <ResourcePickerButton source={source} disabled={condition.blank}
values={value == null ? [] : [value]}
onSelect={values => patch (index, { [key]: values[0] ?? '' })}/>}
</>
} }
return <fieldset className="min-w-0 space-y-1"> return <fieldset className="min-w-0 space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')} <legend className="flex items-center gap-2">{label ('additionalConditions')}
@@ -193,7 +182,7 @@ const CountsAdditionalConditions = (
</Button> </Button>
{countsSetOperators.includes (condition.operator) ? ( {countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`} <CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition} resource={resource} condition={condition}
field={metadata.find (field => field.key === condition.field)!} field={metadata.find (field => field.key === condition.field)!}
definition={definition} params={params} definition={definition} params={params}
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) => maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
@@ -10,19 +10,16 @@ import { useUITranslation } from '@/lib/useUITranslation'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsResource } from '@/lib/counts' import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
import type { CountsCandidate } from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
type CandidateSource = { const CountsCandidatePicker = (
resource: CountsResource; field: string; params?: URLSearchParams } { field, definition, params, initial, maximum, controls, save }: {
field: CountsFieldMetadata; params: URLSearchParams
const CandidatePicker = ( definition: CountsDefinition
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
source?: CandidateSource; choices?: CountsCandidate[]
categories?: { value: string; label: string }[]
initial: (string | number | boolean)[]; maximum: number initial: (string | number | boolean)[]; maximum: number
controls: DialogueFormControls; onSelect: (values: string[]) => void }, controls: DialogueFormControls; save: (values: string[]) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const locale = useLocale () const locale = useLocale ()
@@ -32,58 +29,49 @@ const CandidatePicker = (
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
const [category, setCategory] = useState ('') const [category, setCategory] = useState ('')
const [page, setPage] = useState (1) const [page, setPage] = useState (1)
const params = source?.params ?? new URLSearchParams ()
const point = useRef (params.get ('execution_at')) const point = useRef (params.get ('execution_at'))
const resource = source?.resource ?? 'posts' const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
const request = new URLSearchParams ({
field: source?.field ?? '', query: search, page: String (page) })
for (const key of ['timezone', 'at', 'execution_at']) for (const key of ['timezone', 'at', 'execution_at'])
if (params.has (key)) request.set (key, params.get (key)!) if (params.has (key)) request.set (key, params.get (key)!)
if (countsUseLatest (params)) request.delete ('at') if (countsUseLatest (params)) request.delete ('at')
if (category) request.set ('category', category) if (category) request.set ('category', category)
const dynamic = source != null const dynamic = field.candidateSource === 'tags'
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({ const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
queryKey: countsKeys.candidates (resource, request.toString (), locale), enabled: dynamic, queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
const pinned = new URLSearchParams (request) const pinned = new URLSearchParams (request)
if (point.current != null) pinned.set ('execution_at', point.current) if (point.current != null) pinned.set ('execution_at', point.current)
return fetchCountsCandidates (resource, pinned, locale, signal) return fetchCountsCandidates (pinned, locale, signal)
}, retry: false, staleTime: 0, gcTime: 0 }) }, retry: false })
useEffect (() => { useEffect (() => {
if (data != null) point.current = data.executionAt if (data != null) point.current = data.executionAt
}, [data]) }, [data])
useEffect (() => { useEffect (() => {
controls.setActions ([{ label: label ('counts:pattern.selection'), controls.setActions ([{ label: label ('counts:pattern.selection'),
onSelect: () => onSelect ([...selected]) }]) onSelect: () => save ([...selected]) }])
}, [controls, label, onSelect, selected]) }, [controls, label, save, selected])
const rows = dynamic ? data?.rows ?? [] : choices.filter (row => const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
row.name?.includes (search)) row.name?.includes (search))
const headings: Record<string, string> = { const headings: Record<string, string> = {
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel', id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
kind: 'columns.kind' } kind: 'columns.kind' }
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '') const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
const available =
dynamic
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
const visible = available.filter (column => columns.includes (column)) const visible = available.filter (column => columns.includes (column))
const categories = definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices ?? []
const cell = (row: CountsCandidate, column: string) => { const cell = (row: CountsCandidate, column: string) => {
if (column === 'category') if (column === 'category') return row.category == null ? ''
return row.category == null ? '' : message (`common:categories.${row.category}`).text : message (`common:categories.${row.category}`).text
if (column === 'kind') if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
{
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
return row.kind == null ? '' : message (key).text
}
return column === 'id' ? row.id : row.name return column === 'id' ? row.id : row.name
} }
return <div className="min-w-0 space-y-2"> return <div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<input className={inputClass (false, 'h-9 min-w-0 flex-1')} <input className={inputClass (false, 'h-9 min-w-0 flex-1')}
aria-label={message ('common:search').text} value={draft} maxLength={1000} aria-label={message ('common:search').text} value={draft}
onChange={event => { onChange={event => setDraft (event.target.value)}
const value = event.target.value
if (new TextEncoder ().encode (value).length <= 1000) setDraft (value)
}}
onKeyDown={event => { onKeyDown={event => {
if (event.key !== 'Enter') return if (event.key !== 'Enter') return
event.preventDefault () event.preventDefault ()
@@ -93,7 +81,7 @@ const CandidatePicker = (
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}> <Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
{label ('common:search')}</Button> {label ('common:search')}</Button>
</div> </div>
{categories.length > 0 && <select className={inputClass (false, 'h-9 w-full')} {dynamic && <select className={inputClass (false, 'h-9 w-full')}
aria-label={message ('common:categoryLabel').text} value={category} aria-label={message ('common:categoryLabel').text} value={category}
onChange={event => { setCategory (event.target.value); setPage (1) }}> onChange={event => { setCategory (event.target.value); setPage (1) }}>
<option value=""/> <option value=""/>
@@ -120,13 +108,12 @@ const CandidatePicker = (
{visible.map (column => <th key={column} className="p-2"> {visible.map (column => <th key={column} className="p-2">
{label (headings[column])}</th>)} {label (headings[column])}</th>)}
</tr></thead> </tr></thead>
<tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`} <tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
className="border-t dark:border-slate-700">
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)} <td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
aria-label={row.name ?? String (row.id ?? '')} aria-label={row.name ?? String (row.id ?? '')}
disabled={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum} disabled={!selected.has (row.value) && selected.size >= maximum}
onChange={event => setSelected (previous => { onChange={event => setSelected (previous => {
const next = new Set (maximum === 1 ? [] : previous) const next = new Set (previous)
if (event.target.checked) next.add (row.value) if (event.target.checked) next.add (row.value)
else next.delete (row.value) else next.delete (row.value)
return next return next
@@ -141,8 +128,7 @@ const CandidatePicker = (
onClick={() => setPage (previous => previous - 1)}> onClick={() => setPage (previous => previous - 1)}>
{label ('common:pagination.previous')}</Button> {label ('common:pagination.previous')}</Button>
<span>{page}</span> <span>{page}</span>
<Button type="button" variant="outline" <Button type="button" variant="outline" disabled={!data?.more || fetching}
disabled={!data?.more || page >= 100_000 || fetching}
onClick={() => setPage (previous => previous + 1)}> onClick={() => setPage (previous => previous + 1)}>
{label ('common:pagination.next')}</Button> {label ('common:pagination.next')}</Button>
</div>} </div>}
@@ -150,5 +136,4 @@ const CandidatePicker = (
} }
export default CandidatePicker export default CountsCandidatePicker
export type { CandidateSource }
+4 -20
ファイルの表示
@@ -1,22 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import CandidatePicker from '@/components/common/CandidatePicker' import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsCandidateSource } from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource } from '@/lib/counts'
import type { CountsCondition, CountsDefinition, CountsFieldMetadata } import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
const CountsConditionValues = ( const CountsConditionValues = (
{ resource, condition, field, definition, params, maximum, change }: { { condition, field, definition, params, maximum, change }: {
resource: CountsResource
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
params: URLSearchParams; maximum: number params: URLSearchParams; maximum: number
change: (values: (string | number | boolean)[]) => void }, change: (values: (string | number | boolean)[]) => void },
@@ -25,21 +21,11 @@ const CountsConditionValues = (
const dialogue = useDialogue () const dialogue = useDialogue ()
const [draft, setDraft] = useState ('') const [draft, setDraft] = useState ('')
const values = condition.values ?? [] const values = condition.values ?? []
const source = countsCandidateSource (resource, field.key, params)
const pick = () => { const pick = () => {
void dialogue.form ({ title: label (field.candidateSource === 'tags' void dialogue.form ({ title: label (field.candidateSource === 'tags'
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline', ? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
body: controls => <CandidatePicker body: controls => <CountsCandidatePicker field={field} definition={definition}
source={ params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
field.candidateSource === 'tags'
? { resource: 'posts', field: field.key, params } : undefined}
choices={(field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))}
categories={
field.candidateSource === 'tags'
? definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices : undefined}
initial={values} maximum={maximum} controls={controls} onSelect={change}/> })
} }
if (condition.input === 'list' || field.candidateSource === 'tags') if (condition.input === 'list' || field.candidateSource === 'tags')
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}> return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
@@ -87,8 +73,6 @@ const CountsConditionValues = (
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button> onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
</span>)} </span>)}
{input ()} {input ()}
{source != null && <ResourcePickerButton source={source} values={values}
maximum={maximum} disabled={condition.blank} onSelect={change}/>}
<Button type="button" variant="outline" disabled={condition.blank || draft === '' <Button type="button" variant="outline" disabled={condition.blank || draft === ''
|| values.length >= maximum} onClick={append}>{label ('add')}</Button> || values.length >= maximum} onClick={append}>{label ('add')}</Button>
<Button type="button" variant="ghost" disabled={condition.blank} <Button type="button" variant="ghost" disabled={condition.blank}
+6 -13
ファイルの表示
@@ -7,7 +7,6 @@ import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover' import HelpPopover from '@/components/common/HelpPopover'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort, import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
@@ -224,17 +223,11 @@ const CountsControls = (
{['resource_id', 'user_id'].map (key => <FormField key={key} {['resource_id', 'user_id'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId' label={label (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor')}> : 'fields.actor')}>
{() => <div className="flex flex-wrap items-center gap-1"> {() => <input value={draft.get (key) ?? ''}
<input value={draft.get (key) ?? ''} aria-label={message (key === 'resource_id' ? 'columns.resourceId'
aria-label={message (key === 'resource_id' ? 'columns.resourceId' : 'fields.actor').text}
: 'fields.actor').text} className={inputClass (false, 'w-28 py-1')}
className={inputClass (false, 'w-28 py-1')} onChange={event => update (key, event.target.value)}/>}
onChange={event => update (key, event.target.value)}/>
<ResourcePickerButton source={{ resource,
field: key === 'resource_id' ? key : 'user', params: draft }}
values={draft.get (key) ? [draft.get (key)!] : []}
onSelect={values => update (key, values[0] ?? '')}/>
</div>}
</FormField>)} </FormField>)}
</div> </div>
{resource === 'posts' && <FormField label={label ('columns.tags')}> {resource === 'posts' && <FormField label={label ('columns.tags')}>
@@ -270,7 +263,7 @@ const CountsControls = (
</>} </>}
</div>} </div>}
</FormField>} </FormField>}
<CountsAdditionalConditions resource={resource} params={draft} update={update} <CountsAdditionalConditions params={draft} update={update}
conditionLabels={(labelParams != null conditionLabels={(labelParams != null
&& draft.get ('current') === labelParams.get ('current') && draft.get ('current') === labelParams.get ('current')
&& draft.get ('at') === labelParams.get ('at') && draft.get ('at') === labelParams.get ('at')
+2 -4
ファイルの表示
@@ -227,13 +227,11 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<DialogueProvider key={active.id}> {active.options.body (
{active.options.body ( formControls.current[active.id] ??= {
formControls.current[active.id] ??= {
close: () => closeRequest (active.id), close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions), setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })} confirm: options => openNestedConfirm (active.id, options) })}
</DialogueProvider>
</div> </div>
<DialogFooter <DialogFooter
-10
ファイルの表示
@@ -70,16 +70,6 @@ describe ('SidebarComponent', () => {
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
}) })
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
renderSidebar ()
// jsdom has no layout; guard the responsive classes responsible for clipping.
const sidebar = screen.getByText ('Sidebar body')
expect (sidebar).not.toHaveClass ('overflow-y-auto')
expect (sidebar).toHaveClass ('md:overflow-y-auto')
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => { it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700') localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn () const onWidthChange = vi.fn ()
+1 -1
ファイルの表示
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
</style> </style>
</Helmet> </Helmet>
<div className="h-full md:overflow-y-auto p-4 sidebar"> <div className="h-full overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
+1 -7
ファイルの表示
@@ -1,4 +1,3 @@
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField' import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField' import PostTextField from '@/components/posts/PostTextField'
@@ -89,12 +88,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
disabled={parentPostIds.disabled} disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings} warnings={parentPostIds.warnings}
errors={parentPostIds.errors} errors={parentPostIds.errors}
onChange={parentPostIds.onChange} onChange={parentPostIds.onChange}/>
after={<ResourcePickerButton
source={{ resource: 'posts', field: 'resource_id' }}
values={parentPostIds.value.split (/\s+/).filter (Boolean)}
maximum={Number.MAX_SAFE_INTEGER} disabled={parentPostIds.disabled}
onSelect={values => parentPostIds.onChange (values.join (' '))}/>}/>
</>) </>)
export default PostCoreDataFields export default PostCoreDataFields
+12 -16
ファイルの表示
@@ -34,22 +34,18 @@ const PostTextField: FC<Props> = (
<FormField label={label} messages={errors}> <FormField label={label} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="flex items-center gap-2"> <input
<div className="min-w-0 flex-1"> type={type}
<input value={value}
type={type} disabled={disabled}
value={value} placeholder={placeholder}
disabled={disabled} onBlur={onBlur}
placeholder={placeholder} onChange={ev => onChange (ev.target.value)}
onBlur={onBlur} aria-describedby={describedBy}
onChange={ev => onChange (ev.target.value)} aria-invalid={invalid}
aria-describedby={describedBy} className={inputClass (invalid, className)}/>
aria-invalid={invalid} <FieldWarning messages={warnings}/>
className={inputClass (invalid, className)}/> {after}
</div>
{after}
</div>
<FieldWarning messages={warnings}/>
</>)} </>)}
</FormField>) </FormField>)
+3 -26
ファイルの表示
@@ -89,19 +89,18 @@ export const fetchCountsDefinition = (
.then (definition => localiseCountsDefinition (definition, locale)) .then (definition => localiseCountsDefinition (definition, locale))
export type CountsCandidate = { export type CountsCandidate = {
value: string; id?: number | string; name: string | null; category?: string | null value: string; id?: number; name: string | null; category?: string | null
names?: Record<string, unknown> names?: Record<string, unknown>
kind?: 'internal' | 'external' | 'group' } kind?: 'internal' | 'external' }
export type CountsCandidatesResult = { export type CountsCandidatesResult = {
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string } rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
export const fetchCountsCandidates = ( export const fetchCountsCandidates = (
resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
locale: LocaleCode, locale: LocaleCode,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<CountsCandidatesResult> => apiGet ( ): Promise<CountsCandidatesResult> => apiGet (
`/counts/definitions/${encodeURIComponent (resource)}/candidates`, { '/counts/definitions/posts/candidates', {
params: Object.fromEntries (params), locale, signal }) params: Object.fromEntries (params), locale, signal })
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none'] export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
@@ -156,25 +155,3 @@ export const countsAxisFields = (
history: boolean, history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest'] ): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) })) .map (key => ({ key, label: countsFieldLabel (key) }))
export const countsCandidateSource = (
resource: CountsResource,
field: string,
params: URLSearchParams,
): { resource: CountsResource; field: string; params: URLSearchParams } | undefined => {
if (['resource_id', 'user', 'updated_by_user_id', 'file_blob_id', 'source_file_id']
.includes (field)) return { resource, field, params }
if (field === 'user.id') return { resource, field: 'user', params }
if ((field === 'tags.id' && resource === 'posts')
|| (field === 'members' && resource === 'tag_groups'))
return { resource, field, params }
const leaf = field.split ('.').at (-1) ?? ''
const targets: Record<string, CountsResource> = {
tagGroupId: 'tag_groups', tagId: 'tags', tag_id: 'tags', parent_id: 'materials',
parents: resource === 'posts' ? 'posts' : 'tags',
'parents.id': resource === 'posts' ? 'posts' : 'tags',
parent_post_ids: 'posts', parent_tag_ids: 'tags' }
const target = targets[field] ?? targets[leaf]
return target == null ? undefined : { resource: target, field: 'resource_id', params }
}
+1 -2
ファイルの表示
@@ -174,7 +174,6 @@ export const theatresKeys = {
export const countsKeys = { export const countsKeys = {
definition: (resource: string, locale: LocaleCode = 'ja') => definition: (resource: string, locale: LocaleCode = 'ja') =>
['counts', 'definition', resource, locale] as const, ['counts', 'definition', resource, locale] as const,
candidates: (resource: string, params: string, locale: LocaleCode) => candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
['counts', 'candidates', resource, locale, params] as const,
index: (resource: string, params: string, locale: LocaleCode) => index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const } ['counts', resource, locale, params] as const }
+2 -9
ファイルの表示
@@ -6,7 +6,6 @@ import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
@@ -97,19 +96,13 @@ const MaterialHistoryPage: FC = () => {
<div className="space-y-3"> <div className="space-y-3">
<FormField label={label ('materialId')}> <FormField label={label ('materialId')}>
{({ invalid }) => ( {({ invalid }) => (
<div className="flex items-center gap-2"> <input
<input
type="text" type="text"
value={materialIdInput} value={materialIdInput}
aria-label={message ('materialId').text} aria-label={message ('materialId').text}
lang={message ('materialId').language} lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)} onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid, 'min-w-0 flex-1')}/> className={inputClass (invalid)}/>)}
<ResourcePickerButton
source={{ resource: 'materials', field: 'resource_id' }}
values={materialIdInput === '' ? [] : [materialIdInput]}
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
</div>)}
</FormField> </FormField>
<FormField label={label ('tagName')}> <FormField label={label ('tagName')}>
+2 -9
ファイルの表示
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
@@ -127,17 +126,11 @@ const MaterialSyncSuppressionsPage: FC = () => {
<FormField label={label ('suppressions.driveFileId')}> <FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => ( {({ invalid }) => (
<div className="flex items-center gap-2"> <input
<input
type="text" type="text"
value={driveFileId} value={driveFileId}
onChange={e => setDriveFileId (e.target.value)} onChange={e => setDriveFileId (e.target.value)}
className={inputClass (invalid, 'min-w-0 flex-1')}/> className={inputClass (invalid)}/>)}
<ResourcePickerButton
source={{ resource: 'materials', field: 'drive_file_id' }}
values={driveFileId === '' ? [] : [driveFileId]}
onSelect={values => setDriveFileId (values[0] ?? '')}/>
</div>)}
</FormField> </FormField>
<FormField label={label ('suppressions.reason')}> <FormField label={label ('suppressions.reason')}>
+2 -9
ファイルの表示
@@ -35,10 +35,6 @@ const renderPage = () =>
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
tagsApi.fetchTag.mockReset ()
api.apiPut.mockReset ()
api.isApiError.mockReset ()
toastApi.toast.mockReset ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
}) })
@@ -55,11 +51,8 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] }) tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
tagsApi.fetchTag api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()