From 225f55a3b927b6028ad136479981832130ea3a21 Mon Sep 17 00:00:00 2001 From: Codex Night Worker Date: Sat, 14 Nov 2026 00:47:58 +0900 Subject: [PATCH] AI: address issue #447 --- backend/app/services/counts_candidates.rb | 91 ++++++++++++++++--- frontend/src/components/PostEditForm.tsx | 8 +- .../CandidatePicker.tsx} | 77 +++++++++------- .../common/ResourcePickerButton.tsx | 27 ++++++ .../counts/CountsAdditionalConditions.tsx | 37 +++++--- .../counts/CountsConditionValues.tsx | 24 ++++- .../src/components/counts/CountsControls.tsx | 19 ++-- .../components/dialogues/DialogueProvider.tsx | 6 +- .../components/posts/PostCoreDataFields.tsx | 8 +- frontend/src/lib/countsDefinitions.ts | 29 +++++- frontend/src/lib/queryKeys.ts | 3 +- .../pages/materials/MaterialHistoryPage.tsx | 11 ++- .../MaterialSyncSuppressionsPage.tsx | 11 ++- 13 files changed, 273 insertions(+), 78 deletions(-) rename frontend/src/components/{counts/CountsCandidatePicker.tsx => common/CandidatePicker.tsx} (66%) create mode 100644 frontend/src/components/common/ResourcePickerButton.tsx diff --git a/backend/app/services/counts_candidates.rb b/backend/app/services/counts_candidates.rb index ded9d1d..5e800ad 100644 --- a/backend/app/services/counts_candidates.rb +++ b/backend/app/services/counts_candidates.rb @@ -6,8 +6,14 @@ class CountsCandidates def initialize(params) QueryState.validate!(params, KEYS) - raise ArgumentError unless params.fetch('resource') == 'posts' - raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field')) + definition = CountsResourceDefinition.new(params.fetch('resource')) + 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 @page = Integer(params.fetch('page', '1'), 10) @@ -18,25 +24,27 @@ class CountsCandidates end 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') .merge('definition' => 'latest', 'resource' => 'tags') 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))) connection = TagVersion.connection 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| scope = population.scope.except(:select) - if @params['query'].present? - pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%" - scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id 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 + identity = "v.#{ population.definition.keys.first }" + scope = search(scope, identity, 'v.name', names) + value = ['tags.id', 'members'].include?(@params['field']) ? + "CAST(#{ identity } AS CHAR)" : "CONCAT('#{ kind }:', #{ identity })" 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")) .to_sql end @@ -55,4 +63,63 @@ class CountsCandidates { rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT, execution_at: internal.at.iso8601(6) } 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 diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 99039d9..5b3ced6 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' +import ResourcePickerButton from '@/components/common/ResourcePickerButton' import { useLocale } from '@/lib/useLocale' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import FieldError from '@/components/common/FieldError' @@ -184,7 +185,12 @@ const PostEditForm: FC = ({ post, onSave }) => { value={parentPostIds} disabled={disabled} errors={fieldErrors.parentPostIds} - onChange={setParentPostIds}/> + onChange={setParentPostIds} + after={ setParentPostIds (values.join (' '))}/>}/> void }, + controls: DialogueFormControls; onSelect: (values: string[]) => void }, ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () @@ -29,49 +32,58 @@ const CountsCandidatePicker = ( const [search, setSearch] = useState ('') const [category, setCategory] = useState ('') const [page, setPage] = useState (1) + const params = source?.params ?? new URLSearchParams () 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']) if (params.has (key)) request.set (key, params.get (key)!) if (countsUseLatest (params)) request.delete ('at') if (category) request.set ('category', category) - const dynamic = field.candidateSource === 'tags' + const dynamic = source != null 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 }) => { const pinned = new URLSearchParams (request) if (point.current != null) pinned.set ('execution_at', point.current) - return fetchCountsCandidates (pinned, locale, signal) - }, retry: false }) + return fetchCountsCandidates (resource, pinned, locale, signal) + }, retry: false, staleTime: 0, gcTime: 0 }) useEffect (() => { if (data != null) point.current = data.executionAt }, [data]) useEffect (() => { controls.setActions ([{ label: label ('counts:pattern.selection'), - onSelect: () => save ([...selected]) }]) - }, [controls, label, save, selected]) - const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({ - value: choice.value, name: message (choice.label).text })) - const rows = dynamic ? data?.rows ?? [] : fixed.filter (row => + onSelect: () => onSelect ([...selected]) }]) + }, [controls, label, onSelect, selected]) + const rows = dynamic ? data?.rows ?? [] : choices.filter (row => row.name?.includes (search)) const headings: Record = { id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel', 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 categories = definition.filterFields?.find (field => - field.candidateSource === 'categories')?.choices ?? [] const cell = (row: CountsCandidate, column: string) => { - if (column === 'category') return row.category == null ? '' - : message (`common:categories.${row.category}`).text - if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text + if (column === 'category') + return row.category == null ? '' : message (`common:categories.${row.category}`).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
setDraft (event.target.value)} + aria-label={message ('common:search').text} value={draft} maxLength={1000} + onChange={event => { + const value = event.target.value + if (new TextEncoder ().encode (value).length <= 1000) setDraft (value) + }} onKeyDown={event => { if (event.key !== 'Enter') return event.preventDefault () @@ -81,7 +93,7 @@ const CountsCandidatePicker = (
- {dynamic && { setCategory (event.target.value); setPage (1) }}>
} @@ -136,4 +150,5 @@ const CountsCandidatePicker = ( } -export default CountsCandidatePicker +export default CandidatePicker +export type { CandidateSource } diff --git a/frontend/src/components/common/ResourcePickerButton.tsx b/frontend/src/components/common/ResourcePickerButton.tsx new file mode 100644 index 0000000..f87698b --- /dev/null +++ b/frontend/src/components/common/ResourcePickerButton.tsx @@ -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 +} + + +export default ResourcePickerButton diff --git a/frontend/src/components/counts/CountsAdditionalConditions.tsx b/frontend/src/components/counts/CountsAdditionalConditions.tsx index aa170cf..54aa311 100644 --- a/frontend/src/components/counts/CountsAdditionalConditions.tsx +++ b/frontend/src/components/counts/CountsAdditionalConditions.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from 'react' import CountsConditionValues from '@/components/counts/CountsConditionValues' import DateTimeField from '@/components/common/DateTimeField' +import ResourcePickerButton from '@/components/common/ResourcePickerButton' import { Button } from '@/components/ui/button' -import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields, - countsSetOperators, useCountsDefinition } +import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel, + countsFilterFields, countsSetOperators, useCountsDefinition } from '@/lib/countsDefinitions' import useDialogue from '@/lib/dialogues/useDialogue' import { countsConditionValue } from '@/lib/countsDisplay' @@ -15,7 +16,7 @@ import { useUITranslation } from '@/lib/useUITranslation' 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 { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions' @@ -94,10 +95,12 @@ const ConditionEditor = ( } -const CountsAdditionalConditions = ({ params, update, conditionLabels }: { - conditionLabels?: CountsResult['conditionLabels'] - params: URLSearchParams - update: (key: string, value: string) => void }, +const CountsAdditionalConditions = ( + { resource, params, update, conditionLabels }: { + resource: CountsResource + conditionLabels?: CountsResult['conditionLabels'] + params: URLSearchParams + update: (key: string, value: string) => void }, ) => { const { label, message } = useUITranslation ('counts') const definition = useCountsDefinition () @@ -149,11 +152,19 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: { value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank} aria-label={aria.text} lang={aria.language} onChange={value => patch (index, { [key]: value ?? '' })}/> - return patch (index, { [key]: event.target.value })}/> + const source = countsCandidateSource (resource, condition.field, params) + const value = condition[key] + return <> + patch (index, { [key]: event.target.value })}/> + {source != null && patch (index, { [key]: values[0] ?? '' })}/>} + } return
{label ('additionalConditions')} @@ -182,7 +193,7 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: { {countsSetOperators.includes (condition.operator) ? ( field.key === condition.field)!} definition={definition} params={params} maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) => diff --git a/frontend/src/components/counts/CountsConditionValues.tsx b/frontend/src/components/counts/CountsConditionValues.tsx index 25b79ec..c55feb0 100644 --- a/frontend/src/components/counts/CountsConditionValues.tsx +++ b/frontend/src/components/counts/CountsConditionValues.tsx @@ -1,18 +1,22 @@ import { useState } from 'react' -import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker' +import CandidatePicker from '@/components/common/CandidatePicker' import DateTimeField from '@/components/common/DateTimeField' +import ResourcePickerButton from '@/components/common/ResourcePickerButton' import { Button } from '@/components/ui/button' +import { countsCandidateSource } from '@/lib/countsDefinitions' import useDialogue from '@/lib/dialogues/useDialogue' import { inputClass } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' +import type { CountsResource } from '@/lib/counts' import type { CountsCondition, CountsDefinition, CountsFieldMetadata } from '@/lib/countsDefinitions' const CountsConditionValues = ( - { condition, field, definition, params, maximum, change }: { + { resource, condition, field, definition, params, maximum, change }: { + resource: CountsResource condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition params: URLSearchParams; maximum: number change: (values: (string | number | boolean)[]) => void }, @@ -21,11 +25,21 @@ const CountsConditionValues = ( const dialogue = useDialogue () const [draft, setDraft] = useState ('') const values = condition.values ?? [] + const source = countsCandidateSource (resource, field.key, params) const pick = () => { void dialogue.form ({ title: label (field.candidateSource === 'tags' ? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline', - body: controls => }) + body: controls => ({ + 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') return )} {input ()} + {source != null && }