コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
520ffa4c4c | ||
|
|
50185ca8b7 | ||
|
|
dad3ac99b4 |
@@ -4,7 +4,7 @@ require 'digest'
|
||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
class Forbidden < StandardError; end
|
||||
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
||||
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
||||
|
||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||
'nico_tag_versions' => ['tag_id'],
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
class CountsAxes
|
||||
MAX_AXES = 8
|
||||
MASKED_TEXT = '********'.freeze
|
||||
MASKED_VALUE = '__counts_private__'.freeze
|
||||
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
||||
'created_at'].freeze
|
||||
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
|
||||
class TooManyAxes < ArgumentError; end
|
||||
class PrivateCondition < ArgumentError; end
|
||||
|
||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
||||
|
||||
def self.private_field?(field)
|
||||
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
|
||||
end
|
||||
|
||||
def self.visible_ids_sql(definition)
|
||||
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
|
||||
HistoryAccess.sql(definition.model, 'counts_visible')
|
||||
end
|
||||
|
||||
def self.visibility_sql(definition, field)
|
||||
return 'TRUE' if HistoryAccess.admin?
|
||||
|
||||
source = visible_ids_sql(definition)
|
||||
visible = "v.id IN (#{ source })"
|
||||
return visible unless field == 'changed_field'
|
||||
|
||||
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
|
||||
end
|
||||
|
||||
def self.masked_sql(expression, definition, field)
|
||||
return expression if HistoryAccess.admin? || !private_field?(field)
|
||||
|
||||
visible = visibility_sql(definition, field)
|
||||
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
|
||||
end
|
||||
|
||||
def self.time_expression(expression, grain, timezone)
|
||||
raise ArgumentError unless GRAINS.include?(grain)
|
||||
|
||||
@@ -89,9 +60,13 @@ class CountsAxes
|
||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||
end
|
||||
if !HistoryAccess.admin? && self.class.private_field?(field)
|
||||
expression = self.class.masked_sql(
|
||||
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
||||
visible = HistoryAccess.sql(@definition.model, 'v')
|
||||
if field == 'changed_field'
|
||||
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
||||
end
|
||||
expression = "CASE WHEN #{ visible } " +
|
||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
||||
end
|
||||
expression
|
||||
end
|
||||
@@ -102,12 +77,10 @@ class CountsAxes
|
||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||
identities = ['v.id']
|
||||
if rows.any? { _1['field'] == 'tags' }
|
||||
identities << self.class.masked_sql(
|
||||
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
||||
if rows.any? { _1['field'] == 'changed_field' }
|
||||
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
||||
end
|
||||
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
||||
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||
"CONCAT('external:', tag_entry.external_tag_id))"
|
||||
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||
end
|
||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||
selected += identities.each_with_index.map { |expression, index|
|
||||
@@ -125,17 +98,8 @@ class CountsAxes
|
||||
def joins(fields = all.map { _1['field'] })
|
||||
result = ''
|
||||
if fields.include?('tags')
|
||||
document = 'CAST(v.tags_json AS JSON)'
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visible_ids_sql(@definition)
|
||||
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
|
||||
'ON counts_tags_visible.id = v.id '
|
||||
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
|
||||
"THEN #{ document } ELSE JSON_ARRAY(" +
|
||||
"JSON_OBJECT('tag_id', 0)) END"
|
||||
end
|
||||
result += <<~SQL.squish
|
||||
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||
|
||||
@@ -148,13 +112,6 @@ class CountsAxes
|
||||
condition = @definition.changes.map do |field|
|
||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||
end.join(' OR ')
|
||||
unless HistoryAccess.admin?
|
||||
visible = self.class.visibility_sql(@definition, 'changed_field')
|
||||
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||
fields += " UNION ALL SELECT #{ marker } AS field_key"
|
||||
condition = "((#{ visible }) AND (#{ condition })) OR " +
|
||||
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
|
||||
end
|
||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||
end
|
||||
result
|
||||
|
||||
@@ -82,32 +82,14 @@ class CountsCondition
|
||||
|
||||
def apply(scope, entry)
|
||||
raise ArgumentError unless entry.is_a?(Hash)
|
||||
|
||||
if entry['operator'] == 'axis_eq'
|
||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||
value = entry['value']
|
||||
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
||||
[true, false].include?(value))
|
||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
||||
raise ArgumentError
|
||||
end
|
||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
||||
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
|
||||
(value == CountsAxes::MASKED_VALUE ||
|
||||
(value == CountsAxes::MASKED_TEXT &&
|
||||
(@definition.fields[entry['field']] != :string ||
|
||||
['tags', 'changed_field'].include?(entry['field'])))))
|
||||
field = entry.fetch('field')
|
||||
raise ArgumentError unless @definition.axes(@population.history).include?(field)
|
||||
if entry.key?('grain')
|
||||
unless (@definition.fields[field] == :datetime &&
|
||||
CountsAxes::GRAINS.include?(entry['grain']))
|
||||
raise ArgumentError
|
||||
end
|
||||
end
|
||||
return scope.none
|
||||
end
|
||||
scope = @population.condition_scope(scope, entry.fetch('field'))
|
||||
return @population.axis_condition(scope, entry)
|
||||
end
|
||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||
@@ -129,13 +111,6 @@ class CountsCondition
|
||||
|
||||
source, expression = source_for(field[:key])
|
||||
type = field[:type]
|
||||
# Preserve typed input compatibility; display markers are never real typed values.
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
|
||||
return scope.none unless entry['operator'] == 'ne'
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
end
|
||||
blank = blank_sql(expression, type)
|
||||
if entry['blank']
|
||||
sql = if source && field[:collection]
|
||||
@@ -144,7 +119,6 @@ class CountsCondition
|
||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||
else blank
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return scope.where(Arel.sql(sql))
|
||||
end
|
||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||
@@ -157,7 +131,6 @@ class CountsCondition
|
||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||
operator = entry['operator']
|
||||
if type == :interval
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
return interval(scope, source, expression, parsed)
|
||||
end
|
||||
clauses = []
|
||||
@@ -185,7 +158,6 @@ class CountsCondition
|
||||
elsif operator == 'ne'
|
||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *binds)
|
||||
end
|
||||
|
||||
@@ -196,13 +168,6 @@ class CountsCondition
|
||||
|
||||
values = entry['values']
|
||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
||||
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||
values.include?(CountsAxes::MASKED_TEXT) &&
|
||||
(field[:type] != :string || field[:set_type] == 'tag_reference'))
|
||||
return scope.none if entry['operator'] == 'all'
|
||||
|
||||
values = values - [CountsAxes::MASKED_TEXT]
|
||||
end
|
||||
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||
|
||||
parsed = values.map do |value|
|
||||
@@ -226,10 +191,7 @@ class CountsCondition
|
||||
|
||||
operator = entry['operator']
|
||||
if parsed.empty?
|
||||
return scope.none if ['in', 'any'].include?(operator)
|
||||
return scope if entry['values'].empty?
|
||||
|
||||
return @population.condition_scope(scope, field[:key])
|
||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
||||
end
|
||||
source, expression = source_for(field[:key])
|
||||
if field[:set_type] == 'tag_reference'
|
||||
@@ -250,7 +212,6 @@ class CountsCondition
|
||||
predicate = source ? "NOT #{ predicate }" :
|
||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||
end
|
||||
scope = @population.condition_scope(scope, field[:key])
|
||||
scope.where(predicate, *parsed)
|
||||
end
|
||||
|
||||
|
||||
@@ -70,13 +70,6 @@ class CountsPopulation
|
||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||
end
|
||||
|
||||
# Each predicate keeps its own visibility restriction, including OR branches.
|
||||
def condition_scope(scope, field)
|
||||
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
|
||||
|
||||
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
|
||||
end
|
||||
|
||||
def axis_condition(scope, entry)
|
||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||
|
||||
@@ -216,7 +209,6 @@ class CountsPopulation
|
||||
def apply_tags
|
||||
raise ArgumentError unless definition.resource == 'posts'
|
||||
|
||||
@scope = condition_scope(@scope, 'tags')
|
||||
if params['tag'].present?
|
||||
@scope = @scope.where(snapshot_membership(
|
||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||
|
||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
||||
end
|
||||
|
||||
def summary_value(value, index, page)
|
||||
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
||||
return '********' if value == CountsAxes::MASKED_VALUE
|
||||
|
||||
field = @query.axes.all.fetch(index).fetch('field')
|
||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||
|
||||
@@ -220,11 +220,19 @@ class CountsTableQuery
|
||||
|
||||
private
|
||||
|
||||
# Private predicates require visibility independently of their operator.
|
||||
# Ordering/measure state must not select a server-only masked coordinate.
|
||||
# Public timeline predicates may include hidden records. Private predicates must
|
||||
# never become a count oracle, even when the selected axes themselves are public.
|
||||
def validate_summary_conditions!
|
||||
return if mode != 'summary' || HistoryAccess.admin?
|
||||
|
||||
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
|
||||
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
|
||||
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
|
||||
private_parameters = keys.any? do |key|
|
||||
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
|
||||
end
|
||||
raise CountsAxes::PrivateCondition if private_fields || private_parameters
|
||||
|
||||
['column_order', 'measures'].each do |key|
|
||||
# Coordinates are compared only to already masked expressions, never raw
|
||||
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||
|
||||
+2
-15
@@ -9,21 +9,8 @@
|
||||
# end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
|
||||
Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
|
||||
Region.find_or_create_by!(code: 392) do
|
||||
_1.alpha_code = 'JP'
|
||||
_1.name = '日本国'
|
||||
end
|
||||
Locale.find_or_create_by!(code: 'ja') do
|
||||
_1.language_code = 'ja'
|
||||
_1.script_code = 'Jpan'
|
||||
_1.region_code = 392
|
||||
_1.name = '日本語'
|
||||
end
|
||||
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||
end
|
||||
|
||||
post_url_sanitisation_rules = [
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Counts masked search', type: :request do
|
||||
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
|
||||
let(:actor) { create(:user, :member) }
|
||||
let(:admin) { create(:user, :admin) }
|
||||
let(:base) do
|
||||
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
|
||||
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
|
||||
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
|
||||
end
|
||||
|
||||
before do
|
||||
[1, 2].each do |number|
|
||||
TagVersion.create!(tag:, version_no: number,
|
||||
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
|
||||
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
|
||||
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
|
||||
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
|
||||
created_by_user: actor, created_at: Time.utc(2023, number))
|
||||
end
|
||||
end
|
||||
|
||||
def query(extra = {})
|
||||
get '/counts/query', params: base.merge(extra)
|
||||
expect(response).to have_http_status(:ok), extra.inspect
|
||||
response.parsed_body
|
||||
end
|
||||
|
||||
def predicate(field, operator, value, index = 0)
|
||||
prefix = "conditions.#{ index }"
|
||||
result = { 'conditions' => '', "#{ prefix }.field" => field,
|
||||
"#{ prefix }.operator" => operator }
|
||||
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
|
||||
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
|
||||
"condition_values.#{ index }.condition" => index.to_s,
|
||||
"condition_values.#{ index }.value_type" => 'string',
|
||||
"condition_values.#{ index }.value" => value)
|
||||
else
|
||||
result["#{ prefix }.value"] = value
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
it 'rejects every name and alias predicate on private history' do
|
||||
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
|
||||
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
|
||||
multiple = !['name', 'name_ja'].include?(field)
|
||||
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
|
||||
['eq', 'ne', 'like', *operators].each do |operator|
|
||||
[secret, '********', '%', ''].each do |value|
|
||||
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
|
||||
expect(query(predicate(field, operator, value))['count']).to eq(expected)
|
||||
end
|
||||
end
|
||||
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores missing and empty scalar values unless blank is checked' do
|
||||
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
|
||||
['eq', 'ne', 'like'].each do |operator|
|
||||
condition = predicate(field, operator, '')
|
||||
[condition, condition.except('conditions.0.value')].each do |empty|
|
||||
expect(query(empty)['count']).to eq(2)
|
||||
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
|
||||
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps details totals unchanged for inactive private predicates' do
|
||||
settings = predicate('name', 'ne', '').merge('mode' => 'details')
|
||||
result = query(settings)
|
||||
expect(result['count']).to eq(0)
|
||||
expect(result['versions']).to be_empty
|
||||
expect(result['output_count']).to eq(2)
|
||||
active = query(settings.merge('conditions.0.blank' => '1'))
|
||||
expect(active['output_count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'ignores missing sets but preserves explicit empty set semantics' do
|
||||
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|
||||
|field, operators|
|
||||
operators.each do |operator|
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator }
|
||||
expect(query(condition)['count']).to eq(2)
|
||||
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = ['in', 'any'].include?(operator) ? 0 : 2
|
||||
expect(query(empty)['count']).to eq(expected)
|
||||
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||
# An explicit empty string element is a real value, not an unspecified set.
|
||||
expect(query(predicate(field, operator, ''))['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores empty private predicates regardless of their position' do
|
||||
first = predicate('name', 'ne', '')
|
||||
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
|
||||
last = predicate('resource_id', 'eq', tag.id.to_s)
|
||||
.merge(predicate('aliases.name', 'like', '', 1))
|
||||
expect(query(first)['count']).to eq(2)
|
||||
expect(query(last)['count']).to eq(2)
|
||||
active = predicate('name', 'ne', 'unrelated')
|
||||
.merge(predicate('aliases.name', 'like', '', 1))
|
||||
expect(query(active)['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'searches public literal markers and treats axis predicates as explicit' do
|
||||
public_tag = create(:tag, primary_name: '********')
|
||||
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
|
||||
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
|
||||
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
|
||||
aliases: [] } }, created_at: Time.utc(2023))
|
||||
expect(query['count']).to eq(3)
|
||||
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
|
||||
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
|
||||
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
|
||||
end
|
||||
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(1)
|
||||
['********', '', nil].each do |value|
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
|
||||
'conditions.0.operator' => 'axis_eq',
|
||||
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||
condition['conditions.0.value'] = value unless value.nil?
|
||||
expected = value == '********' ? 1 : 0
|
||||
expect(query(condition)['count']).to eq(expected)
|
||||
drill = { 'drill' => '', 'drill.0.field' => 'name',
|
||||
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||
drill['drill.0.value'] = value unless value.nil?
|
||||
expect(query(drill)['count']).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps public identity and timeline predicates searchable' do
|
||||
expect(query['count']).to eq(2)
|
||||
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
|
||||
'event_type' => 'discard', 'user' => actor.id.to_s,
|
||||
'created_at' => '2023-02-01T00:00:00Z' }
|
||||
values.each do |field, value|
|
||||
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
|
||||
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
|
||||
end
|
||||
condition = predicate('name', 'ne', '秘密のタグ')
|
||||
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||
expect(query(condition)['count']).to eq(0)
|
||||
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
|
||||
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'keeps public predicates available after combining multiple conditions' do
|
||||
condition = predicate('resource_id', 'eq', tag.id.to_s)
|
||||
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||
expect(query(condition)['count']).to eq(1)
|
||||
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
|
||||
expect(query(condition)['count']).to eq(0)
|
||||
end
|
||||
|
||||
it 'keeps real name searches for administrators and excludes private details' do
|
||||
condition = predicate('name', 'eq', '秘密のタグ')
|
||||
details = query(condition.merge('mode' => 'details'))
|
||||
expect(details['versions']).to be_empty
|
||||
expect(details['output_count']).to eq(0)
|
||||
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
|
||||
expect(masked['versions']).to be_empty
|
||||
expect(masked['output_count']).to eq(0)
|
||||
sign_in_as(admin)
|
||||
expect(query(condition)['count']).to eq(2)
|
||||
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
|
||||
end
|
||||
|
||||
it 'rejects private names reached through a post tag snapshot' do
|
||||
post = create(:post)
|
||||
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
|
||||
url: post.url, tags: '', parent_post_ids: '',
|
||||
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
|
||||
category: 'general', sections: [] }],
|
||||
created_at: Time.utc(2024))
|
||||
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
|
||||
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
|
||||
['秘密のタグ', '********', '%'].each do |value|
|
||||
settings = predicate(field, operator, value).merge('resource' => 'posts')
|
||||
expect(query(settings)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
|
||||
'conditions.0.blank' => '1')
|
||||
expect(query(blank)['count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -354,25 +354,15 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||
expect(response.body).not_to include('hidden title')
|
||||
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
|
||||
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
||||
expect(masked['cells'].first['count']).to eq(4)
|
||||
expect(query['versions']).to be_empty
|
||||
end
|
||||
|
||||
it 'allows public identity predicates without exposing private snapshot fields' do
|
||||
PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
|
||||
url: record.url, title: 'hidden title', tags: '', tags_json: [],
|
||||
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||
|
||||
get '/counts/query', params: base.merge(mode: 'summary',
|
||||
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(4)
|
||||
expect(json['cells'].sum { _1['count'] }).to eq(4)
|
||||
expect(json['cells'].map { _1['values'].last })
|
||||
.to include(CountsAxes::MASKED_VALUE)
|
||||
expect(response.body).not_to include('hidden title')
|
||||
it 'rejects private summary predicates instead of exposing a count oracle' do
|
||||
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(json).to eq('error' => 'private_condition')
|
||||
end
|
||||
|
||||
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||
|
||||
@@ -69,29 +69,27 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
end
|
||||
end
|
||||
|
||||
it 'counts one opaque collection per private version across pages' do
|
||||
it 'preserves five tag occurrences after merging private tags and across pages' do
|
||||
[nil, guest, member, admin].each do |user|
|
||||
page = result(user)
|
||||
expected = user == admin ? 5 : 6
|
||||
expect(grand(page)).to eq(expected)
|
||||
expect(page[:cells].sum { _1[:count] }).to eq(expected)
|
||||
expect(grand(page)).to eq(5)
|
||||
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
||||
expect(page).not_to have_key(:excluded_count)
|
||||
paged = result(user, 'limit' => '1')
|
||||
expect(grand(paged)).to eq(expected)
|
||||
expect(grand(paged)).to eq(5)
|
||||
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||
.to eq(page[:group_count])
|
||||
end
|
||||
public_page = result(nil)
|
||||
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||
expect(hidden.sum { _1[:count] }).to eq(4)
|
||||
expect(hidden.sum { _1[:count] }).to eq(3)
|
||||
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
||||
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||
end
|
||||
|
||||
it 'retains distinct membership when tags are not a row axis' do
|
||||
[nil, admin].each do |user|
|
||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
|
||||
.to eq(user == admin ? 3 : 5)
|
||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
||||
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||
@@ -123,13 +121,14 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||
[nil, admin].each do |user|
|
||||
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||
expect(grand(page)).to eq(user == admin ? 2 : 4)
|
||||
expect(grand(page)).to eq(2)
|
||||
if user.nil?
|
||||
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||
end
|
||||
# Private empty collections contribute the same opaque tuple as populated ones.
|
||||
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||
expect(grand(combined)).to eq(user == admin ? 0 : 4)
|
||||
# Changed discard snapshots have no tags: no full cell contributes here.
|
||||
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||
expect(empty[:cells]).to be_empty
|
||||
expect(grand(empty)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -143,7 +142,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||
end
|
||||
|
||||
it 'sorts public version numbers numerically including formerly hidden records' do
|
||||
it 'sorts public numeric values numerically alongside a masked group' do
|
||||
post = PostVersion.order(:id).last.post
|
||||
[2, 10].each do |number|
|
||||
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
||||
@@ -152,196 +151,27 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
end
|
||||
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||
expect(values.map(&:to_i)).to eq([1, 2, 10])
|
||||
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
||||
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
||||
end
|
||||
|
||||
it 'keeps public identity predicates and excludes private attributes' do
|
||||
hidden_id = PostVersion.first.post_id.to_s
|
||||
base = { 'rows' => '', 'cols' => '' }
|
||||
condition = lambda do |field, operator, value|
|
||||
{ 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator, 'conditions.0.value' => value }
|
||||
end
|
||||
|
||||
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
|
||||
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
|
||||
.sole[:count]).to eq(2)
|
||||
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
|
||||
.sole[:count]).to eq(5)
|
||||
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
|
||||
.sole[:count]).to eq(0)
|
||||
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
|
||||
.sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
|
||||
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'retains public OR branches while rejecting private predicates on hidden rows' do
|
||||
HistoryAccess.set(user: nil) do
|
||||
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
|
||||
authorisation_flg: false)
|
||||
condition = CountsCondition.new(population)
|
||||
public_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
|
||||
private_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
|
||||
masked_scope = condition.apply(population.scope,
|
||||
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
|
||||
expect(public_scope.except(:select).count).to eq(2)
|
||||
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
|
||||
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
|
||||
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
|
||||
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it 'excludes private collections from positive, negative, AND and OR tag searches' do
|
||||
settings = { 'rows' => '', 'cols' => '' }
|
||||
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
|
||||
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
|
||||
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
|
||||
end
|
||||
end
|
||||
|
||||
it 'ignores empty numeric and interval bounds without restricting private history' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
|
||||
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
|
||||
'tags.sections' => ['range'] }.each do |field, operators|
|
||||
operators.each do |operator|
|
||||
condition = settings.merge('conditions.0.field' => field,
|
||||
'conditions.0.operator' => operator)
|
||||
empty = condition.merge('conditions.0.value' => '')
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
|
||||
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
|
||||
.sole[:count]).to eq(5)
|
||||
if operator == 'range'
|
||||
empty['conditions.0.end'] = ''
|
||||
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||
['conditions.0.value', 'conditions.0.end'].each do |key|
|
||||
active = empty.merge(key => '100')
|
||||
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps typed marker sets active after incompatible marker values are removed' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
|
||||
'condition_values' => '', 'condition_values.0.condition' => '0',
|
||||
'condition_values.0.value_type' => 'string',
|
||||
'condition_values.0.value' => '********' }
|
||||
['in', 'not_in'].each do |operator|
|
||||
expected = operator == 'in' ? 0 : 1
|
||||
condition = settings.merge('conditions.0.operator' => operator)
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'restricts active numeric comparisons and preserves empty selection semantics' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'video_ms' }
|
||||
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.value' => '0')
|
||||
condition['conditions.0.end'] = '100' if operator == 'range'
|
||||
# The one public row is NULL; private NULLs and real values never match.
|
||||
expected = operator == 'ne' ? 1 : 0
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
['in', 'not_in'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = operator == 'not_in' ? 5 : 0
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
|
||||
end
|
||||
|
||||
it 'preserves empty tag sets and restricts active blank collection predicates' do
|
||||
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||
'conditions.0.field' => 'tags' }
|
||||
['any', 'all', 'none'].each do |operator|
|
||||
condition = settings.merge('conditions.0.operator' => operator,
|
||||
'conditions.0.values' => '1', 'condition_values' => '')
|
||||
expected = operator == 'any' ? 0 : 5
|
||||
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||
end
|
||||
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'rejects server-only coordinates in column order' do
|
||||
probes = [
|
||||
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
||||
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
||||
{ 'tags' => 'A' },
|
||||
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
||||
'conditions.0.value' => 'hidden-title-0' },
|
||||
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
||||
'drill.0.value' => "internal:#{ tags.first.id }" },
|
||||
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||
HistoryAccess.set(user: nil) do
|
||||
probes.each do |probe|
|
||||
expect { CountsTableQuery.new(params.merge(probe)).call }
|
||||
expect { CountsTableQuery.new(params.merge(probe)) }
|
||||
.to raise_error(CountsAxes::PrivateCondition)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
it 'keeps private collection and change counts independent of stored elements' do
|
||||
settings = [
|
||||
{ 'rows' => 'tags', 'cols' => 'changed_field' },
|
||||
{ 'rows' => 'changed_field tags', 'cols' => '' }]
|
||||
original = settings.map { result(nil, _1) }
|
||||
snapshot = tags.map do |tag|
|
||||
{ tag_id: tag.id, version_no: 1, sections: [] }
|
||||
end
|
||||
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
|
||||
PostVersion.where(post_id: hidden_posts).update_all(
|
||||
title: 'same hidden value', tags_json: snapshot)
|
||||
expect(settings.map { result(nil, _1) }).to eq(original)
|
||||
end
|
||||
|
||||
it 'excludes private tag and change coordinates from drill conditions' do
|
||||
['tags', 'changed_field'].each do |field|
|
||||
settings = { 'rows' => field, 'cols' => '' }
|
||||
page = result(nil, settings)
|
||||
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
|
||||
expect(cell[:count]).to eq(4)
|
||||
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
|
||||
drill = { 'drill' => '', 'drill.0.field' => field,
|
||||
'drill.0.value_type' => 'string', 'drill.0.value' => value }
|
||||
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
|
||||
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
|
||||
expect(details[:versions]).to be_empty
|
||||
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||
'conditions.0.operator' => 'axis_eq',
|
||||
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
|
||||
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects real private tag and change coordinates for non-admin users' do
|
||||
[nil, guest, member].each do |user|
|
||||
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|
||||
|field, value|
|
||||
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
|
||||
'drill.0.field' => field, 'drill.0.value_type' => 'string',
|
||||
'drill.0.value' => value }
|
||||
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
|
||||
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||
@@ -357,7 +187,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
||||
HistoryAccess.set(user: nil) do
|
||||
payload = params.merge('format' => 'json')
|
||||
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
|
||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
||||
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||
expect(csv).to include('********')
|
||||
|
||||
@@ -12,36 +12,24 @@ const api = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('PostFormTagsArea', () => {
|
||||
it ('replaces the selected token and restores the caret without losing other tags', async () => {
|
||||
it ('updates text and fetches autocomplete for the selected token', async () => {
|
||||
const setTags = vi.fn ()
|
||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
|
||||
|
||||
const { rerender } = renderWithProviders (
|
||||
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
|
||||
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
|
||||
|
||||
const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox')
|
||||
const textarea = screen.getByRole ('textbox')
|
||||
fireEvent.focus (textarea)
|
||||
fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } })
|
||||
rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>)
|
||||
fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } })
|
||||
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
|
||||
fireEvent.change (textarea, { target: { value: '虹夏' } })
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete', {
|
||||
locale: 'ja', params: { q: '虹な', nico: '0' },
|
||||
signal: expect.any (AbortSignal) })
|
||||
'/tags/autocomplete',
|
||||
{ locale: 'ja', params: { q: '虹', nico: '0' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続')
|
||||
|
||||
fireEvent.mouseDown (await screen.findByText ('虹夏'))
|
||||
expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続')
|
||||
rerender (<PostFormTagsArea tags="ぼっち 虹夏 後続" setTags={setTags}/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (textarea).toHaveFocus ()
|
||||
expect (textarea.selectionStart).toBe (7)
|
||||
expect (textarea.selectionEnd).toBe (7)
|
||||
})
|
||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||
expect (setTags).toHaveBeenCalledWith ('虹夏')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useRef, useState } from 'react'
|
||||
// TODO: TagSearch と共通化する.
|
||||
|
||||
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
|
||||
|
||||
@@ -38,13 +41,24 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | '
|
||||
|
||||
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
const { label } = useUITranslation ('navigation')
|
||||
const locale = useLocale ()
|
||||
const ref = useRef<HTMLTextAreaElement> (null)
|
||||
|
||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||
const [focused, setFocused] = useState (false)
|
||||
const [request, setRequest] = useState ({ query: '' })
|
||||
const [result, setResult] = useState<{
|
||||
locale: string
|
||||
request: { query: string }
|
||||
tags: Tag[] }> ({ locale, request, tags: [] })
|
||||
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
|
||||
const suggestions = (result.locale === locale && result.request === request
|
||||
&& request.query === currentToken) ? result.tags : []
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
const textarea = ref.current!
|
||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
||||
setTags (newValue)
|
||||
@@ -56,18 +70,52 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const { inputProps, suggestionProps, requestSuggestions } =
|
||||
useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect })
|
||||
|
||||
const recompute = (pos: number, v: string = tags) => {
|
||||
const { start, end, token } = getTokenAt (v, pos)
|
||||
requestSuggestions (token)
|
||||
setRequest ({ query: token })
|
||||
setSuggestionsVsbl (token.trim () !== '')
|
||||
if (!(token.trim ()))
|
||||
return
|
||||
{
|
||||
setSuggestionsVsbl (false)
|
||||
return
|
||||
}
|
||||
|
||||
setBounds ({ start, end })
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (request.query.trim () === '')
|
||||
return
|
||||
const controller = new AbortController ()
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, request,
|
||||
tags: tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setResult ({ locale, request, tags: [] })
|
||||
}
|
||||
}) ()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale])
|
||||
|
||||
useEffect (() => {
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}, [locale])
|
||||
|
||||
return (
|
||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
@@ -86,10 +134,15 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
onFocus={() => setFocused (true)}
|
||||
onBlur={() => {
|
||||
setFocused (false)
|
||||
inputProps.onBlur ()
|
||||
setSuggestionsVsbl (false)
|
||||
}}/>
|
||||
{focused && (
|
||||
<TagSearchBox {...suggestionProps}/>)}
|
||||
<TagSearchBox
|
||||
suggestions={suggestionsVsbl && suggestions.length > 0
|
||||
? suggestions
|
||||
: [] as Tag[]}
|
||||
activeIndex={-1}
|
||||
onSelect={handleTagSelect}/>)}
|
||||
</>)}
|
||||
</FormField>)
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagSearch from '@/components/TagSearch'
|
||||
import * as api from '@/lib/api'
|
||||
import { buildTag } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
|
||||
const LocationOutput = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
const renderSearch = () => renderWithProviders (
|
||||
<>
|
||||
<TagSearch/>
|
||||
<LocationOutput/>
|
||||
</>, { route: '/ja/posts?page=3' })
|
||||
|
||||
|
||||
describe ('TagSearch', () => {
|
||||
beforeEach (() => {
|
||||
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
|
||||
})
|
||||
|
||||
it ('searches through native submission without an Enter key event', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
||||
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
|
||||
|
||||
expect (input.form).toBeInstanceOf (HTMLFormElement)
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
expect (input).toHaveValue ('虹夏 not:test type:or')
|
||||
})
|
||||
|
||||
it ('requests a search action from the software keyboard', () => {
|
||||
renderSearch ()
|
||||
|
||||
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
|
||||
})
|
||||
|
||||
it ('searches through the form when Enter is pressed', async () => {
|
||||
const user = userEvent.setup ()
|
||||
renderSearch ()
|
||||
|
||||
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
})
|
||||
|
||||
it.each ([
|
||||
{ label: 'composition flag', composing: true, keyCode: 13 },
|
||||
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
|
||||
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole ('textbox')
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
const uncancelled = fireEvent.keyDown (input, {
|
||||
key: 'Enter', isComposing: composing, keyCode })
|
||||
|
||||
expect (uncancelled).toBe (false)
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
})
|
||||
|
||||
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole ('textbox')
|
||||
fireEvent.compositionStart (input)
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
const uncancelled = fireEvent.keyDown (input, {
|
||||
key: 'Enter', isComposing: false, keyCode: 13 })
|
||||
|
||||
expect (uncancelled).toBe (false)
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
})
|
||||
|
||||
it ('blocks direct submission during composition and allows it after confirmation', () => {
|
||||
renderSearch ()
|
||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
||||
fireEvent.compositionStart (input)
|
||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (input).toHaveValue ('虹夏')
|
||||
|
||||
fireEvent.compositionEnd (input)
|
||||
fireEvent.submit (input.form!)
|
||||
|
||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
||||
`/ja/posts?${ query }`)
|
||||
})
|
||||
|
||||
it ('selects the active suggestion without submitting the form', async () => {
|
||||
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
|
||||
const user = userEvent.setup ()
|
||||
renderSearch ()
|
||||
|
||||
await user.type (screen.getByRole ('textbox'), '虹')
|
||||
await screen.findByText ('虹夏')
|
||||
await user.keyboard ('{ArrowDown}{Enter}')
|
||||
|
||||
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('aborts obsolete requests and ignores their late suggestions', async () => {
|
||||
const responses: ((tags: Tag[]) => void)[] = []
|
||||
vi.mocked (api.apiGet).mockImplementation (() => new Promise<Tag[]> (resolve => {
|
||||
responses.push (resolve)
|
||||
}))
|
||||
const { unmount } = renderSearch ()
|
||||
const input = screen.getByRole ('textbox')
|
||||
|
||||
fireEvent.change (input, { target: { value: '虹' } })
|
||||
const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
|
||||
expect (previousSignal).toBeInstanceOf (AbortSignal)
|
||||
expect (previousSignal?.aborted).toBe (false)
|
||||
|
||||
fireEvent.change (input, { target: { value: 'ぼ' } })
|
||||
expect (previousSignal?.aborted).toBe (true)
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', {
|
||||
locale: 'ja', params: { q: 'ぼ', nico: '1' },
|
||||
signal: expect.any (AbortSignal) })
|
||||
const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
|
||||
|
||||
await act (async () => {
|
||||
responses[1] ([buildTag ({ name: 'ぼっち' })])
|
||||
})
|
||||
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
|
||||
|
||||
await act (async () => {
|
||||
responses[0] ([buildTag ({ name: '虹夏' })])
|
||||
})
|
||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
|
||||
|
||||
unmount ()
|
||||
expect (currentSignal?.aborted).toBe (true)
|
||||
})
|
||||
})
|
||||
@@ -1,59 +1,136 @@
|
||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import { useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { ChangeEvent, FC, FormEvent } from 'react'
|
||||
import TagSearchBox from './TagSearchBox'
|
||||
|
||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
|
||||
const TagSearch: FC = () => {
|
||||
const { message } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [search, setSearch] = useState ('')
|
||||
const [request, setRequest] = useState ({ query: '' })
|
||||
const [result, setResult] = useState<{
|
||||
locale: string
|
||||
request: { query: string }
|
||||
tags: Tag[] }> ({ locale, request, tags: [] })
|
||||
const suggestions = result.locale === locale && result.request === request
|
||||
? result.tags : []
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
|
||||
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = ev.target.value
|
||||
const query = value.trim ().split (' ').at (-1) ?? ''
|
||||
setSearch (value)
|
||||
setRequest ({ query })
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (query !== '')
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (request.query === '')
|
||||
return
|
||||
const controller = new AbortController ()
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||
locale, signal: controller.signal, params: { q: request.query } })
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, request,
|
||||
tags: tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setResult ({ locale, request, tags: [] })
|
||||
}
|
||||
}) ()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale])
|
||||
|
||||
useEffect (() => {
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (false)
|
||||
}, [locale])
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (ev.key)
|
||||
{
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault ()
|
||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||||
setSuggestionsVsbl (true)
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault ()
|
||||
setActiveIndex (i => Math.max (i - 1, -1))
|
||||
setSuggestionsVsbl (true)
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (activeIndex < 0)
|
||||
break
|
||||
ev.preventDefault ()
|
||||
{
|
||||
const selected = suggestions[activeIndex]
|
||||
if (selected)
|
||||
handleTagSelect (selected)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
ev.preventDefault ()
|
||||
setSuggestionsVsbl (false)
|
||||
break
|
||||
}
|
||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||
{
|
||||
const parts = search.split (' ')
|
||||
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||
navigate (`/posts?${
|
||||
(new URLSearchParams ({
|
||||
tags: parts.map (t => t.toLowerCase ())
|
||||
.filter (t => t !== 'type:or')
|
||||
.join (' '),
|
||||
match }))
|
||||
.toString () }`)
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
const parts = search.split (' ')
|
||||
parts[parts.length - 1] = (
|
||||
(parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:')
|
||||
(parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:')
|
||||
? ('not:' + tag.name)
|
||||
: tag.name)
|
||||
setSearch (parts.join (' ') + ' ')
|
||||
}
|
||||
|
||||
const { inputProps, suggestionProps, requestSuggestions,
|
||||
clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
|
||||
token: search.trim ().split (' ').at (-1) ?? '',
|
||||
nicoFlg: true,
|
||||
onSelect: handleTagSelect })
|
||||
|
||||
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearch (value)
|
||||
requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault ()
|
||||
if (compositionFlgRef.current)
|
||||
return
|
||||
|
||||
const parts = search.split (' ')
|
||||
const match =
|
||||
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||
navigate (`/posts?${
|
||||
(new URLSearchParams ({
|
||||
tags: parts.map (t => t.toLowerCase ())
|
||||
.filter (t => t !== 'type:or')
|
||||
.join (' '),
|
||||
match })).toString () }`)
|
||||
clearSuggestions ()
|
||||
setRequest ({ query: '' })
|
||||
setSuggestionsVsbl (false)
|
||||
setActiveIndex (-1)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
@@ -61,22 +138,27 @@ const TagSearch: FC = () => {
|
||||
const anyFlg = query.get ('match') === 'any'
|
||||
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
||||
setSearch (tagsQuery)
|
||||
clearSuggestions ()
|
||||
}, [location.search, clearSuggestions])
|
||||
setRequest ({ query: '' })
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (false)
|
||||
}, [location.search])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative w-full">
|
||||
<div className="relative w-full">
|
||||
<input type="text"
|
||||
{...inputProps}
|
||||
enterKeyHint="search"
|
||||
data-shortcut-focus-search="true"
|
||||
placeholder={message ('labels.tagSearch').text}
|
||||
value={search}
|
||||
onChange={whenChanged}
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
onBlur={() => setSuggestionsVsbl (false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass (false,
|
||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||
<TagSearchBox {...suggestionProps}/>
|
||||
</form>)
|
||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={handleTagSelect}/>
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default TagSearch
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
@@ -20,20 +20,18 @@ describe ('TagInput', () => {
|
||||
const setValue = vi.fn ()
|
||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
|
||||
|
||||
const { rerender } = render (
|
||||
<TagInput value="ぼっち 虹" setValue={setValue}/>)
|
||||
render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
|
||||
rerender (<TagInput value="ぼっち 虹夏" setValue={setValue}/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete', {
|
||||
locale: 'ja', params: { q: '虹夏', nico: '0' },
|
||||
signal: expect.any (AbortSignal) })
|
||||
'/tags/autocomplete',
|
||||
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
||||
expect (await screen.findByText ('虹夏')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not fetch when the last token is blank', () => {
|
||||
@@ -56,51 +54,10 @@ describe ('TagInput', () => {
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete', {
|
||||
locale: 'ja', params: { q: '虹夏', nico: '1' },
|
||||
signal: expect.any (AbortSignal) })
|
||||
'/tags/autocomplete',
|
||||
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it ('preserves negation and blocks suggestion selection during composition', async () => {
|
||||
const setValue = vi.fn ()
|
||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })])
|
||||
const { rerender } = render (<TagInput value="" setValue={setValue}/>)
|
||||
const input = screen.getByRole ('textbox')
|
||||
|
||||
fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } })
|
||||
rerender (<TagInput value="ぼっち NOT:虹" setValue={setValue}/>)
|
||||
await screen.findByText ('虹夏')
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||
'/tags/autocomplete', {
|
||||
locale: 'ja', params: { q: '虹', nico: '0' },
|
||||
signal: expect.any (AbortSignal) })
|
||||
|
||||
fireEvent.keyDown (input, { key: 'ArrowDown' })
|
||||
fireEvent.compositionStart (input)
|
||||
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false)
|
||||
expect (setValue).toHaveBeenCalledTimes (1)
|
||||
|
||||
fireEvent.compositionEnd (input)
|
||||
fireEvent.keyDown (input, { key: 'Enter' })
|
||||
|
||||
expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ')
|
||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => {
|
||||
const tag = buildTag ({ name: '虹夏', postCount: 0 })
|
||||
const response = Promise.resolve ([tag])
|
||||
api.apiGet.mockReturnValueOnce (response)
|
||||
const props = { setValue: vi.fn (), includeUnused: unusedFlg }
|
||||
const { rerender } = render (<TagInput {...props} value=""/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } })
|
||||
rerender (<TagInput {...props} value="虹"/>)
|
||||
await act (async () => {
|
||||
await response
|
||||
})
|
||||
|
||||
expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC, ChangeEvent } from 'react'
|
||||
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
@@ -16,39 +19,136 @@ type Props = {
|
||||
setValue: (value: string) => void }
|
||||
|
||||
|
||||
const TagInput: FC<Props> = (
|
||||
{ includeNico: nicoFlg = false,
|
||||
includeUnused: unusedFlg = false,
|
||||
describedBy, invalid, value, setValue },
|
||||
) => {
|
||||
const TagInput: FC<Props> = ({
|
||||
includeNico = false,
|
||||
includeUnused = false,
|
||||
describedBy,
|
||||
invalid,
|
||||
value,
|
||||
setValue,
|
||||
}) => {
|
||||
const locale = useLocale ()
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [request, setRequest] = useState ({ query: '' })
|
||||
const [result, setResult] = useState<{
|
||||
locale: string
|
||||
includeNico: boolean
|
||||
request: { query: string }
|
||||
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
|
||||
const suggestions = (result.locale === locale && result.includeNico === includeNico
|
||||
&& result.request === request
|
||||
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
|
||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue (ev.target.value)
|
||||
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
|
||||
setRequest ({ query })
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (query !== '')
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (request.query === '')
|
||||
return
|
||||
const controller = new AbortController ()
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||
locale, signal: controller.signal,
|
||||
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, includeNico, request,
|
||||
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setResult ({ locale, includeNico, request, tags: [] })
|
||||
}
|
||||
}) ()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale, includeNico, includeUnused])
|
||||
|
||||
useEffect (() => {
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}, [locale, includeNico])
|
||||
|
||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
const parts = value?.split (' ')
|
||||
const negative = /^not:/i.test (parts[parts.length - 1])
|
||||
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
||||
setValue (parts.join (' ') + ' ')
|
||||
setRequest ({ query: '' })
|
||||
setActiveIndex (-1)
|
||||
}
|
||||
|
||||
const { inputProps, suggestionProps, requestSuggestions } =
|
||||
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
|
||||
nicoFlg, unusedFlg, onSelect: handleTagSelect })
|
||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (ev.key)
|
||||
{
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault ()
|
||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||||
setSuggestionsVsbl (true)
|
||||
break
|
||||
|
||||
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue (event.target.value)
|
||||
const token = event.target.value.trim ().split (' ').at (-1) ?? ''
|
||||
requestSuggestions (token, token.replace (/^not:/i, ''))
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault ()
|
||||
setActiveIndex (i => Math.max (i - 1, -1))
|
||||
setSuggestionsVsbl (true)
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (activeIndex < 0)
|
||||
break
|
||||
{
|
||||
ev.preventDefault ()
|
||||
const selected = suggestions[activeIndex]
|
||||
if (selected)
|
||||
handleTagSelect (selected)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
ev.preventDefault ()
|
||||
setSuggestionsVsbl (false)
|
||||
break
|
||||
}
|
||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||
{
|
||||
setSuggestionsVsbl (false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
{...inputProps}
|
||||
type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={value}
|
||||
onChange={whenChanged}
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
onBlur={() => setSuggestionsVsbl (false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={inputClass (invalid)}/>
|
||||
<TagSearchBox {...suggestionProps}/>
|
||||
<TagSearchBox
|
||||
suggestions={
|
||||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={handleTagSelect}/>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,17 +11,6 @@ import type { Deerjikist,
|
||||
TagVersion } from '@/types'
|
||||
|
||||
|
||||
export const fetchTagAutocomplete = async (
|
||||
query: string,
|
||||
nicoFlg: boolean,
|
||||
locale: LocaleCode,
|
||||
signal: AbortSignal,
|
||||
): Promise<Tag[]> =>
|
||||
await apiGet ('/tags/autocomplete', {
|
||||
locale, signal,
|
||||
params: { q: query, nico: nicoFlg ? '1' : '0' } })
|
||||
|
||||
|
||||
export const fetchTags = async (
|
||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, deprecated,
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { fetchTagAutocomplete } from '@/lib/tags'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
|
||||
export const useTagAutocomplete = (
|
||||
{ token, onSelect, nicoFlg = false, unusedFlg = false }: {
|
||||
token: string
|
||||
onSelect: (tag: Tag) => void
|
||||
nicoFlg?: boolean
|
||||
unusedFlg?: boolean },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const compositionFlgRef = useRef (false)
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [visible, setVisible] = useState (false)
|
||||
const [request, setRequest] = useState ({ token: '', query: '' })
|
||||
const [result, setResult] = useState<{
|
||||
locale: string
|
||||
nicoFlg: boolean
|
||||
unusedFlg: boolean
|
||||
request: { token: string; query: string }
|
||||
tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] })
|
||||
const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg
|
||||
&& result.unusedFlg === unusedFlg && result.request === request
|
||||
&& request.token === token) ? result.tags : []
|
||||
|
||||
const requestSuggestions = (token: string, query: string = token) => {
|
||||
setRequest ({ token, query })
|
||||
setActiveIndex (-1)
|
||||
setVisible (token.trim () !== '')
|
||||
}
|
||||
|
||||
const clearSuggestions = useCallback (() => {
|
||||
setRequest ({ token: '', query: '' })
|
||||
setActiveIndex (-1)
|
||||
setVisible (false)
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
if (request.token.trim () === '')
|
||||
return
|
||||
const controller = new AbortController ()
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const tags = await fetchTagAutocomplete (
|
||||
request.query, nicoFlg, locale, controller.signal)
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, nicoFlg, unusedFlg, request,
|
||||
tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] })
|
||||
}
|
||||
}) ()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale, nicoFlg, unusedFlg])
|
||||
|
||||
useEffect (() => {
|
||||
clearSuggestions ()
|
||||
}, [locale, nicoFlg, clearSuggestions])
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
clearSuggestions ()
|
||||
onSelect (tag)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (compositionFlgRef.current
|
||||
|| event.nativeEvent.isComposing
|
||||
|| event.nativeEvent.keyCode === 229)
|
||||
{
|
||||
if (event.key === 'Enter')
|
||||
event.preventDefault ()
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key)
|
||||
{
|
||||
case 'ArrowDown':
|
||||
event.preventDefault ()
|
||||
setActiveIndex (index => Math.min (index + 1, suggestions.length - 1))
|
||||
setVisible (true)
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault ()
|
||||
setActiveIndex (index => Math.max (index - 1, -1))
|
||||
setVisible (true)
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
{
|
||||
const selected = visible ? suggestions[activeIndex] : null
|
||||
if (selected == null)
|
||||
{
|
||||
setVisible (false)
|
||||
break
|
||||
}
|
||||
event.preventDefault ()
|
||||
handleTagSelect (selected)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault ()
|
||||
setVisible (false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestSuggestions, clearSuggestions, compositionFlgRef,
|
||||
inputProps: {
|
||||
onFocus: () => setVisible (true),
|
||||
onBlur: () => setVisible (false),
|
||||
onKeyDown: handleKeyDown,
|
||||
onCompositionStart: () => {
|
||||
compositionFlgRef.current = true
|
||||
},
|
||||
onCompositionEnd: () => {
|
||||
compositionFlgRef.current = false
|
||||
} },
|
||||
suggestionProps: {
|
||||
suggestions: visible ? suggestions : [],
|
||||
activeIndex,
|
||||
onSelect: handleTagSelect } }
|
||||
}
|
||||
新しいイシューから参照
ユーザーをブロックする