このコミットが含まれているのは:
@@ -195,9 +195,10 @@ class CountsPopulation
|
|||||||
end
|
end
|
||||||
return unless params['tags'].present?
|
return unless params['tags'].present?
|
||||||
|
|
||||||
refs = PostTagSearch.parse_references(params, params['tags'].split)
|
|
||||||
raw = params['tags']
|
raw = params['tags']
|
||||||
literals = PostTagSearch.literals(nil, raw, references: refs)
|
tokens = raw.split.reject { _1.downcase == 'type:or' }
|
||||||
|
refs = PostTagSearch.parse_references(params, tokens, partial: true)
|
||||||
|
literals = PostTagSearch.literals(nil, tokens.join(' '), references: refs)
|
||||||
predicates = literals.map do |literal|
|
predicates = literals.map do |literal|
|
||||||
reference = literal[:reference]
|
reference = literal[:reference]
|
||||||
expression = if reference
|
expression = if reference
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def metadata
|
def metadata
|
||||||
{ population_count: nil, population_count_computed: false,
|
{ condition_labels: condition_labels, population_count: nil, population_count_computed: false,
|
||||||
excluded_count: nil, excluded_count_scope: 'current_query',
|
excluded_count: nil, excluded_count_scope: 'current_query',
|
||||||
population_count_scope: 'all_recorded_versions',
|
population_count_scope: 'all_recorded_versions',
|
||||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||||
@@ -378,6 +378,21 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def condition_labels
|
||||||
|
entries = QueryState.records(@params, 'conditions').select { _1['operator'] == 'axis_eq' }
|
||||||
|
entries += QueryState.records(@params, 'drill')
|
||||||
|
entries.group_by { _1['field'] }.to_h do |field, conditions|
|
||||||
|
values = conditions.map { _1['value'] }.compact.uniq
|
||||||
|
labels = case field
|
||||||
|
when 'tags' then tag_labels(values)
|
||||||
|
when 'user'
|
||||||
|
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
|
||||||
|
else {}
|
||||||
|
end
|
||||||
|
[field, labels]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def axis_labels(records, column_values)
|
def axis_labels(records, column_values)
|
||||||
result = {}
|
result = {}
|
||||||
@axis_names = {}
|
@axis_names = {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Shared literal parsing and reference resolution for current and snapshot post search.
|
# Shared literal parsing and reference resolution for current and snapshot post search.
|
||||||
class PostTagSearch
|
class PostTagSearch
|
||||||
def self.parse_references(raw, literals)
|
def self.parse_references(raw, literals, partial: false)
|
||||||
return nil unless raw.key?('tag_refs')
|
return nil unless raw.key?('tag_refs')
|
||||||
|
|
||||||
references = QueryState.records(raw, 'tag_refs')
|
references = QueryState.records(raw, 'tag_refs')
|
||||||
@@ -15,6 +15,16 @@ class PostTagSearch
|
|||||||
end
|
end
|
||||||
raise ArgumentError unless valid
|
raise ArgumentError unless valid
|
||||||
|
|
||||||
|
if partial
|
||||||
|
remaining = references.dup
|
||||||
|
result = literals.map do |literal|
|
||||||
|
index = remaining.index { _1['name'] == literal }
|
||||||
|
index ? remaining.delete_at(index) : nil
|
||||||
|
end
|
||||||
|
raise ArgumentError if remaining.any?
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
references.map { _1['name'] } == literals ? references : nil
|
references.map { _1['name'] } == literals ? references : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
|
|
||||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
|
import type { CountsResult } from '@/lib/counts'
|
||||||
import type { 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'
|
||||||
|
|
||||||
@@ -93,7 +94,8 @@ const ConditionEditor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const CountsAdditionalConditions = ({ params, update }: {
|
const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
|
||||||
|
conditionLabels?: CountsResult['conditionLabels']
|
||||||
params: URLSearchParams
|
params: URLSearchParams
|
||||||
update: (key: string, value: string) => void },
|
update: (key: string, value: string) => void },
|
||||||
) => {
|
) => {
|
||||||
@@ -162,7 +164,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
<span>{countsAxisLabel (condition, definition, locale).text}</span>
|
<span>{countsAxisLabel (condition, definition, locale).text}</span>
|
||||||
<span>{label ('operatorSymbols.eq')}</span>
|
<span>{label ('operatorSymbols.eq')}</span>
|
||||||
<span>{countsConditionValue (condition.value, condition, locale,
|
<span>{countsConditionValue (condition.value, condition, locale,
|
||||||
params.get ('timezone') ?? 'UTC')}</span>
|
params.get ('timezone') ?? 'UTC', conditionLabels)}</span>
|
||||||
<Button type="button" variant="ghost" size="sm"
|
<Button type="button" variant="ghost" size="sm"
|
||||||
aria-label={message ('remove').text}
|
aria-label={message ('remove').text}
|
||||||
onClick={() => write (conditions.filter ((_, position) => position !== index))}>
|
onClick={() => write (conditions.filter ((_, position) => position !== index))}>
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ import { useClientMotion } from '@/lib/useClientMotion'
|
|||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import { readCountState, setCountState } from '@/lib/queryState'
|
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsResource, CountsResult } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
const CountsControls = (
|
const CountsControls = (
|
||||||
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
|
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
|
||||||
displayThumbnail, reorder, actions, pending = false }: {
|
displayThumbnail, reorder, actions, conditionLabels, pending = false }: {
|
||||||
resource: CountsResource; applied: URLSearchParams; open: boolean
|
resource: CountsResource; applied: URLSearchParams; open: boolean
|
||||||
|
conditionLabels?: CountsResult['conditionLabels']
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
execute: (params: URLSearchParams) => void
|
execute: (params: URLSearchParams) => void
|
||||||
@@ -92,6 +93,27 @@ const CountsControls = (
|
|||||||
const update = (key: string, value: string) => setDraft (previous => {
|
const update = (key: string, value: string) => setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
setCountState (next, key, value)
|
setCountState (next, key, value)
|
||||||
|
if (key === 'tags')
|
||||||
|
{
|
||||||
|
const tokens = value.split (/\s+/).filter (token => token
|
||||||
|
&& token.toLowerCase () !== 'type:or')
|
||||||
|
const remaining = [...tokens]
|
||||||
|
const references = readCountState<Record<string, string | number | boolean>[]> (
|
||||||
|
previous, 'tag_refs', []).flatMap (reference => {
|
||||||
|
const name = String (reference.name)
|
||||||
|
const exact = remaining.indexOf (name)
|
||||||
|
const index = exact >= 0 ? exact : remaining.findIndex (token =>
|
||||||
|
token.replace (/^not:/i, '') === name.replace (/^not:/i, ''))
|
||||||
|
if (index < 0) return []
|
||||||
|
const [token] = remaining.splice (index, 1)
|
||||||
|
return [token === name ? reference : { ...reference, name: token,
|
||||||
|
negative: token.toLowerCase ().startsWith ('not:') }]
|
||||||
|
})
|
||||||
|
for (const name of [...next.keys ()])
|
||||||
|
if (name === 'tag_refs' || name.startsWith ('tag_refs.')) next.delete (name)
|
||||||
|
if (references.length > 0)
|
||||||
|
setCountState (next, 'tag_refs', encodeCountState ('tag_refs', references))
|
||||||
|
}
|
||||||
if (key === 'limit') next.delete ('page')
|
if (key === 'limit') next.delete ('page')
|
||||||
if (next.toString () === previous.toString ()) return previous
|
if (next.toString () === previous.toString ()) return previous
|
||||||
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||||
@@ -248,14 +270,6 @@ const CountsControls = (
|
|||||||
<option value="all">AND</option><option value="any">OR</option>
|
<option value="all">AND</option><option value="any">OR</option>
|
||||||
</select>
|
</select>
|
||||||
{draft.get ('tags') && <>
|
{draft.get ('tags') && <>
|
||||||
{readCountState<Record<string, string | number>[]> (
|
|
||||||
draft, 'tag_refs', []).map ((reference, index) => (
|
|
||||||
<span key={index} className="text-sm">
|
|
||||||
{reference.name} ({[reference.tag_id == null ? ''
|
|
||||||
: `internal:${reference.tag_id}`,
|
|
||||||
reference.external_tag_id == null ? ''
|
|
||||||
: `external:${reference.external_tag_id}`].filter (Boolean).join (' ')})
|
|
||||||
</span>))}
|
|
||||||
<Button type="button" variant="ghost" size="sm"
|
<Button type="button" variant="ghost" size="sm"
|
||||||
aria-label={message ('remove').text} onClick={() => setDraft (previous => {
|
aria-label={message ('remove').text} onClick={() => setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
@@ -267,7 +281,8 @@ const CountsControls = (
|
|||||||
</>}
|
</>}
|
||||||
</div>}
|
</div>}
|
||||||
</FormField>}
|
</FormField>}
|
||||||
<CountsAdditionalConditions params={draft} update={update}/>
|
<CountsAdditionalConditions params={draft} update={update}
|
||||||
|
conditionLabels={conditionLabels}/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export type CountsResult = {
|
|||||||
executionAt?: string
|
executionAt?: string
|
||||||
columnValues?: (string | number | null)[][]
|
columnValues?: (string | number | null)[][]
|
||||||
cells?: { values: (string | number | null)[]; count: number }[]
|
cells?: { values: (string | number | null)[]; count: number }[]
|
||||||
|
conditionLabels?: Record<string, Record<string, string | null>>
|
||||||
axisLabels?: Record<string, Record<string, string | null>>
|
axisLabels?: Record<string, Record<string, string | null>>
|
||||||
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
||||||
rows?: { field: string; grain?: string }[]
|
rows?: { field: string; grain?: string }[]
|
||||||
|
|||||||
@@ -448,8 +448,16 @@ const countsDateValue = (
|
|||||||
export const countsConditionValue = (
|
export const countsConditionValue = (
|
||||||
value: string | number | boolean | null | undefined,
|
value: string | number | boolean | null | undefined,
|
||||||
axis: CountsAxis, locale: LocaleCode, timezone: string,
|
axis: CountsAxis, locale: LocaleCode, timezone: string,
|
||||||
|
labels?: Record<string, Record<string, string | null>>,
|
||||||
): string => {
|
): string => {
|
||||||
|
if (axis.field === 'user' && value == null)
|
||||||
|
return translateUIMessage ('bot', 'counts', locale).text
|
||||||
if (value == null) return 'NULL'
|
if (value == null) return 'NULL'
|
||||||
|
if (axis.field === 'tags' || axis.field === 'user')
|
||||||
|
return labels?.[axis.field]?.[String (value)]
|
||||||
|
|| translateUIMessage ('notRegistered', 'counts', locale).text
|
||||||
|
if (axis.field === 'changed_field')
|
||||||
|
return translateUIMessage (countsFieldLabel (String (value)), 'counts', locale).text
|
||||||
if (typeof value === 'boolean')
|
if (typeof value === 'boolean')
|
||||||
return translateUIMessage (`truthValues.${value}`, 'counts', locale).text
|
return translateUIMessage (`truthValues.${value}`, 'counts', locale).text
|
||||||
if (axis.grain != null)
|
if (axis.grain != null)
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ const CountsResourceContent = (
|
|||||||
<div ref={controlsArea} className="shrink-0"
|
<div ref={controlsArea} className="shrink-0"
|
||||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||||
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params} conditionLabels={data?.conditionLabels}
|
||||||
pending={running || fetching || !params.has ('execution_at')}
|
pending={running || fetching || !params.has ('execution_at')}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
reorder={value => update ('order', value)}
|
reorder={value => update ('order', value)}
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする