コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 4a7296eea0 AI: address review on PR #477 2026-11-14 03:03:37 +09:00
Codex Night Worker 2b18d72f7a AI: address issue #404 2026-11-14 02:58:39 +09:00
24個のファイルの変更、149行の追加、304行の削除
+7
ファイルの表示
@@ -6,6 +6,11 @@ class WikiPagesController < ApplicationController
def index
locale = resolve_locale!
resource_type = params[:resource_type]
unless resource_type.nil? || ['tag_description', 'tag_group'].include?(resource_type)
return head :bad_request
end
title = params[:title].to_s.strip
descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
@@ -14,6 +19,8 @@ class WikiPagesController < ApplicationController
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
descriptions = descriptions.none if resource_type == 'tag_group'
tag_groups = tag_groups.none if resource_type == 'tag_description'
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
+12 -79
ファイルの表示
@@ -6,14 +6,8 @@ class CountsCandidates
def initialize(params)
QueryState.validate!(params, KEYS)
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'))
raise ArgumentError unless params.fetch('resource') == 'posts'
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
@params = params
@page = Integer(params.fetch('page', '1'), 10)
@@ -24,27 +18,25 @@ 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)
groups_flg = @params['field'] == 'members'
external = CountsPopulation.new(options.merge(
'resource' => groups_flg ? 'tag_groups' : 'nico_tags',
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
'execution_at' => internal.at.iso8601(6)))
connection = TagVersion.connection
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|
scope = population.scope.except(:select)
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 })"
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
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"))
.to_sql
end
@@ -63,63 +55,4 @@ 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
+7 -1
ファイルの表示
@@ -166,7 +166,13 @@ export const pageRoutes = (
robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <WikiSearchPage/>,
{ path: 'wiki', element: <LocaleRedirect to="/tag-descriptions"/>,
handle: { redirectTo: '/tag-descriptions' } },
{ path: 'tag-descriptions',
element: <WikiSearchPage key="tag_description" resourceType="tag_description"/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'tag-groups',
element: <WikiSearchPage key="tag_group" resourceType="tag_group"/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
+1 -7
ファイルの表示
@@ -1,6 +1,5 @@
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'
@@ -185,12 +184,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
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 (' '))}/>}/>
onChange={setParentPostIds}/>
<PostTagsField
disabled={disabled}
+29 -14
ファイルの表示
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types'
type Props = {
user: User | null
@@ -39,10 +39,11 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
{ tag, material, wikiId, user, pathName: fullPathname }: {
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
wikiResourceType?: WikiPage['resourceType']
user: User | null
pathName: string },
): Menu => {
@@ -99,17 +100,30 @@ export const menuOutline = (
visible: materialFlg && Boolean (material?.tag) },
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ ...message ('wiki'), to: '/wiki', subMenu: [
{ ...message ('search'), to: '/wiki' },
{ ...message ('create'), to: '/wiki/new', visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
...([
{ key: 'tagDescriptions', path: '/tag-descriptions', type: 'tag_description' },
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const)
.map (({ key, path, type }): MenuVisibleItem => {
const current = (wikiResourceType ?? 'tag_description') === type
const detailFlg = wikiPageFlg && current
const creationPath =
type === 'tag_group'
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new'
return { ...message (key), to: path,
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path,
subMenu: [
{ ...message ('search'), to: path },
{ ...message ('create'), to: creationPath, visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: detailFlg },
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
visible: detailFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: detailFlg && editable }] }
}),
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' },
@@ -229,7 +243,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const menu = menuOutline ({ tag, material, wikiId,
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item =>
-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 DateTimeField from '@/components/common/DateTimeField'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import { Button } from '@/components/ui/button'
import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel,
countsFilterFields, countsSetOperators, useCountsDefinition }
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
countsSetOperators, useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { countsConditionValue } from '@/lib/countsDisplay'
@@ -16,7 +15,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
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 { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
@@ -95,12 +94,10 @@ const ConditionEditor = (
}
const CountsAdditionalConditions = (
{ resource, params, update, conditionLabels }: {
resource: CountsResource
conditionLabels?: CountsResult['conditionLabels']
params: URLSearchParams
update: (key: string, value: string) => void },
const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
conditionLabels?: CountsResult['conditionLabels']
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
@@ -152,19 +149,11 @@ const CountsAdditionalConditions = (
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/>
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'}
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 <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
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 })}/>
}
return <fieldset className="min-w-0 space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')}
@@ -193,7 +182,7 @@ const CountsAdditionalConditions = (
</Button>
{countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition} resource={resource}
condition={condition}
field={metadata.find (field => field.key === condition.field)!}
definition={definition} params={params}
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 type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsResource } from '@/lib/counts'
import type { CountsCandidate } from '@/lib/countsDefinitions'
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions'
type CandidateSource = {
resource: CountsResource; field: string; params?: URLSearchParams }
const CandidatePicker = (
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
source?: CandidateSource; choices?: CountsCandidate[]
categories?: { value: string; label: string }[]
const CountsCandidatePicker = (
{ field, definition, params, initial, maximum, controls, save }: {
field: CountsFieldMetadata; params: URLSearchParams
definition: CountsDefinition
initial: (string | number | boolean)[]; maximum: number
controls: DialogueFormControls; onSelect: (values: string[]) => void },
controls: DialogueFormControls; save: (values: string[]) => void },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
@@ -32,58 +29,49 @@ const CandidatePicker = (
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 resource = source?.resource ?? 'posts'
const request = new URLSearchParams ({
field: source?.field ?? '', query: search, page: String (page) })
const request = new URLSearchParams ({ field: field.key, 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 = source != null
const dynamic = field.candidateSource === 'tags'
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 }) => {
const pinned = new URLSearchParams (request)
if (point.current != null) pinned.set ('execution_at', point.current)
return fetchCountsCandidates (resource, pinned, locale, signal)
}, retry: false, staleTime: 0, gcTime: 0 })
return fetchCountsCandidates (pinned, locale, signal)
}, retry: false })
useEffect (() => {
if (data != null) point.current = data.executionAt
}, [data])
useEffect (() => {
controls.setActions ([{ label: label ('counts:pattern.selection'),
onSelect: () => onSelect ([...selected]) }])
}, [controls, label, onSelect, selected])
const rows = dynamic ? data?.rows ?? [] : choices.filter (row =>
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 =>
row.name?.includes (search))
const headings: Record<string, string> = {
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
kind: 'columns.kind' }
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '')
const available =
dynamic
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['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')
{
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
return row.kind == null ? '' : message (key).text
}
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
return column === 'id' ? row.id : row.name
}
return <div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center gap-2">
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
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)
}}
aria-label={message ('common:search').text} value={draft}
onChange={event => setDraft (event.target.value)}
onKeyDown={event => {
if (event.key !== 'Enter') return
event.preventDefault ()
@@ -93,7 +81,7 @@ const CandidatePicker = (
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
{label ('common:search')}</Button>
</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}
onChange={event => { setCategory (event.target.value); setPage (1) }}>
<option value=""/>
@@ -120,13 +108,12 @@ const CandidatePicker = (
{visible.map (column => <th key={column} className="p-2">
{label (headings[column])}</th>)}
</tr></thead>
<tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`}
className="border-t dark:border-slate-700">
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
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 => {
const next = new Set (maximum === 1 ? [] : previous)
const next = new Set (previous)
if (event.target.checked) next.add (row.value)
else next.delete (row.value)
return next
@@ -141,8 +128,7 @@ const CandidatePicker = (
onClick={() => setPage (previous => previous - 1)}>
{label ('common:pagination.previous')}</Button>
<span>{page}</span>
<Button type="button" variant="outline"
disabled={!data?.more || page >= 100_000 || fetching}
<Button type="button" variant="outline" disabled={!data?.more || fetching}
onClick={() => setPage (previous => previous + 1)}>
{label ('common:pagination.next')}</Button>
</div>}
@@ -150,5 +136,4 @@ const CandidatePicker = (
}
export default CandidatePicker
export type { CandidateSource }
export default CountsCandidatePicker
+4 -20
ファイルの表示
@@ -1,22 +1,18 @@
import { useState } from 'react'
import CandidatePicker from '@/components/common/CandidatePicker'
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
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 = (
{ resource, condition, field, definition, params, maximum, change }: {
resource: CountsResource
{ condition, field, definition, params, maximum, change }: {
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
params: URLSearchParams; maximum: number
change: (values: (string | number | boolean)[]) => void },
@@ -25,21 +21,11 @@ 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 => <CandidatePicker
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}/> })
body: controls => <CountsCandidatePicker field={field} definition={definition}
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
}
if (condition.input === 'list' || field.candidateSource === 'tags')
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>
</span>)}
{input ()}
{source != null && <ResourcePickerButton source={source} values={values}
maximum={maximum} disabled={condition.blank} onSelect={change}/>}
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
<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 FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button'
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
@@ -224,17 +223,11 @@ const CountsControls = (
{['resource_id', 'user_id'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor')}>
{() => <div className="flex flex-wrap items-center gap-1">
<input value={draft.get (key) ?? ''}
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor').text}
className={inputClass (false, 'w-28 py-1')}
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>}
{() => <input value={draft.get (key) ?? ''}
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor').text}
className={inputClass (false, 'w-28 py-1')}
onChange={event => update (key, event.target.value)}/>}
</FormField>)}
</div>
{resource === 'posts' && <FormField label={label ('columns.tags')}>
@@ -270,7 +263,7 @@ const CountsControls = (
</>}
</div>}
</FormField>}
<CountsAdditionalConditions resource={resource} params={draft} update={update}
<CountsAdditionalConditions params={draft} update={update}
conditionLabels={(labelParams != null
&& draft.get ('current') === labelParams.get ('current')
&& draft.get ('at') === labelParams.get ('at')
+2 -4
ファイルの表示
@@ -227,13 +227,11 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<DialogueProvider key={active.id}>
{active.options.body (
formControls.current[active.id] ??= {
{active.options.body (
formControls.current[active.id] ??= {
close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })}
</DialogueProvider>
</div>
<DialogFooter
+1 -7
ファイルの表示
@@ -1,4 +1,3 @@
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
@@ -89,12 +88,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
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 (' '))}/>}/>
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
+3 -26
ファイルの表示
@@ -89,19 +89,18 @@ export const fetchCountsDefinition = (
.then (definition => localiseCountsDefinition (definition, locale))
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>
kind?: 'internal' | 'external' | 'group' }
kind?: 'internal' | 'external' }
export type CountsCandidatesResult = {
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
export const fetchCountsCandidates = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
signal?: AbortSignal,
): Promise<CountsCandidatesResult> => apiGet (
`/counts/definitions/${encodeURIComponent (resource)}/candidates`, {
'/counts/definitions/posts/candidates', {
params: Object.fromEntries (params), locale, signal })
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
@@ -156,25 +155,3 @@ export const countsAxisFields = (
history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest']
.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 }
}
+5 -3
ファイルの表示
@@ -100,10 +100,11 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? ''
const resourceType = url.pathname === '/tag-groups' ? 'tag_group' : 'tag_description'
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title }, locale) })
queryKey: wikiKeys.index ({ title, resourceType }, locale),
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) })
}
@@ -310,7 +311,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mPost (u.pathname))),
run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
{ test: u => ['/wiki', '/tag-descriptions', '/tag-groups'].includes (u.pathname),
run: prefetchWikiPagesIndex },
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow },
+3 -3
ファイルの表示
@@ -5,6 +5,7 @@ import type {
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
WikiPage,
WikiTagReference } from '@/types'
export const localesKeys = {
@@ -130,7 +131,7 @@ export const wikiKeys = {
autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const,
index: (
p: { title?: string },
p: { title?: string, resourceType?: WikiPage['resourceType'] },
locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const,
show: (
@@ -174,7 +175,6 @@ export const theatresKeys = {
export const countsKeys = {
definition: (resource: string, locale: LocaleCode = 'ja') =>
['counts', 'definition', resource, locale] as const,
candidates: (resource: string, params: string, locale: LocaleCode) =>
['counts', 'candidates', resource, locale, params] as const,
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
+2 -2
ファイルの表示
@@ -24,10 +24,10 @@ export const fetchWikiChanges = (
export const fetchWikiPages = async (
{ title }: { title?: string },
{ title, resourceType }: { title?: string, resourceType?: WikiPage['resourceType'] },
locale: LocaleCode = 'ja',
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { locale, params: { title } })
await apiGet ('/wiki', { locale, params: { title, resource_type: resourceType } })
export const fetchWikiPage = async (
+2
ファイルの表示
@@ -14,6 +14,8 @@
"materials": "チカㇻペ",
"suppressions": "アエアイカㇷ゚テㇷ゚",
"wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "アシㇼ",
"edit": "イヌイェㇷ゚",
"entertainment": "シノッ",
+2
ファイルの表示
@@ -13,6 +13,8 @@
"materials": "素材",
"suppressions": "抑止",
"wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "新規",
"edit": "編輯",
"entertainment": "おたのしみ",
+1 -1
ファイルの表示
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
<TabGroup>
{material.tag && (
<Tab name={label ('navigation:wiki')}>
<Tab name={label ('navigation:tagDescriptions')}>
<WikiBody
title={material.tag.name}
language={localeLanguage (locale)}
+2 -9
ファイルの表示
@@ -6,7 +6,6 @@ import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
@@ -97,19 +96,13 @@ const MaterialHistoryPage: FC = () => {
<div className="space-y-3">
<FormField label={label ('materialId')}>
{({ invalid }) => (
<>
<input
<input
type="text"
value={materialIdInput}
aria-label={message ('materialId').text}
lang={message ('materialId').language}
onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>
<ResourcePickerButton
source={{ resource: 'materials', field: 'resource_id' }}
values={materialIdInput === '' ? [] : [materialIdInput]}
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
</>)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label={label ('tagName')}>
+2 -9
ファイルの表示
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
@@ -127,17 +126,11 @@ const MaterialSyncSuppressionsPage: FC = () => {
<FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => (
<>
<input
<input
type="text"
value={driveFileId}
onChange={e => setDriveFileId (e.target.value)}
className={inputClass (invalid)}/>
<ResourcePickerButton
source={{ resource: 'materials', field: 'drive_file_id' }}
values={driveFileId === '' ? [] : [driveFileId]}
onSelect={values => setDriveFileId (values[0] ?? '')}/>
</>)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label={label ('suppressions.reason')}>
+1 -1
ファイルの表示
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
{loading && label ('common:loading')}
</Tab>
{tags.length === 1 && (
<Tab name={label ('navigation:wiki')}>
<Tab name={label ('navigation:tagDescriptions')}>
<WikiBody title={tags[0]} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>
+2 -1
ファイルの表示
@@ -51,7 +51,8 @@ const WikiDetailPage: FC = () => {
queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity })
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ()
.replace (/^(?:ヘルプ|手引):/, '')
const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
+11 -7
ファイルの表示
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { fetchWikiPages } from '@/lib/wiki'
import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
@@ -17,16 +17,20 @@ import type { FormEvent , FC } from 'react'
import type { WikiPage } from '@/types'
const WikiSearchPage: FC = () => {
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
{ resourceType = 'tag_description' },
) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const heading =
resourceType === 'tag_group'
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale),
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) })
const handleSearch = (ev: FormEvent) => {
ev.preventDefault ()
@@ -42,10 +46,10 @@ const WikiSearchPage: FC = () => {
return (
<MainArea>
<PageHead title={message ('navigation:wiki')}/>
<PageHead title={message (heading)}/>
<div className="max-w-xl">
<PageTitle>{label ('navigation:wiki')}</PageTitle>
<PageTitle>{label (heading)}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label={label ('labels.title')}>