コミットを比較
1
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
c0f42ea0c0 |
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import PostList from '@/components/PostList'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
import PostDurationField from '@/components/posts/PostDurationField'
|
import PostDurationField from '@/components/posts/PostDurationField'
|
||||||
@@ -11,14 +12,15 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { updatePost } from '@/lib/posts'
|
import { fetchPosts, updatePost } from '@/lib/posts'
|
||||||
|
import { postsKeys } from '@/lib/queryKeys'
|
||||||
import { msToTime } from '@/lib/utils'
|
import { msToTime } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
import type { Post, TagWithSections } from '@/types'
|
import type { FetchPostsParams, Post, TagWithSections } from '@/types'
|
||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'parentPostIds' | 'tags' | 'videoMs'
|
'parentPostIds' | 'tags' | 'videoMs'
|
||||||
@@ -69,6 +71,33 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
|
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
|
||||||
const [title, setTitle] = useState (post.title)
|
const [title, setTitle] = useState (post.title)
|
||||||
|
|
||||||
|
const [importVisible, setImportVisible] = useState (false)
|
||||||
|
const [sourceTitle, setSourceTitle] = useState ('')
|
||||||
|
const [sourcePage, setSourcePage] = useState (1)
|
||||||
|
const sourceParams: FetchPostsParams = {
|
||||||
|
title: sourceTitle, page: sourcePage, limit: 20,
|
||||||
|
url: '', tags: '', match: 'all', order: 'original_created_at:desc',
|
||||||
|
originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '' }
|
||||||
|
const { data: sources, isError: sourceError } = useQuery ({
|
||||||
|
queryKey: postsKeys.index (sourceParams, locale),
|
||||||
|
queryFn: () => fetchPosts (sourceParams, locale),
|
||||||
|
enabled: importVisible && !disabled })
|
||||||
|
|
||||||
|
const importTags = (source: Post) => {
|
||||||
|
if (disabled)
|
||||||
|
return
|
||||||
|
setTags (current => {
|
||||||
|
const existing = new Set (current.split (/\s+/))
|
||||||
|
const additions = tagsToStr (source.tags).split (/\s+/)
|
||||||
|
.filter (tag => tag !== '' && !existing.has (tag))
|
||||||
|
return additions.length > 0
|
||||||
|
? `${current}${current.trim () === '' ? '' : ' '}${additions.join (' ')}`
|
||||||
|
: current
|
||||||
|
})
|
||||||
|
setImportVisible (false)
|
||||||
|
}
|
||||||
|
|
||||||
const videoFlg =
|
const videoFlg =
|
||||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||||
[tags])
|
[tags])
|
||||||
@@ -185,12 +214,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
value={parentPostIds}
|
value={parentPostIds}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errors={fieldErrors.parentPostIds}
|
errors={fieldErrors.parentPostIds}
|
||||||
onChange={setParentPostIds}
|
onChange={setParentPostIds}/>
|
||||||
after={<ResourcePickerButton
|
|
||||||
source={{ resource: 'posts', field: 'resource_id' }}
|
|
||||||
values={parentPostIds.split (/\s+/).filter (Boolean)}
|
|
||||||
maximum={Number.MAX_SAFE_INTEGER} disabled={disabled}
|
|
||||||
onSelect={values => setParentPostIds (values.join (' '))}/>}/>
|
|
||||||
|
|
||||||
<PostTagsField
|
<PostTagsField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -198,6 +222,48 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
setTags={setTags}
|
setTags={setTags}
|
||||||
errors={fieldErrors.tags}/>
|
errors={fieldErrors.tags}/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={importVisible}
|
||||||
|
onClick={() => setImportVisible (visible => !visible)}>
|
||||||
|
{label ('edit.import')}
|
||||||
|
</Button>
|
||||||
|
{importVisible && (
|
||||||
|
<div className="space-y-2" onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter' && event.target instanceof HTMLInputElement)
|
||||||
|
event.preventDefault ()
|
||||||
|
}}>
|
||||||
|
<PostTextField
|
||||||
|
label={label ('shared:labels.title')}
|
||||||
|
type="search"
|
||||||
|
value={sourceTitle}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={value => {
|
||||||
|
setSourceTitle (value)
|
||||||
|
setSourcePage (1)
|
||||||
|
}}/>
|
||||||
|
<FieldError messages={sourceError ? [label ('common:fetchError')] : []}/>
|
||||||
|
{!disabled && <PostList posts={sources?.posts ?? []} onSelect={importTags}/>}
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || sourcePage <= 1}
|
||||||
|
onClick={() => setSourcePage (page => page - 1)}>
|
||||||
|
{label ('common:pagination.previous')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled || sourcePage * 20 >= (sources?.count ?? 0)}
|
||||||
|
onClick={() => setSourcePage (page => page + 1)}>
|
||||||
|
{label ('common:pagination.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{/* オリジナルの作成日時 */}
|
{/* オリジナルの作成日時 */}
|
||||||
<PostOriginalCreatedTimeField
|
<PostOriginalCreatedTimeField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -13,10 +14,11 @@ import type { FC, MouseEvent } from 'react'
|
|||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
type Props = { posts: Post[]
|
type Props = { posts: Post[]
|
||||||
|
onSelect?: (post: Post) => void
|
||||||
onClick?: (event: MouseEvent<HTMLElement>) => void }
|
onClick?: (event: MouseEvent<HTMLElement>) => void }
|
||||||
|
|
||||||
|
|
||||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
const PostList: FC<Props> = ({ posts, onClick, onSelect }) => {
|
||||||
const { label } = useUITranslation ('shared')
|
const { label } = useUITranslation ('shared')
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
@@ -39,7 +41,63 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
const sharedId = `page-${ post.id }`
|
const sharedId = `page-${ post.id }`
|
||||||
const layoutId = sharedId
|
const layoutId = sharedId
|
||||||
|
|
||||||
return (
|
const card = (
|
||||||
|
<motion.div
|
||||||
|
ref={cardRef}
|
||||||
|
layout={animationMode === 'off' ? false : true}
|
||||||
|
layoutId={(onSelect || animationMode === 'off') ? undefined : layoutId}
|
||||||
|
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
||||||
|
'transform-gpu will-change-transform',
|
||||||
|
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
||||||
|
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onLayoutAnimationStart={() => {
|
||||||
|
if (!(cardRef.current))
|
||||||
|
return
|
||||||
|
|
||||||
|
cardRef.current.style.position = 'relative'
|
||||||
|
cardRef.current.style.zIndex = '9999'
|
||||||
|
}}
|
||||||
|
onLayoutAnimationComplete={() => {
|
||||||
|
if (!(cardRef.current))
|
||||||
|
return
|
||||||
|
|
||||||
|
cardRef.current.style.zIndex = ''
|
||||||
|
cardRef.current.style.position = ''
|
||||||
|
}}
|
||||||
|
transition={{ layout: cardLayoutTransition }}>
|
||||||
|
{thumbnailMode === 'off'
|
||||||
|
? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center
|
||||||
|
bg-muted text-center text-xs text-muted-foreground">
|
||||||
|
{label ('labels.noThumbnail')}
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
|
<img
|
||||||
|
src={post.thumbnail || post.thumbnailBase || undefined}
|
||||||
|
alt={post.title || post.url}
|
||||||
|
title={post.title || post.url || undefined}
|
||||||
|
loading={
|
||||||
|
thumbnailMode === 'light'
|
||||||
|
? 'lazy'
|
||||||
|
: (i < 12 ? 'eager' : 'lazy')
|
||||||
|
}
|
||||||
|
decoding="async"
|
||||||
|
className="object-cover w-full h-full"/>)}
|
||||||
|
</motion.div>)
|
||||||
|
|
||||||
|
return onSelect
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
key={post.id}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-40 h-40 p-0"
|
||||||
|
aria-label={post.title || post.url}
|
||||||
|
onClick={() => onSelect (post)}>
|
||||||
|
{card}
|
||||||
|
</Button>)
|
||||||
|
: (
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/posts/${ post.id }`}
|
to={`/posts/${ post.id }`}
|
||||||
key={post.id}
|
key={post.id}
|
||||||
@@ -49,51 +107,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
setForLocationKey (location.key, sharedId)
|
setForLocationKey (location.key, sharedId)
|
||||||
onClick?.(e)
|
onClick?.(e)
|
||||||
}}>
|
}}>
|
||||||
<motion.div
|
{card}
|
||||||
ref={cardRef}
|
|
||||||
layout={animationMode === 'off' ? false : true}
|
|
||||||
layoutId={animationMode === 'off' ? undefined : layoutId}
|
|
||||||
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
|
|
||||||
'transform-gpu will-change-transform',
|
|
||||||
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
|
|
||||||
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
onLayoutAnimationStart={() => {
|
|
||||||
if (!(cardRef.current))
|
|
||||||
return
|
|
||||||
|
|
||||||
cardRef.current.style.position = 'relative'
|
|
||||||
cardRef.current.style.zIndex = '9999'
|
|
||||||
}}
|
|
||||||
onLayoutAnimationComplete={() => {
|
|
||||||
if (!(cardRef.current))
|
|
||||||
return
|
|
||||||
|
|
||||||
cardRef.current.style.zIndex = ''
|
|
||||||
cardRef.current.style.position = ''
|
|
||||||
}}
|
|
||||||
transition={{ layout: cardLayoutTransition }}>
|
|
||||||
{thumbnailMode === 'off'
|
|
||||||
? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center
|
|
||||||
bg-muted text-center text-xs text-muted-foreground">
|
|
||||||
{label ('labels.noThumbnail')}
|
|
||||||
</div>)
|
|
||||||
: (
|
|
||||||
<img
|
|
||||||
src={post.thumbnail || post.thumbnailBase || undefined}
|
|
||||||
alt={post.title || post.url}
|
|
||||||
title={post.title || post.url || undefined}
|
|
||||||
loading={
|
|
||||||
thumbnailMode === 'light'
|
|
||||||
? 'lazy'
|
|
||||||
: i < 12
|
|
||||||
? 'eager'
|
|
||||||
: 'lazy'
|
|
||||||
}
|
|
||||||
decoding="async"
|
|
||||||
className="object-cover w-full h-full"/>)}
|
|
||||||
</motion.div>
|
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
})}
|
})}
|
||||||
</div>)
|
</div>)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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) =>
|
||||||
|
|||||||
+31
-46
@@ -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 }
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"_untranslated": ["edit.import"],
|
||||||
"list": {
|
"list": {
|
||||||
"tagline": "{{brand}}",
|
"tagline": "{{brand}}",
|
||||||
"empty": "ミンタㇻ タ ネン カ イサㇺ",
|
"empty": "ミンタㇻ タ ネン カ イサㇺ",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"showExternal": "ソイタㇰ ソㇱ アヌカレ"
|
"showExternal": "ソイタㇰ ソㇱ アヌカレ"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
"import": "インポート",
|
||||||
"validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……",
|
"validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……",
|
||||||
"conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!",
|
"conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!",
|
||||||
"conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。",
|
"conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"question": "表示しますか?", "showExternal": "外部ページを表示"
|
"question": "表示しますか?", "showExternal": "外部ページを表示"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
"import": "インポート",
|
||||||
"validationFailed": "更新はできなかったよ……",
|
"validationFailed": "更新はできなかったよ……",
|
||||||
"conflictTitle": "競合が発生しました.",
|
"conflictTitle": "競合が発生しました.",
|
||||||
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
|
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする