コミットを比較
6
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
c897ea8f8f | ||
|
|
4a7296eea0 | ||
|
|
2b18d72f7a | ||
|
|
f05d8a4b3a | ||
|
|
dbae864f24 | ||
|
|
8d00a6545e |
@@ -1,4 +1,34 @@
|
|||||||
class TagDescriptionsController < ApplicationController
|
class TagDescriptionsController < ApplicationController
|
||||||
|
rescue_from TagDescriptionOperation::Conflict, with: :render_conflict
|
||||||
|
|
||||||
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
descriptions = HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||||
|
.where(language_code: locale.language_code).includes(tag: :tag_names)
|
||||||
|
.order(updated_at: :desc, id: :asc)
|
||||||
|
title = params[:title].to_s.strip
|
||||||
|
if title.present?
|
||||||
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
|
descriptions = descriptions.joins(tag: :tag_names)
|
||||||
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
|
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
|
end
|
||||||
|
render json: descriptions.map { WikiPageRepr.tag_description(locale, _1) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_by_id
|
||||||
|
locale = resolve_locale!
|
||||||
|
reference = TagDescription.find_by(id: params[:description_id])
|
||||||
|
return head :not_found unless reference
|
||||||
|
|
||||||
|
HistoryAccess.require_current!(reference, TagDescriptionVersion)
|
||||||
|
description = TagDescription.find_by(tag_id: reference.tag_id,
|
||||||
|
language_code: locale.language_code)
|
||||||
|
return head :not_found unless description
|
||||||
|
|
||||||
|
render json: representation(description)
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
|
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
|
||||||
@@ -18,6 +48,25 @@ class TagDescriptionsController < ApplicationController
|
|||||||
render json: representation(description)
|
render json: representation(description)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
title = params[:title].to_s.strip
|
||||||
|
body = params[:body].to_s
|
||||||
|
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||||
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
return render_unprocessable_entity field: :title if title.include?(':')
|
||||||
|
|
||||||
|
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
|
||||||
|
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||||
|
|
||||||
|
description = TagDescriptionOperation.create!(
|
||||||
|
locale, tag: tag_name.tag, body:, created_by_user: current_user)
|
||||||
|
render json: representation(description), status: :created
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
@@ -32,7 +81,9 @@ class TagDescriptionsController < ApplicationController
|
|||||||
description = TagDescription.find_by(tag:, language_code: locale.language_code)
|
description = TagDescription.find_by(tag:, language_code: locale.language_code)
|
||||||
|
|
||||||
if description
|
if description
|
||||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
TagDescriptionOperation.update_with_title!(
|
||||||
|
locale, tag:, body:, title: params[:title], actor: current_user,
|
||||||
|
base_revision_id: params[:base_revision_id].presence)
|
||||||
description.reload
|
description.reload
|
||||||
render json: representation(description)
|
render json: representation(description)
|
||||||
else
|
else
|
||||||
@@ -48,8 +99,20 @@ class TagDescriptionsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def render_conflict error
|
||||||
|
render json: { error: 'conflict', message: error.message }, status: :conflict
|
||||||
|
end
|
||||||
|
|
||||||
def representation description
|
def representation description
|
||||||
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||||
|
if params[:document] == '1'
|
||||||
|
locale = resolve_locale!
|
||||||
|
version = HistoryAccess.visible(TagDescriptionVersion.all).find_by!(
|
||||||
|
tag_id: description.tag_id, language_code: description.language_code,
|
||||||
|
version_no: description.version_no)
|
||||||
|
return WikiPageRepr.tag_description(locale, description).merge(revision_id: version.id)
|
||||||
|
end
|
||||||
|
|
||||||
description.as_json(only: [
|
description.as_json(only: [
|
||||||
:tag_id,
|
:tag_id,
|
||||||
:language_code,
|
:language_code,
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
class TagGroupsController < ApplicationController
|
class TagGroupsController < ApplicationController
|
||||||
|
rescue_from TagGroupMarkdownParser::InvalidDocument,
|
||||||
|
TagGroupMarkdownRenderer::UnsupportedStructure, with: :render_document_error
|
||||||
|
|
||||||
def index
|
def index
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
|
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion)
|
||||||
|
.includes(:created_by_user, :updated_by_user)
|
||||||
|
if params[:document] == '1'
|
||||||
|
groups = groups.order(updated_at: :desc, id: :asc)
|
||||||
|
title = params[:title].to_s.strip
|
||||||
|
if title.present?
|
||||||
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
|
groups = groups.where('CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||||
|
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||||
|
end
|
||||||
|
return render json: groups.map { WikiPageRepr.tag_group(_1) }
|
||||||
|
end
|
||||||
|
groups = groups.order(:id)
|
||||||
render json: TagGroupRepr.many(locale, groups.to_a)
|
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -10,7 +25,15 @@ class TagGroupsController < ApplicationController
|
|||||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||||
return head :not_found unless group
|
return head :not_found unless group
|
||||||
|
|
||||||
render json: TagGroupRepr.one(locale, group)
|
render json: representation(locale, group)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_by_name
|
||||||
|
locale = resolve_locale!
|
||||||
|
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(name: params[:name])
|
||||||
|
return head :not_found unless group
|
||||||
|
|
||||||
|
render json: representation(locale, group)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -18,8 +41,8 @@ class TagGroupsController < ApplicationController
|
|||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
group = TagGroupOperation.create!(locale, **write_attributes(locale), actor: current_user)
|
||||||
render json: TagGroupRepr.one(locale, group), status: :created
|
render json: representation(locale, group), status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -30,13 +53,39 @@ class TagGroupsController < ApplicationController
|
|||||||
group = TagGroup.find_by(id: params[:id])
|
group = TagGroup.find_by(id: params[:id])
|
||||||
return head :not_found unless group
|
return head :not_found unless group
|
||||||
|
|
||||||
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
TagGroupOperation.update!(locale, group:, **write_attributes(locale, group:), actor: current_user)
|
||||||
render json: TagGroupRepr.one(locale, group.reload)
|
render json: representation(locale, group.reload)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def write_attributes
|
def render_document_error error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def representation locale, group
|
||||||
|
representation = TagGroupRepr.one(locale, group)
|
||||||
|
return representation unless params[:document] == '1'
|
||||||
|
|
||||||
|
version = TagGroupRepr.current_version(group)
|
||||||
|
raise ActiveRecord::RecordNotFound unless version
|
||||||
|
|
||||||
|
TagGroupRepr.document(representation, version)
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_attributes locale, group: nil
|
||||||
|
if params.key?(:body)
|
||||||
|
body = params[:body].to_s
|
||||||
|
if body.blank?
|
||||||
|
group ||= TagGroup.new
|
||||||
|
group.errors.add(:body, :blank)
|
||||||
|
raise ActiveRecord::RecordInvalid, group
|
||||||
|
end
|
||||||
|
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
|
||||||
|
return parsed.except(:compatibility_body).merge(
|
||||||
|
name: params[:name], require_compatibility: true)
|
||||||
|
end
|
||||||
|
|
||||||
{ name: params[:name], description: params[:description],
|
{ name: params[:name], description: params[:description],
|
||||||
members: params[:members], placements: params[:placements] }
|
members: params[:members], placements: params[:placements] }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ class WikiPagesController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
|
|
||||||
|
resource_type = params[:resource_type]
|
||||||
|
unless resource_type.nil? || ['tag_description', 'tag_group'].include?(resource_type)
|
||||||
|
return head :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
descriptions =
|
descriptions =
|
||||||
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||||
@@ -14,6 +19,8 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||||
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
|
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
|
||||||
|
descriptions = descriptions.none if resource_type == 'tag_group'
|
||||||
|
tag_groups = tag_groups.none if resource_type == 'tag_description'
|
||||||
if title.present?
|
if title.present?
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
descriptions =
|
descriptions =
|
||||||
@@ -246,25 +253,8 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
base_revision_id = params[:base_revision_id].presence
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
TagDescriptionOperation.update_with_title!(
|
||||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
locale, tag:, body:, title:, actor: current_user, base_revision_id:)
|
||||||
tag.lock!
|
|
||||||
old_title = tag_name.name
|
|
||||||
if title != old_title
|
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
tag_name.update!(name: title) if title != old_title
|
|
||||||
|
|
||||||
message = params[:message].presence
|
|
||||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
|
||||||
message:, base_revision_id:)
|
|
||||||
|
|
||||||
if title != old_title
|
|
||||||
tag.reload
|
|
||||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -326,21 +316,9 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_group_representation representation, version, body: nil, status: :ok
|
def render_group_representation representation, version, body: nil, status: :ok
|
||||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
document = TagGroupRepr.document(representation, version)
|
||||||
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
|
document['body'] = body if body
|
||||||
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
render json: document, status:
|
||||||
succ = versions.find_by(version_no: version.version_no + 1)&.id
|
|
||||||
updated_at = version.created_at
|
|
||||||
render json: {
|
|
||||||
'id' => version.tag_group_id,
|
|
||||||
'resource_type' => 'tag_group',
|
|
||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
|
||||||
'deprecated_at' => nil,
|
|
||||||
'body' => body,
|
|
||||||
'revision_id' => version.id,
|
|
||||||
'pred' => pred,
|
|
||||||
'succ' => succ,
|
|
||||||
'updated_at' => updated_at }, status:
|
|
||||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||||
render_unprocessable_entity(error.message)
|
render_unprocessable_entity(error.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ require 'digest'
|
|||||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||||
attribute :user
|
attribute :user
|
||||||
class Forbidden < StandardError; end
|
class Forbidden < StandardError; end
|
||||||
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
||||||
|
|
||||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||||
'nico_tag_versions' => ['tag_id'],
|
'nico_tag_versions' => ['tag_id'],
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ module TagGroupRepr
|
|||||||
'updated_at' => wiki_page.updated_at }
|
'updated_at' => wiki_page.updated_at }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def document representation, version
|
||||||
|
HistoryAccess.require!(version)
|
||||||
|
versions = HistoryAccess.visible(TagGroupVersion.all)
|
||||||
|
.where(tag_group_id: version.tag_group_id)
|
||||||
|
{ 'id' => version.tag_group_id,
|
||||||
|
'resource_type' => 'tag_group',
|
||||||
|
'title' => "#{ PREFIX }#{ representation.fetch('name') }",
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'body' => TagGroupMarkdownRenderer.render(representation),
|
||||||
|
'revision_id' => version.id,
|
||||||
|
'pred' => versions.find_by(version_no: version.version_no - 1)&.id,
|
||||||
|
'succ' => versions.find_by(version_no: version.version_no + 1)&.id,
|
||||||
|
'updated_at' => version.created_at }
|
||||||
|
end
|
||||||
|
|
||||||
def current_version group
|
def current_version group
|
||||||
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
|
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
class CountsAxes
|
class CountsAxes
|
||||||
MAX_AXES = 8
|
MAX_AXES = 8
|
||||||
|
MASKED_TEXT = '********'.freeze
|
||||||
MASKED_VALUE = '__counts_private__'.freeze
|
MASKED_VALUE = '__counts_private__'.freeze
|
||||||
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
|
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
||||||
|
'created_at'].freeze
|
||||||
class TooManyAxes < ArgumentError; end
|
class TooManyAxes < ArgumentError; end
|
||||||
class PrivateCondition < ArgumentError; end
|
class PrivateCondition < ArgumentError; end
|
||||||
|
|
||||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
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)
|
def self.time_expression(expression, grain, timezone)
|
||||||
raise ArgumentError unless GRAINS.include?(grain)
|
raise ArgumentError unless GRAINS.include?(grain)
|
||||||
|
|
||||||
@@ -60,13 +89,9 @@ class CountsAxes
|
|||||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||||
end
|
end
|
||||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
if !HistoryAccess.admin? && self.class.private_field?(field)
|
||||||
visible = HistoryAccess.sql(@definition.model, 'v')
|
expression = self.class.masked_sql(
|
||||||
if field == 'changed_field'
|
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
||||||
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
|
||||||
end
|
|
||||||
expression = "CASE WHEN #{ visible } " +
|
|
||||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
|
||||||
end
|
end
|
||||||
expression
|
expression
|
||||||
end
|
end
|
||||||
@@ -77,10 +102,12 @@ class CountsAxes
|
|||||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||||
identities = ['v.id']
|
identities = ['v.id']
|
||||||
if rows.any? { _1['field'] == 'tags' }
|
if rows.any? { _1['field'] == 'tags' }
|
||||||
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
identities << self.class.masked_sql(
|
||||||
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
"CONCAT('external:', tag_entry.external_tag_id))"
|
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
||||||
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
if rows.any? { _1['field'] == 'changed_field' }
|
||||||
|
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||||
selected += identities.each_with_index.map { |expression, index|
|
selected += identities.each_with_index.map { |expression, index|
|
||||||
@@ -98,8 +125,17 @@ class CountsAxes
|
|||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
result = ''
|
result = ''
|
||||||
if fields.include?('tags')
|
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
|
result += <<~SQL.squish
|
||||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
||||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||||
|
|
||||||
@@ -112,6 +148,13 @@ class CountsAxes
|
|||||||
condition = @definition.changes.map do |field|
|
condition = @definition.changes.map do |field|
|
||||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||||
end.join(' OR ')
|
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 })"
|
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -82,14 +82,32 @@ class CountsCondition
|
|||||||
|
|
||||||
def apply(scope, entry)
|
def apply(scope, entry)
|
||||||
raise ArgumentError unless entry.is_a?(Hash)
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
|
||||||
if entry['operator'] == 'axis_eq'
|
if entry['operator'] == 'axis_eq'
|
||||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||||
value = entry['value']
|
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
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
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)
|
return @population.axis_condition(scope, entry)
|
||||||
end
|
end
|
||||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||||
@@ -111,6 +129,13 @@ class CountsCondition
|
|||||||
|
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
type = field[:type]
|
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)
|
blank = blank_sql(expression, type)
|
||||||
if entry['blank']
|
if entry['blank']
|
||||||
sql = if source && field[:collection]
|
sql = if source && field[:collection]
|
||||||
@@ -119,6 +144,7 @@ class CountsCondition
|
|||||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||||
else blank
|
else blank
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
return scope.where(Arel.sql(sql))
|
return scope.where(Arel.sql(sql))
|
||||||
end
|
end
|
||||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||||
@@ -131,6 +157,7 @@ class CountsCondition
|
|||||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if type == :interval
|
if type == :interval
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
return interval(scope, source, expression, parsed)
|
return interval(scope, source, expression, parsed)
|
||||||
end
|
end
|
||||||
clauses = []
|
clauses = []
|
||||||
@@ -158,6 +185,7 @@ class CountsCondition
|
|||||||
elsif operator == 'ne'
|
elsif operator == 'ne'
|
||||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
scope.where(predicate, *binds)
|
scope.where(predicate, *binds)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,6 +196,13 @@ class CountsCondition
|
|||||||
|
|
||||||
values = entry['values']
|
values = entry['values']
|
||||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
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 != '' }
|
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||||
|
|
||||||
parsed = values.map do |value|
|
parsed = values.map do |value|
|
||||||
@@ -191,7 +226,10 @@ class CountsCondition
|
|||||||
|
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if parsed.empty?
|
if parsed.empty?
|
||||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
return scope.none if ['in', 'any'].include?(operator)
|
||||||
|
return scope if entry['values'].empty?
|
||||||
|
|
||||||
|
return @population.condition_scope(scope, field[:key])
|
||||||
end
|
end
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
if field[:set_type] == 'tag_reference'
|
if field[:set_type] == 'tag_reference'
|
||||||
@@ -212,6 +250,7 @@ class CountsCondition
|
|||||||
predicate = source ? "NOT #{ predicate }" :
|
predicate = source ? "NOT #{ predicate }" :
|
||||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
scope.where(predicate, *parsed)
|
scope.where(predicate, *parsed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class CountsPopulation
|
|||||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
end
|
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)
|
def axis_condition(scope, entry)
|
||||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
@@ -209,6 +216,7 @@ class CountsPopulation
|
|||||||
def apply_tags
|
def apply_tags
|
||||||
raise ArgumentError unless definition.resource == 'posts'
|
raise ArgumentError unless definition.resource == 'posts'
|
||||||
|
|
||||||
|
@scope = condition_scope(@scope, 'tags')
|
||||||
if params['tag'].present?
|
if params['tag'].present?
|
||||||
@scope = @scope.where(snapshot_membership(
|
@scope = @scope.where(snapshot_membership(
|
||||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
|
|
||||||
def summary_value(value, index, page)
|
def summary_value(value, index, page)
|
||||||
return '********' if value == CountsAxes::MASKED_VALUE
|
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
field = @query.axes.all.fetch(index).fetch('field')
|
field = @query.axes.all.fetch(index).fetch('field')
|
||||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||||
|
|||||||
@@ -220,19 +220,11 @@ class CountsTableQuery
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Public timeline predicates may include hidden records. Private predicates must
|
# Private predicates require visibility independently of their operator.
|
||||||
# never become a count oracle, even when the selected axes themselves are public.
|
# Ordering/measure state must not select a server-only masked coordinate.
|
||||||
def validate_summary_conditions!
|
def validate_summary_conditions!
|
||||||
return if mode != 'summary' || HistoryAccess.admin?
|
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|
|
['column_order', 'measures'].each do |key|
|
||||||
# Coordinates are compared only to already masked expressions, never raw
|
# Coordinates are compared only to already masked expressions, never raw
|
||||||
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||||
|
|||||||
@@ -37,6 +37,37 @@ class TagDescriptionOperation
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.update_with_title! locale, tag:, body:, title:, actor:, base_revision_id: nil
|
||||||
|
return update!(locale, tag:, body:, updated_by_user: actor,
|
||||||
|
base_revision_id:) if title.nil?
|
||||||
|
|
||||||
|
title = title.to_s.strip
|
||||||
|
tag_name = tag.tag_name(locale.language_code)
|
||||||
|
if title.blank? || title.include?(':') || !tag_name || tag_name.name.include?(':')
|
||||||
|
tag.errors.add(:title, :invalid)
|
||||||
|
raise ActiveRecord::RecordInvalid, tag
|
||||||
|
end
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tag.lock!
|
||||||
|
old_title = tag_name.name
|
||||||
|
if title != old_title
|
||||||
|
TagVersioning.ensure_snapshot!(tag, created_by_user: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_name.update!(name: title) if title != old_title
|
||||||
|
|
||||||
|
update!(locale, tag:, body:, updated_by_user: actor,
|
||||||
|
base_revision_id:)
|
||||||
|
|
||||||
|
if title != old_title
|
||||||
|
tag.reload
|
||||||
|
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.record_version! description:, body:, user:
|
def self.record_version! description:, body:, user:
|
||||||
creating = description.new_record?
|
creating = description.new_record?
|
||||||
version_no = creating ? 1 : description.version_no + 1
|
version_no = creating ? 1 : description.version_no + 1
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ Rails.application.routes.draw do
|
|||||||
resources :locales, only: [:index]
|
resources :locales, only: [:index]
|
||||||
resources :sitemap_entries, only: [:index, :show], param: :resource
|
resources :sitemap_entries, only: [:index, :show], param: :resource
|
||||||
|
|
||||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
resources :tag_groups, only: [:index, :show, :create, :update] do
|
||||||
|
collection do
|
||||||
|
get 'name/:name', action: :show_by_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||||
|
|
||||||
@@ -28,6 +32,9 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :tags, only: [:index, :show] do
|
resources :tags, only: [:index, :show] do
|
||||||
collection do
|
collection do
|
||||||
|
get :descriptions, to: 'tag_descriptions#index'
|
||||||
|
post :descriptions, to: 'tag_descriptions#create'
|
||||||
|
get 'descriptions/:description_id', to: 'tag_descriptions#show_by_id'
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
get :'with-depth', action: :with_depth
|
get :'with-depth', action: :with_depth
|
||||||
get :versions, to: 'tag_versions#index'
|
get :versions, to: 'tag_versions#index'
|
||||||
|
|||||||
+15
-2
@@ -9,8 +9,21 @@
|
|||||||
# end
|
# end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
|
||||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
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 = 'カナモジ オヨビ ラテンモジ' }
|
||||||
end
|
end
|
||||||
|
|
||||||
post_url_sanitisation_rules = [
|
post_url_sanitisation_rules = [
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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,15 +354,25 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||||
expect(response.body).not_to include('hidden title')
|
expect(response.body).not_to include('hidden title')
|
||||||
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||||
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
|
||||||
expect(masked['cells'].first['count']).to eq(4)
|
expect(masked['cells'].first['count']).to eq(4)
|
||||||
expect(query['versions']).to be_empty
|
expect(query['versions']).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects private summary predicates instead of exposing a count oracle' do
|
it 'allows public identity predicates without exposing private snapshot fields' do
|
||||||
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
|
||||||
expect(response).to have_http_status(:forbidden)
|
url: record.url, title: 'hidden title', tags: '', tags_json: [],
|
||||||
expect(json).to eq('error' => 'private_condition')
|
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')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reports the combined axis limit without replacing it with a generic 400' do
|
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||||
|
|||||||
@@ -69,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preserves five tag occurrences after merging private tags and across pages' do
|
it 'counts one opaque collection per private version across pages' do
|
||||||
[nil, guest, member, admin].each do |user|
|
[nil, guest, member, admin].each do |user|
|
||||||
page = result(user)
|
page = result(user)
|
||||||
expect(grand(page)).to eq(5)
|
expected = user == admin ? 5 : 6
|
||||||
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
expect(grand(page)).to eq(expected)
|
||||||
|
expect(page[:cells].sum { _1[:count] }).to eq(expected)
|
||||||
expect(page).not_to have_key(:excluded_count)
|
expect(page).not_to have_key(:excluded_count)
|
||||||
paged = result(user, 'limit' => '1')
|
paged = result(user, 'limit' => '1')
|
||||||
expect(grand(paged)).to eq(5)
|
expect(grand(paged)).to eq(expected)
|
||||||
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||||
.to eq(page[:group_count])
|
.to eq(page[:group_count])
|
||||||
end
|
end
|
||||||
public_page = result(nil)
|
public_page = result(nil)
|
||||||
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||||
expect(hidden.sum { _1[:count] }).to eq(3)
|
expect(hidden.sum { _1[:count] }).to eq(4)
|
||||||
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
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')
|
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'retains distinct membership when tags are not a row axis' do
|
it 'retains distinct membership when tags are not a row axis' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
|
||||||
|
.to eq(user == admin ? 3 : 5)
|
||||||
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||||
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||||
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||||
@@ -121,14 +123,13 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
it 'counts distinct changed records when tags are not additive row tuples' do
|
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||||
expect(grand(page)).to eq(2)
|
expect(grand(page)).to eq(user == admin ? 2 : 4)
|
||||||
if user.nil?
|
if user.nil?
|
||||||
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||||
end
|
end
|
||||||
# Changed discard snapshots have no tags: no full cell contributes here.
|
# Private empty collections contribute the same opaque tuple as populated ones.
|
||||||
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||||
expect(empty[:cells]).to be_empty
|
expect(grand(combined)).to eq(user == admin ? 0 : 4)
|
||||||
expect(grand(empty)).to eq(0)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sorts public numeric values numerically alongside a masked group' do
|
it 'sorts public version numbers numerically including formerly hidden records' do
|
||||||
post = PostVersion.order(:id).last.post
|
post = PostVersion.order(:id).last.post
|
||||||
[2, 10].each do |number|
|
[2, 10].each do |number|
|
||||||
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
||||||
@@ -151,27 +152,196 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
end
|
end
|
||||||
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||||
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||||
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
expect(values.map(&:to_i)).to eq([1, 2, 10])
|
||||||
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
it 'keeps public identity predicates and excludes private attributes' do
|
||||||
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
hidden_id = PostVersion.first.post_id.to_s
|
||||||
{ 'tags' => 'A' },
|
base = { 'rows' => '', 'cols' => '' }
|
||||||
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
condition = lambda do |field, operator, value|
|
||||||
'conditions.0.value' => 'hidden-title-0' },
|
{ 'conditions' => '', 'conditions.0.field' => field,
|
||||||
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
'conditions.0.operator' => operator, 'conditions.0.value' => value }
|
||||||
'drill.0.value' => "internal:#{ tags.first.id }" },
|
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 = [
|
||||||
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||||
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
probes.each do |probe|
|
probes.each do |probe|
|
||||||
expect { CountsTableQuery.new(params.merge(probe)) }
|
expect { CountsTableQuery.new(params.merge(probe)).call }
|
||||||
.to raise_error(CountsAxes::PrivateCondition)
|
.to raise_error(CountsAxes::PrivateCondition)
|
||||||
end
|
end
|
||||||
end
|
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
|
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||||
@@ -187,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
payload = params.merge('format' => 'json')
|
payload = params.merge('format' => 'json')
|
||||||
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
|
||||||
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||||
expect(csv).to include('********')
|
expect(csv).to include('********')
|
||||||
|
|||||||
+7
-1
@@ -166,7 +166,13 @@ export const pageRoutes = (
|
|||||||
robots: 'noindex', authentication: 'user' } },
|
robots: 'noindex', authentication: 'user' } },
|
||||||
{ path: ':id', element: <MaterialDetailPage/>,
|
{ path: ':id', element: <MaterialDetailPage/>,
|
||||||
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
|
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
|
||||||
{ path: 'wiki', element: <WikiSearchPage/>,
|
{ path: 'wiki', element: <LocaleRedirect to="/tag-descriptions"/>,
|
||||||
|
handle: { redirectTo: '/tag-descriptions' } },
|
||||||
|
{ path: 'tag-descriptions',
|
||||||
|
element: <WikiSearchPage key="tag_description" resourceType="tag_description"/>,
|
||||||
|
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
|
||||||
|
{ path: 'tag-groups',
|
||||||
|
element: <WikiSearchPage key="tag_group" resourceType="tag_group"/>,
|
||||||
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
|
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
|
||||||
{ path: 'wiki/:title', element: <WikiDetailPage/>,
|
{ path: 'wiki/:title', element: <WikiDetailPage/>,
|
||||||
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
|
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { 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'
|
||||||
|
|
||||||
|
|
||||||
|
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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
|
|||||||
|
|
||||||
import TagSearchBox from './TagSearchBox'
|
import TagSearchBox from './TagSearchBox'
|
||||||
|
|
||||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
|
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ const TagSearch: FC = () => {
|
|||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
|
const compositionFlgRef = useRef (false)
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
@@ -74,7 +75,34 @@ const TagSearch: FC = () => {
|
|||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
}, [locale])
|
}, [locale])
|
||||||
|
|
||||||
|
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
|
||||||
|
ev.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 () }`)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (compositionFlgRef.current
|
||||||
|
|| ev.nativeEvent.isComposing
|
||||||
|
|| ev.nativeEvent.keyCode === 229)
|
||||||
|
{
|
||||||
|
if (ev.key === 'Enter')
|
||||||
|
ev.preventDefault ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (ev.key)
|
switch (ev.key)
|
||||||
{
|
{
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
@@ -90,13 +118,12 @@ const TagSearch: FC = () => {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (activeIndex < 0)
|
|
||||||
break
|
|
||||||
ev.preventDefault ()
|
|
||||||
{
|
{
|
||||||
const selected = suggestions[activeIndex]
|
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
|
||||||
if (selected)
|
if (selected == null)
|
||||||
handleTagSelect (selected)
|
break
|
||||||
|
ev.preventDefault ()
|
||||||
|
handleTagSelect (selected)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -105,20 +132,6 @@ const TagSearch: FC = () => {
|
|||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
break
|
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 handleTagSelect = (tag: Tag) => {
|
||||||
@@ -144,8 +157,9 @@ const TagSearch: FC = () => {
|
|||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<form onSubmit={handleSubmit} className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
enterKeyHint="search"
|
||||||
data-shortcut-focus-search="true"
|
data-shortcut-focus-search="true"
|
||||||
placeholder={message ('labels.tagSearch').text}
|
placeholder={message ('labels.tagSearch').text}
|
||||||
value={search}
|
value={search}
|
||||||
@@ -153,12 +167,18 @@ const TagSearch: FC = () => {
|
|||||||
onFocus={() => setSuggestionsVsbl (true)}
|
onFocus={() => setSuggestionsVsbl (true)}
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
onBlur={() => setSuggestionsVsbl (false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
compositionFlgRef.current = true
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
compositionFlgRef.current = false
|
||||||
|
}}
|
||||||
className={inputClass (false,
|
className={inputClass (false,
|
||||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onSelect={handleTagSelect}/>
|
onSelect={handleTagSelect}/>
|
||||||
</div>)
|
</form>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagSearch
|
export default TagSearch
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
||||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -39,10 +39,11 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
|
|||||||
|
|
||||||
|
|
||||||
export const menuOutline = (
|
export const menuOutline = (
|
||||||
{ tag, material, wikiId, user, pathName: fullPathname }: {
|
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: {
|
||||||
tag?: Tag | null
|
tag?: Tag | null
|
||||||
material?: Material | null
|
material?: Material | null
|
||||||
wikiId: number | null
|
wikiId: number | null
|
||||||
|
wikiResourceType?: WikiPage['resourceType']
|
||||||
user: User | null
|
user: User | null
|
||||||
pathName: string },
|
pathName: string },
|
||||||
): Menu => {
|
): Menu => {
|
||||||
@@ -99,17 +100,30 @@ export const menuOutline = (
|
|||||||
visible: materialFlg && Boolean (material?.tag) },
|
visible: materialFlg && Boolean (material?.tag) },
|
||||||
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
||||||
visible: materialFlg }] },
|
visible: materialFlg }] },
|
||||||
{ ...message ('wiki'), to: '/wiki', subMenu: [
|
...([
|
||||||
{ ...message ('search'), to: '/wiki' },
|
{ key: 'tagDescriptions', path: '/tag-descriptions', type: 'tag_description' },
|
||||||
{ ...message ('create'), to: '/wiki/new', visible: editable },
|
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const)
|
||||||
{ ...message ('allHistory'), to: '/wiki/changes' },
|
.map (({ key, path, type }): MenuVisibleItem => {
|
||||||
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
const current = (wikiResourceType ?? 'tag_description') === type
|
||||||
{ component: <Separator/>, visible: wikiPageFlg },
|
const detailFlg = wikiPageFlg && current
|
||||||
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
const creationPath =
|
||||||
visible: wikiPageFlg },
|
type === 'tag_group'
|
||||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new'
|
||||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
return { ...message (key), to: path,
|
||||||
visible: wikiPageFlg && editable }] },
|
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path,
|
||||||
|
subMenu: [
|
||||||
|
{ ...message ('search'), to: path },
|
||||||
|
{ ...message ('create'), to: creationPath, visible: editable },
|
||||||
|
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||||
|
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||||
|
{ component: <Separator/>, visible: detailFlg },
|
||||||
|
{ ...message ('postsWithCount', { count: postCount }),
|
||||||
|
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
|
||||||
|
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
|
||||||
|
visible: detailFlg },
|
||||||
|
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||||
|
visible: detailFlg && editable }] }
|
||||||
|
}),
|
||||||
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
||||||
{ ...message ('search'), to: '/help' },
|
{ ...message ('search'), to: '/help' },
|
||||||
{ ...message ('allHistory'), to: '/help/changes' },
|
{ ...message ('allHistory'), to: '/help/changes' },
|
||||||
@@ -229,7 +243,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
queryKey: materialsKeys.show (effectiveTitle, locale),
|
queryKey: materialsKeys.show (effectiveTitle, locale),
|
||||||
queryFn: () => fetchMaterial (effectiveTitle, locale) })
|
queryFn: () => fetchMaterial (effectiveTitle, locale) })
|
||||||
|
|
||||||
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
const menu = menuOutline ({ tag, material, wikiId,
|
||||||
|
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
|
||||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||||
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
|
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
|
||||||
const moreMenu = menu.filter (item =>
|
const moreMenu = menu.filter (item =>
|
||||||
|
|||||||
@@ -100,10 +100,11 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
|
|||||||
|
|
||||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
|
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
|
||||||
const title = url.searchParams.get ('title') ?? ''
|
const title = url.searchParams.get ('title') ?? ''
|
||||||
|
const resourceType = url.pathname === '/tag-groups' ? 'tag_group' : 'tag_description'
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: wikiKeys.index ({ title }, locale),
|
queryKey: wikiKeys.index ({ title, resourceType }, locale),
|
||||||
queryFn: () => fetchWikiPages ({ title }, locale) })
|
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -310,7 +311,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
|
|||||||
&& Boolean (mPost (u.pathname))),
|
&& Boolean (mPost (u.pathname))),
|
||||||
run: prefetchPostShow },
|
run: prefetchPostShow },
|
||||||
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
|
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
|
||||||
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
|
{ test: u => ['/wiki', '/tag-descriptions', '/tag-groups'].includes (u.pathname),
|
||||||
|
run: prefetchWikiPagesIndex },
|
||||||
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
|
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
|
||||||
&& Boolean (mWiki (u.pathname))),
|
&& Boolean (mWiki (u.pathname))),
|
||||||
run: prefetchWikiPageShow },
|
run: prefetchWikiPageShow },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
FetchPostsParams,
|
FetchPostsParams,
|
||||||
FetchTagsParams,
|
FetchTagsParams,
|
||||||
MaterialFilter,
|
MaterialFilter,
|
||||||
|
WikiPage,
|
||||||
WikiTagReference } from '@/types'
|
WikiTagReference } from '@/types'
|
||||||
|
|
||||||
export const localesKeys = {
|
export const localesKeys = {
|
||||||
@@ -130,7 +131,7 @@ export const wikiKeys = {
|
|||||||
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
||||||
['wiki', 'autolink', locale, language] as const,
|
['wiki', 'autolink', locale, language] as const,
|
||||||
index: (
|
index: (
|
||||||
p: { title?: string },
|
p: { title?: string, resourceType?: WikiPage['resourceType'] },
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
) => ['wiki', locale, 'index', p] as const,
|
) => ['wiki', locale, 'index', p] as const,
|
||||||
show: (
|
show: (
|
||||||
|
|||||||
+56
-12
@@ -1,6 +1,6 @@
|
|||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet, apiPost, apiPut } from '@/lib/api'
|
||||||
import { switchLocalePath } from '@/lib/locales'
|
import { switchLocalePath } from '@/lib/locales'
|
||||||
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
||||||
|
|
||||||
@@ -24,19 +24,54 @@ export const fetchWikiChanges = (
|
|||||||
|
|
||||||
|
|
||||||
export const fetchWikiPages = async (
|
export const fetchWikiPages = async (
|
||||||
{ title }: { title?: string },
|
{ title, resourceType = 'tag_description' }: {
|
||||||
|
title?: string, resourceType?: WikiPage['resourceType'] },
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
): Promise<WikiPage[]> =>
|
): Promise<WikiPage[]> => apiGet (
|
||||||
await apiGet ('/wiki', { locale, params: { title } })
|
resourceType === 'tag_group' ? '/tag_groups' : '/tags/descriptions',
|
||||||
|
{ locale, params: { title, document: '1' } })
|
||||||
|
|
||||||
|
|
||||||
export const fetchWikiPage = async (
|
export const fetchWikiPage = async (
|
||||||
id: string,
|
id: string,
|
||||||
{ version }: { version?: string },
|
{ version }: { version?: string },
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
): Promise<WikiPage> =>
|
): Promise<WikiPage> => {
|
||||||
await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
|
if (version != null)
|
||||||
locale, params: version ? { version } : { } })
|
return apiGet (`/wiki/${encodeURIComponent (id)}`, { locale, params: { version } })
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await apiGet (`/tags/descriptions/${encodeURIComponent (id)}`,
|
||||||
|
{ locale, params: { document: '1' } })
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||||
|
throw error
|
||||||
|
return apiGet (`/tag_groups/${encodeURIComponent (id)}`,
|
||||||
|
{ locale, params: { document: '1' } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const saveTagDocument = async (
|
||||||
|
{ title, body, page }: { title: string, body: string, page?: WikiPage },
|
||||||
|
locale: LocaleCode,
|
||||||
|
): Promise<WikiPage> => {
|
||||||
|
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
|
||||||
|
const options = { locale, params: { document: '1' } }
|
||||||
|
if (page?.resourceType === 'tag_group' || (page == null && groupName != null))
|
||||||
|
{
|
||||||
|
const attributes = { name: groupName ?? title.trim (), body }
|
||||||
|
return page == null
|
||||||
|
? apiPost ('/tag_groups', attributes, options)
|
||||||
|
: apiPut (`/tag_groups/${encodeURIComponent (String (page.id))}`, attributes, options)
|
||||||
|
}
|
||||||
|
if (page == null)
|
||||||
|
return apiPost ('/tags/descriptions', { title, body }, options)
|
||||||
|
return apiPut (`/tags/${encodeURIComponent (String (page.tagId))}/description`,
|
||||||
|
{ title, body }, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchWikiPageByTitle = async (
|
export const fetchWikiPageByTitle = async (
|
||||||
@@ -46,7 +81,14 @@ export const fetchWikiPageByTitle = async (
|
|||||||
): Promise<WikiPage | null> => {
|
): Promise<WikiPage | null> => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
|
if (version != null)
|
||||||
|
return await apiGet (`/wiki/title/${encodeURIComponent (title)}`,
|
||||||
|
{ locale, params: { version } })
|
||||||
|
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
|
||||||
|
const path = groupName == null
|
||||||
|
? `/tags/name/${encodeURIComponent (title)}/description`
|
||||||
|
: `/tag_groups/name/${encodeURIComponent (groupName)}`
|
||||||
|
return await apiGet (path, { locale, params: { document: '1' } })
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
@@ -72,7 +114,7 @@ export const fetchWikiPageByReference = async (
|
|||||||
{
|
{
|
||||||
if (!isAxiosError (error) || error.response?.status !== 404)
|
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||||
throw error
|
throw error
|
||||||
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
|
const tag = await fetchTagByName (reference, locale)
|
||||||
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +128,9 @@ export const fetchWikiTag = async (
|
|||||||
return fetchTagByName (reference.name, locale)
|
return fetchTagByName (reference.name, locale)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
|
const page = await fetchWikiPage (reference.wikiId, { }, locale)
|
||||||
|
return (page.resourceType === 'tag_description' && page.tagId != null)
|
||||||
|
? fetchTag (String (page.tagId), locale) : null
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"materials": "チカㇻペ",
|
"materials": "チカㇻペ",
|
||||||
"suppressions": "アエアイカㇷ゚テㇷ゚",
|
"suppressions": "アエアイカㇷ゚テㇷ゚",
|
||||||
"wiki": "Wiki",
|
"wiki": "Wiki",
|
||||||
|
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
|
||||||
|
"tagGroups": "$t(counts:resources.tag_groups)",
|
||||||
"create": "アシㇼ",
|
"create": "アシㇼ",
|
||||||
"edit": "イヌイェㇷ゚",
|
"edit": "イヌイェㇷ゚",
|
||||||
"entertainment": "シノッ",
|
"entertainment": "シノッ",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"materials": "素材",
|
"materials": "素材",
|
||||||
"suppressions": "抑止",
|
"suppressions": "抑止",
|
||||||
"wiki": "Wiki",
|
"wiki": "Wiki",
|
||||||
|
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
|
||||||
|
"tagGroups": "$t(counts:resources.tag_groups)",
|
||||||
"create": "新規",
|
"create": "新規",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"entertainment": "おたのしみ",
|
"entertainment": "おたのしみ",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
|
|||||||
|
|
||||||
<TabGroup>
|
<TabGroup>
|
||||||
{material.tag && (
|
{material.tag && (
|
||||||
<Tab name={label ('navigation:wiki')}>
|
<Tab name={label ('navigation:tagDescriptions')}>
|
||||||
<WikiBody
|
<WikiBody
|
||||||
title={material.tag.name}
|
title={material.tag.name}
|
||||||
language={localeLanguage (locale)}
|
language={localeLanguage (locale)}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
|
|||||||
{loading && label ('common:loading')}
|
{loading && label ('common:loading')}
|
||||||
</Tab>
|
</Tab>
|
||||||
{tags.length === 1 && (
|
{tags.length === 1 && (
|
||||||
<Tab name={label ('navigation:wiki')}>
|
<Tab name={label ('navigation:tagDescriptions')}>
|
||||||
<WikiBody title={tags[0]} body={wikiPage?.body}
|
<WikiBody title={tags[0]} body={wikiPage?.body}
|
||||||
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
||||||
? localeLanguage (locale) : 'und')}/>
|
? localeLanguage (locale) : 'und')}/>
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ const WikiDetailPage: FC = () => {
|
|||||||
queryKey: helpKeys.titles ('ja'),
|
queryKey: helpKeys.titles ('ja'),
|
||||||
queryFn: () => getHelpTitleIndex ('ja'),
|
queryFn: () => getHelpTitleIndex ('ja'),
|
||||||
staleTime: Infinity })
|
staleTime: Infinity })
|
||||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ()
|
||||||
|
.replace (/^(?:ヘルプ|手引):/, '')
|
||||||
const identifiers = useMemo (() => (
|
const identifiers = useMemo (() => (
|
||||||
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
|
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
|
||||||
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
|
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import FormField from '@/components/common/FormField'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { apiGet, apiPut } from '@/lib/api'
|
import { fetchWikiPage, saveTagDocument } from '@/lib/wiki'
|
||||||
import { wikiKeys } from '@/lib/queryKeys'
|
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
@@ -33,7 +33,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type WikiFormField = 'title' | 'body'
|
type WikiFormField = 'title' | 'name' | 'body'
|
||||||
|
|
||||||
|
|
||||||
const WikiEditPage: FC<Props> = ({ user }) => {
|
const WikiEditPage: FC<Props> = ({ user }) => {
|
||||||
@@ -50,7 +50,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
const source = `${locale}:${id}`
|
const source = `${locale}:${id}`
|
||||||
const { data: wiki, isPending: loading } = useQuery ({
|
const { data: wiki, isPending: loading } = useQuery ({
|
||||||
queryKey: wikiKeys.show (id ?? '', { }, locale),
|
queryKey: wikiKeys.show (id ?? '', { }, locale),
|
||||||
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
|
queryFn: () => fetchWikiPage (id ?? '', { }, locale),
|
||||||
enabled: editable })
|
enabled: editable })
|
||||||
const [body, setBody] = useState (wiki?.body ?? '')
|
const [body, setBody] = useState (wiki?.body ?? '')
|
||||||
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
|
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
|
||||||
@@ -60,22 +60,19 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
useValidationErrors<WikiFormField> ()
|
useValidationErrors<WikiFormField> ()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (pending || !editable)
|
if (pending || !editable || wiki == null)
|
||||||
return
|
return
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
|
|
||||||
const formData = new FormData ()
|
|
||||||
formData.append ('title', title)
|
|
||||||
formData.append ('body', body)
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await apiPut (`/wiki/${ id }`, formData,
|
await saveTagDocument ({ title, body, page: wiki }, locale)
|
||||||
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
|
||||||
qc.setQueryData (wikiKeys.show (title, { }, locale),
|
qc.setQueryData (wikiKeys.show (title, { }, locale),
|
||||||
(prev: WikiPage | undefined) =>
|
(prev: WikiPage | undefined) =>
|
||||||
prev == null ? prev : { ...prev, title, body })
|
prev == null ? prev : { ...prev, title, body })
|
||||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
await Promise.all ([
|
||||||
|
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
|
||||||
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
|
||||||
toast ({ title: label ('shared:messages.postSuccess') })
|
toast ({ title: label ('shared:messages.postSuccess') })
|
||||||
navigate (`/wiki/${ encodeURIComponent (title) }`)
|
navigate (`/wiki/${ encodeURIComponent (title) }`)
|
||||||
}
|
}
|
||||||
@@ -110,7 +107,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
{/* TODO: タグ補完 */}
|
{/* TODO: タグ補完 */}
|
||||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<input type="text"
|
<input type="text"
|
||||||
value={title}
|
value={title}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import FormField from '@/components/common/FormField'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { apiPost } from '@/lib/api'
|
import { saveTagDocument } from '@/lib/wiki'
|
||||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
@@ -21,7 +21,7 @@ import Forbidden from '@/pages/Forbidden'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { User, WikiPage } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
const mdParser = new MarkdownIt
|
const mdParser = new MarkdownIt
|
||||||
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
||||||
@@ -32,7 +32,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type WikiFormField = 'title' | 'body'
|
type WikiFormField = 'title' | 'name' | 'body'
|
||||||
|
|
||||||
|
|
||||||
const WikiNewPage: FC<Props> = ({ user }) => {
|
const WikiNewPage: FC<Props> = ({ user }) => {
|
||||||
@@ -55,14 +55,9 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
|
|
||||||
const formData = new FormData
|
|
||||||
formData.append ('title', title)
|
|
||||||
formData.append ('body', body)
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiPost<WikiPage> ('/wiki', formData,
|
const data = await saveTagDocument ({ title, body }, locale)
|
||||||
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
|
||||||
await qc.cancelQueries ({ queryKey: wikiKeys.root })
|
await qc.cancelQueries ({ queryKey: wikiKeys.root })
|
||||||
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
|
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
|
||||||
await Promise.all ([
|
await Promise.all ([
|
||||||
@@ -91,7 +86,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
{/* TODO: タグ補完 */}
|
{/* TODO: タグ補完 */}
|
||||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<input type="text"
|
<input type="text"
|
||||||
value={title}
|
value={title}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
|
|||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { apiGet } from '@/lib/api'
|
import { fetchWikiPages } from '@/lib/wiki'
|
||||||
import { wikiKeys } from '@/lib/queryKeys'
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -17,16 +17,20 @@ import type { FormEvent , FC } from 'react'
|
|||||||
import type { WikiPage } from '@/types'
|
import type { WikiPage } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const WikiSearchPage: FC = () => {
|
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
|
||||||
|
{ resourceType = 'tag_description' },
|
||||||
|
) => {
|
||||||
const { message, label } = useUITranslation ('shared')
|
const { message, label } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const heading =
|
||||||
|
resourceType === 'tag_group'
|
||||||
|
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const [text, setText] = useState ('')
|
const [text, setText] = useState ('')
|
||||||
const [submittedTitle, setSubmittedTitle] = useState ('')
|
const [submittedTitle, setSubmittedTitle] = useState ('')
|
||||||
const { data: results = [], refetch } = useQuery ({
|
const { data: results = [], refetch } = useQuery ({
|
||||||
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
|
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale),
|
||||||
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
|
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) })
|
||||||
locale, params: { title: submittedTitle } }) })
|
|
||||||
|
|
||||||
const handleSearch = (ev: FormEvent) => {
|
const handleSearch = (ev: FormEvent) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
@@ -42,10 +46,10 @@ const WikiSearchPage: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={message ('navigation:wiki')}/>
|
<PageHead title={message (heading)}/>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<PageTitle>{label ('navigation:wiki')}</PageTitle>
|
<PageTitle>{label (heading)}</PageTitle>
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
<form onSubmit={handleSearch} className="space-y-2">
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
<FormField label={label ('labels.title')}>
|
<FormField label={label ('labels.title')}>
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする