コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
3232c57cc4 | ||
|
|
084bc95102 | ||
|
|
225f55a3b9 |
@@ -1,34 +1,4 @@
|
|||||||
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)
|
||||||
@@ -48,25 +18,6 @@ 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?
|
||||||
@@ -81,9 +32,7 @@ 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_with_title!(
|
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||||
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
|
||||||
@@ -99,20 +48,8 @@ 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,22 +1,7 @@
|
|||||||
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)
|
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
|
||||||
.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
|
||||||
|
|
||||||
@@ -25,15 +10,7 @@ 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: representation(locale, group)
|
render json: TagGroupRepr.one(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
|
||||||
@@ -41,8 +18,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(locale), actor: current_user)
|
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
||||||
render json: representation(locale, group), status: :created
|
render json: TagGroupRepr.one(locale, group), status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -53,39 +30,13 @@ 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(locale, group:), actor: current_user)
|
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
||||||
render json: representation(locale, group.reload)
|
render json: TagGroupRepr.one(locale, group.reload)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def render_document_error error
|
def write_attributes
|
||||||
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,11 +6,6 @@ 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)
|
||||||
@@ -19,8 +14,6 @@ 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 =
|
||||||
@@ -253,8 +246,25 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
base_revision_id = params[:base_revision_id].presence
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
TagDescriptionOperation.update_with_title!(
|
ApplicationRecord.transaction do
|
||||||
locale, tag:, body:, title:, actor: current_user, base_revision_id:)
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
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
|
||||||
@@ -316,9 +326,21 @@ 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
|
||||||
document = TagGroupRepr.document(representation, version)
|
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||||
document['body'] = body if body
|
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
|
||||||
render json: document, status:
|
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
|
||||||
|
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
|
||||||
|
|||||||
@@ -56,21 +56,6 @@ 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
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ class CountsCandidates
|
|||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
QueryState.validate!(params, KEYS)
|
QueryState.validate!(params, KEYS)
|
||||||
raise ArgumentError unless params.fetch('resource') == 'posts'
|
definition = CountsResourceDefinition.new(params.fetch('resource'))
|
||||||
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
|
fields = ['resource_id', 'user']
|
||||||
|
fields += ['tags', 'tags_json', 'tags.id'] if definition.resource == 'posts'
|
||||||
|
fields << 'members' if definition.resource == 'tag_groups'
|
||||||
|
if definition.resource == 'materials'
|
||||||
|
fields += ['updated_by_user_id', 'file_blob_id', 'source_file_id', 'drive_file_id']
|
||||||
|
end
|
||||||
|
raise ArgumentError unless fields.include?(params.fetch('field'))
|
||||||
|
|
||||||
@params = params
|
@params = params
|
||||||
@page = Integer(params.fetch('page', '1'), 10)
|
@page = Integer(params.fetch('page', '1'), 10)
|
||||||
@@ -18,25 +24,27 @@ class CountsCandidates
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
references = ['tags', 'tags_json', 'tags.id', 'members']
|
||||||
|
return resource_candidates unless references.include?(@params['field'])
|
||||||
|
|
||||||
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
|
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
|
||||||
.merge('definition' => 'latest', 'resource' => 'tags')
|
.merge('definition' => 'latest', 'resource' => 'tags')
|
||||||
internal = CountsPopulation.new(options)
|
internal = CountsPopulation.new(options)
|
||||||
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
|
groups_flg = @params['field'] == 'members'
|
||||||
|
external = CountsPopulation.new(options.merge(
|
||||||
|
'resource' => groups_flg ? 'tag_groups' : 'nico_tags',
|
||||||
'execution_at' => internal.at.iso8601(6)))
|
'execution_at' => internal.at.iso8601(6)))
|
||||||
connection = TagVersion.connection
|
connection = TagVersion.connection
|
||||||
sources = [[internal, 'internal', 'v.category', 'v.names'],
|
sources = [[internal, 'internal', 'v.category', 'v.names'],
|
||||||
[external, 'external', 'NULL', 'JSON_OBJECT()']]
|
[external, groups_flg ? 'group' : 'external', 'NULL', 'JSON_OBJECT()']]
|
||||||
sources = sources.map do |population, kind, category, names|
|
sources = sources.map do |population, kind, category, names|
|
||||||
scope = population.scope.except(:select)
|
scope = population.scope.except(:select)
|
||||||
if @params['query'].present?
|
identity = "v.#{ population.definition.keys.first }"
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
scope = search(scope, identity, 'v.name', names)
|
||||||
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
value = ['tags.id', 'members'].include?(@params['field']) ?
|
||||||
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
"CAST(#{ identity } AS CHAR)" : "CONCAT('#{ kind }:', #{ identity })"
|
||||||
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
|
||||||
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
|
||||||
end
|
|
||||||
scope.select(Arel.sql(
|
scope.select(Arel.sql(
|
||||||
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
|
"#{ value } AS value, #{ identity } AS id, " +
|
||||||
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
||||||
.to_sql
|
.to_sql
|
||||||
end
|
end
|
||||||
@@ -55,4 +63,63 @@ class CountsCandidates
|
|||||||
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
||||||
execution_at: internal.at.iso8601(6) }
|
execution_at: internal.at.iso8601(6) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def search(scope, identity, name, names)
|
||||||
|
return scope if @params['query'].blank?
|
||||||
|
|
||||||
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
||||||
|
scope.where("(#{ name } LIKE ? OR CAST(#{ identity } AS CHAR) = ? OR EXISTS (" +
|
||||||
|
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
||||||
|
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
||||||
|
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_candidates
|
||||||
|
options = @params.slice('at', 'execution_at', 'timezone')
|
||||||
|
.merge('resource' => @params.fetch('resource'), 'definition' => 'latest')
|
||||||
|
actor_flg = ['user', 'updated_by_user_id'].include?(@params['field'])
|
||||||
|
options['definition'] = 'history' if actor_flg
|
||||||
|
population = CountsPopulation.new(options)
|
||||||
|
scope = population.scope.except(:select)
|
||||||
|
if actor_flg
|
||||||
|
point = population.timestamp(@params['at']) || population.at
|
||||||
|
raise ArgumentError if point > population.at
|
||||||
|
|
||||||
|
column = @params['field'] == 'user' ? 'created_by_user_id' : 'updated_by_user_id'
|
||||||
|
identities = scope.where('v.created_at <= ?', point).select("v.#{ column }")
|
||||||
|
scope = User.where(id: identities)
|
||||||
|
identity = 'users.id'
|
||||||
|
name = 'users.name'
|
||||||
|
names = 'JSON_OBJECT()'
|
||||||
|
else
|
||||||
|
definition = population.definition
|
||||||
|
column = @params['field'] == 'resource_id' ? definition.keys.first : @params['field']
|
||||||
|
if column == 'drive_file_id'
|
||||||
|
column = 'source_file_id'
|
||||||
|
scope = scope.where('v.source_kind' => 'google_drive_file')
|
||||||
|
end
|
||||||
|
identity = "v.#{ column }"
|
||||||
|
label = ['title', 'name', 'url'].find { definition.fields.key?(_1) }
|
||||||
|
name = label ? "v.#{ label }" : 'NULL'
|
||||||
|
names = definition.resource == 'tags' ? 'v.names' : 'JSON_OBJECT()'
|
||||||
|
if ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field'])
|
||||||
|
scope = scope.where.not("v.#{ column }" => nil).group(Arel.sql(identity))
|
||||||
|
name = 'v.file_filename'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
scope = search(scope, identity, name, names)
|
||||||
|
title = ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field']) ?
|
||||||
|
"MIN(#{ name })" : name
|
||||||
|
query = scope.optimizer_hints('MAX_EXECUTION_TIME(10000)').distinct
|
||||||
|
.select(Arel.sql("#{ identity } AS id, #{ title } AS name, #{ names } AS names"))
|
||||||
|
.order(Arel.sql(identity)).limit(LIMIT + 1).offset((@page - 1) * LIMIT)
|
||||||
|
rows = scope.klass.connection.select_all(query.to_sql).to_a.map do |values|
|
||||||
|
values['names'] = JSON.parse(values['names']) if values['names'].is_a?(String)
|
||||||
|
values.merge('value' => values.fetch('id').to_s)
|
||||||
|
end
|
||||||
|
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
||||||
|
execution_at: population.at.iso8601(6) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,37 +37,6 @@ 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,11 +17,7 @@ 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] do
|
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||||
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]
|
||||||
|
|
||||||
@@ -32,9 +28,6 @@ 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'
|
||||||
|
|||||||
+1
-6
@@ -166,12 +166,7 @@ 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: <LocaleRedirect to="/tags"/>,
|
{ path: 'wiki', element: <WikiSearchPage/>,
|
||||||
handle: { redirectTo: '/tags' } },
|
|
||||||
{ path: 'tag-descriptions', element: <LocaleRedirect to="/tags"/>,
|
|
||||||
handle: { redirectTo: '/tags' } },
|
|
||||||
{ 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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
@@ -184,7 +185,12 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
value={parentPostIds}
|
value={parentPostIds}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errors={fieldErrors.parentPostIds}
|
errors={fieldErrors.parentPostIds}
|
||||||
onChange={setParentPostIds}/>
|
onChange={setParentPostIds}
|
||||||
|
after={<ResourcePickerButton
|
||||||
|
source={{ resource: 'posts', field: 'resource_id' }}
|
||||||
|
values={parentPostIds.split (/\s+/).filter (Boolean)}
|
||||||
|
maximum={Number.MAX_SAFE_INTEGER} disabled={disabled}
|
||||||
|
onSelect={values => setParentPostIds (values.join (' '))}/>}/>
|
||||||
|
|
||||||
<PostTagsField
|
<PostTagsField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -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, WikiPage } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -39,11 +39,10 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
|
|||||||
|
|
||||||
|
|
||||||
export const menuOutline = (
|
export const menuOutline = (
|
||||||
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: {
|
{ tag, material, wikiId, 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 => {
|
||||||
@@ -100,29 +99,17 @@ 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: [
|
||||||
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const)
|
{ ...message ('search'), to: '/wiki' },
|
||||||
.map (({ key, path, type }): MenuVisibleItem => {
|
{ ...message ('create'), to: '/wiki/new', visible: editable },
|
||||||
const current = (wikiResourceType ?? 'tag_description') === type
|
|
||||||
const detailFlg = wikiPageFlg && current
|
|
||||||
const creationPath =
|
|
||||||
type === 'tag_group'
|
|
||||||
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new'
|
|
||||||
return { ...message (key), to: path,
|
|
||||||
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path,
|
|
||||||
subMenu: [
|
|
||||||
{ ...message ('search'), to: path },
|
|
||||||
{ ...message ('create'), to: creationPath, visible: editable },
|
|
||||||
{ ...message ('allHistory'), to: '/wiki/changes' },
|
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||||
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||||
{ component: <Separator/>, visible: detailFlg },
|
{ component: <Separator/>, visible: wikiPageFlg },
|
||||||
{ ...message ('postsWithCount', { count: postCount }),
|
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
||||||
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
|
visible: wikiPageFlg },
|
||||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
|
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
visible: detailFlg },
|
|
||||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||||
visible: detailFlg && editable }] }
|
visible: wikiPageFlg && 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' },
|
||||||
@@ -242,8 +229,7 @@ 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,
|
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
||||||
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 =>
|
||||||
|
|||||||
+46
-31
@@ -10,16 +10,19 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
|
import type { CountsResource } from '@/lib/counts'
|
||||||
from '@/lib/countsDefinitions'
|
import type { CountsCandidate } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
const CountsCandidatePicker = (
|
type CandidateSource = {
|
||||||
{ field, definition, params, initial, maximum, controls, save }: {
|
resource: CountsResource; field: string; params?: URLSearchParams }
|
||||||
field: CountsFieldMetadata; params: URLSearchParams
|
|
||||||
definition: CountsDefinition
|
const CandidatePicker = (
|
||||||
|
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
|
||||||
|
source?: CandidateSource; choices?: CountsCandidate[]
|
||||||
|
categories?: { value: string; label: string }[]
|
||||||
initial: (string | number | boolean)[]; maximum: number
|
initial: (string | number | boolean)[]; maximum: number
|
||||||
controls: DialogueFormControls; save: (values: string[]) => void },
|
controls: DialogueFormControls; onSelect: (values: string[]) => void },
|
||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
@@ -29,49 +32,58 @@ const CountsCandidatePicker = (
|
|||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
const [category, setCategory] = useState ('')
|
const [category, setCategory] = useState ('')
|
||||||
const [page, setPage] = useState (1)
|
const [page, setPage] = useState (1)
|
||||||
|
const params = source?.params ?? new URLSearchParams ()
|
||||||
const point = useRef (params.get ('execution_at'))
|
const point = useRef (params.get ('execution_at'))
|
||||||
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
|
const resource = source?.resource ?? 'posts'
|
||||||
|
const request = new URLSearchParams ({
|
||||||
|
field: source?.field ?? '', query: search, page: String (page) })
|
||||||
for (const key of ['timezone', 'at', 'execution_at'])
|
for (const key of ['timezone', 'at', 'execution_at'])
|
||||||
if (params.has (key)) request.set (key, params.get (key)!)
|
if (params.has (key)) request.set (key, params.get (key)!)
|
||||||
if (countsUseLatest (params)) request.delete ('at')
|
if (countsUseLatest (params)) request.delete ('at')
|
||||||
if (category) request.set ('category', category)
|
if (category) request.set ('category', category)
|
||||||
const dynamic = field.candidateSource === 'tags'
|
const dynamic = source != null
|
||||||
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
||||||
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
|
queryKey: countsKeys.candidates (resource, request.toString (), locale), enabled: dynamic,
|
||||||
queryFn: ({ signal }) => {
|
queryFn: ({ signal }) => {
|
||||||
const pinned = new URLSearchParams (request)
|
const pinned = new URLSearchParams (request)
|
||||||
if (point.current != null) pinned.set ('execution_at', point.current)
|
if (point.current != null) pinned.set ('execution_at', point.current)
|
||||||
return fetchCountsCandidates (pinned, locale, signal)
|
return fetchCountsCandidates (resource, pinned, locale, signal)
|
||||||
}, retry: false })
|
}, retry: false, staleTime: 0, gcTime: 0 })
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (data != null) point.current = data.executionAt
|
if (data != null) point.current = data.executionAt
|
||||||
}, [data])
|
}, [data])
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
||||||
onSelect: () => save ([...selected]) }])
|
onSelect: () => onSelect ([...selected]) }])
|
||||||
}, [controls, label, save, selected])
|
}, [controls, label, onSelect, selected])
|
||||||
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
|
const rows = dynamic ? data?.rows ?? [] : choices.filter (row =>
|
||||||
value: choice.value, name: message (choice.label).text }))
|
|
||||||
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
|
|
||||||
row.name?.includes (search))
|
row.name?.includes (search))
|
||||||
const headings: Record<string, string> = {
|
const headings: Record<string, string> = {
|
||||||
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
||||||
kind: 'columns.kind' }
|
kind: 'columns.kind' }
|
||||||
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
|
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '')
|
||||||
|
const available =
|
||||||
|
dynamic
|
||||||
|
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
|
||||||
const visible = available.filter (column => columns.includes (column))
|
const visible = available.filter (column => columns.includes (column))
|
||||||
const categories = definition.filterFields?.find (field =>
|
|
||||||
field.candidateSource === 'categories')?.choices ?? []
|
|
||||||
const cell = (row: CountsCandidate, column: string) => {
|
const cell = (row: CountsCandidate, column: string) => {
|
||||||
if (column === 'category') return row.category == null ? ''
|
if (column === 'category')
|
||||||
: message (`common:categories.${row.category}`).text
|
return row.category == null ? '' : message (`common:categories.${row.category}`).text
|
||||||
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
|
if (column === 'kind')
|
||||||
|
{
|
||||||
|
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
|
||||||
|
return row.kind == null ? '' : message (key).text
|
||||||
|
}
|
||||||
return column === 'id' ? row.id : row.name
|
return column === 'id' ? row.id : row.name
|
||||||
}
|
}
|
||||||
return <div className="min-w-0 space-y-2">
|
return <div className="min-w-0 space-y-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
||||||
aria-label={message ('common:search').text} value={draft}
|
aria-label={message ('common:search').text} value={draft} maxLength={1000}
|
||||||
onChange={event => setDraft (event.target.value)}
|
onChange={event => {
|
||||||
|
const value = event.target.value
|
||||||
|
if (new TextEncoder ().encode (value).length <= 1000) setDraft (value)
|
||||||
|
}}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key !== 'Enter') return
|
if (event.key !== 'Enter') return
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
@@ -81,7 +93,7 @@ const CountsCandidatePicker = (
|
|||||||
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
||||||
{label ('common:search')}</Button>
|
{label ('common:search')}</Button>
|
||||||
</div>
|
</div>
|
||||||
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
|
{categories.length > 0 && <select className={inputClass (false, 'h-9 w-full')}
|
||||||
aria-label={message ('common:categoryLabel').text} value={category}
|
aria-label={message ('common:categoryLabel').text} value={category}
|
||||||
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
||||||
<option value=""/>
|
<option value=""/>
|
||||||
@@ -108,12 +120,13 @@ const CountsCandidatePicker = (
|
|||||||
{visible.map (column => <th key={column} className="p-2">
|
{visible.map (column => <th key={column} className="p-2">
|
||||||
{label (headings[column])}</th>)}
|
{label (headings[column])}</th>)}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
|
<tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`}
|
||||||
|
className="border-t dark:border-slate-700">
|
||||||
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
||||||
aria-label={row.name ?? String (row.id ?? '')}
|
aria-label={row.name ?? String (row.id ?? '')}
|
||||||
disabled={!selected.has (row.value) && selected.size >= maximum}
|
disabled={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum}
|
||||||
onChange={event => setSelected (previous => {
|
onChange={event => setSelected (previous => {
|
||||||
const next = new Set (previous)
|
const next = new Set (maximum === 1 ? [] : previous)
|
||||||
if (event.target.checked) next.add (row.value)
|
if (event.target.checked) next.add (row.value)
|
||||||
else next.delete (row.value)
|
else next.delete (row.value)
|
||||||
return next
|
return next
|
||||||
@@ -128,7 +141,8 @@ const CountsCandidatePicker = (
|
|||||||
onClick={() => setPage (previous => previous - 1)}>
|
onClick={() => setPage (previous => previous - 1)}>
|
||||||
{label ('common:pagination.previous')}</Button>
|
{label ('common:pagination.previous')}</Button>
|
||||||
<span>{page}</span>
|
<span>{page}</span>
|
||||||
<Button type="button" variant="outline" disabled={!data?.more || fetching}
|
<Button type="button" variant="outline"
|
||||||
|
disabled={!data?.more || page >= 100_000 || fetching}
|
||||||
onClick={() => setPage (previous => previous + 1)}>
|
onClick={() => setPage (previous => previous + 1)}>
|
||||||
{label ('common:pagination.next')}</Button>
|
{label ('common:pagination.next')}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -136,4 +150,5 @@ const CountsCandidatePicker = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default CountsCandidatePicker
|
export default CandidatePicker
|
||||||
|
export type { CandidateSource }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import CandidatePicker from '@/components/common/CandidatePicker'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CandidateSource } from '@/components/common/CandidatePicker'
|
||||||
|
|
||||||
|
|
||||||
|
const ResourcePickerButton = (
|
||||||
|
{ source, values, maximum = 1, disabled, onSelect }: {
|
||||||
|
source: CandidateSource; values: (string | number | boolean)[]
|
||||||
|
maximum?: number; disabled?: boolean
|
||||||
|
onSelect: (values: string[]) => void },
|
||||||
|
) => {
|
||||||
|
const dialogue = useDialogue ()
|
||||||
|
const { label } = useUITranslation ('counts')
|
||||||
|
return <Button type="button" variant="outline" disabled={disabled || maximum === 0}
|
||||||
|
onClick={() => void dialogue.form ({
|
||||||
|
title: label ('pattern.link'), size: 'large', footerLayout: 'inline',
|
||||||
|
body: controls => <CandidatePicker source={source} initial={values}
|
||||||
|
maximum={maximum} controls={controls} onSelect={onSelect}/> })}>
|
||||||
|
{label ('pattern.selection')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ResourcePickerButton
|
||||||
@@ -2,9 +2,10 @@ import { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
|
import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel,
|
||||||
countsSetOperators, useCountsDefinition }
|
countsFilterFields, countsSetOperators, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { countsConditionValue } from '@/lib/countsDisplay'
|
import { countsConditionValue } from '@/lib/countsDisplay'
|
||||||
@@ -15,7 +16,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
|
|
||||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsResult } from '@/lib/counts'
|
import type { CountsResource, CountsResult } from '@/lib/counts'
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
@@ -94,7 +95,9 @@ const ConditionEditor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
|
const CountsAdditionalConditions = (
|
||||||
|
{ resource, params, update, conditionLabels }: {
|
||||||
|
resource: CountsResource
|
||||||
conditionLabels?: CountsResult['conditionLabels']
|
conditionLabels?: CountsResult['conditionLabels']
|
||||||
params: URLSearchParams
|
params: URLSearchParams
|
||||||
update: (key: string, value: string) => void },
|
update: (key: string, value: string) => void },
|
||||||
@@ -149,11 +152,19 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
|
|||||||
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
||||||
aria-label={aria.text} lang={aria.language}
|
aria-label={aria.text} lang={aria.language}
|
||||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||||
return <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
const source = countsCandidateSource (resource, condition.field, params)
|
||||||
|
const value = condition[key]
|
||||||
|
return <>
|
||||||
|
<input value={condition[key] == null ? '' : String (condition[key])}
|
||||||
|
disabled={condition.blank}
|
||||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||||
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
|
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
|
||||||
aria-label={aria.text} lang={aria.language}
|
aria-label={aria.text} lang={aria.language}
|
||||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||||
|
{source != null && <ResourcePickerButton source={source} disabled={condition.blank}
|
||||||
|
values={value == null ? [] : [value]}
|
||||||
|
onSelect={values => patch (index, { [key]: values[0] ?? '' })}/>}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
return <fieldset className="min-w-0 space-y-1">
|
return <fieldset className="min-w-0 space-y-1">
|
||||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||||
@@ -182,7 +193,7 @@ const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
|
|||||||
</Button>
|
</Button>
|
||||||
{countsSetOperators.includes (condition.operator) ? (
|
{countsSetOperators.includes (condition.operator) ? (
|
||||||
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
||||||
condition={condition}
|
condition={condition} resource={resource}
|
||||||
field={metadata.find (field => field.key === condition.field)!}
|
field={metadata.find (field => field.key === condition.field)!}
|
||||||
definition={definition} params={params}
|
definition={definition} params={params}
|
||||||
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
|
import CandidatePicker from '@/components/common/CandidatePicker'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { countsCandidateSource } from '@/lib/countsDefinitions'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountsResource } from '@/lib/counts'
|
||||||
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
const CountsConditionValues = (
|
const CountsConditionValues = (
|
||||||
{ condition, field, definition, params, maximum, change }: {
|
{ resource, condition, field, definition, params, maximum, change }: {
|
||||||
|
resource: CountsResource
|
||||||
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
||||||
params: URLSearchParams; maximum: number
|
params: URLSearchParams; maximum: number
|
||||||
change: (values: (string | number | boolean)[]) => void },
|
change: (values: (string | number | boolean)[]) => void },
|
||||||
@@ -21,11 +25,21 @@ const CountsConditionValues = (
|
|||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const [draft, setDraft] = useState ('')
|
const [draft, setDraft] = useState ('')
|
||||||
const values = condition.values ?? []
|
const values = condition.values ?? []
|
||||||
|
const source = countsCandidateSource (resource, field.key, params)
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
||||||
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
|
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
|
||||||
body: controls => <CountsCandidatePicker field={field} definition={definition}
|
body: controls => <CandidatePicker
|
||||||
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
|
source={
|
||||||
|
field.candidateSource === 'tags'
|
||||||
|
? { resource: 'posts', field: field.key, params } : undefined}
|
||||||
|
choices={(field.choices ?? []).map (choice => ({
|
||||||
|
value: choice.value, name: message (choice.label).text }))}
|
||||||
|
categories={
|
||||||
|
field.candidateSource === 'tags'
|
||||||
|
? definition.filterFields?.find (field =>
|
||||||
|
field.candidateSource === 'categories')?.choices : undefined}
|
||||||
|
initial={values} maximum={maximum} controls={controls} onSelect={change}/> })
|
||||||
}
|
}
|
||||||
if (condition.input === 'list' || field.candidateSource === 'tags')
|
if (condition.input === 'list' || field.candidateSource === 'tags')
|
||||||
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
||||||
@@ -73,6 +87,8 @@ const CountsConditionValues = (
|
|||||||
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
||||||
</span>)}
|
</span>)}
|
||||||
{input ()}
|
{input ()}
|
||||||
|
{source != null && <ResourcePickerButton source={source} values={values}
|
||||||
|
maximum={maximum} disabled={condition.blank} onSelect={change}/>}
|
||||||
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
||||||
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
||||||
<Button type="button" variant="ghost" disabled={condition.blank}
|
<Button type="button" variant="ghost" disabled={condition.blank}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
|||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import HelpPopover from '@/components/common/HelpPopover'
|
import HelpPopover from '@/components/common/HelpPopover'
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
||||||
@@ -223,11 +224,17 @@ const CountsControls = (
|
|||||||
{['resource_id', 'user_id'].map (key => <FormField key={key}
|
{['resource_id', 'user_id'].map (key => <FormField key={key}
|
||||||
label={label (key === 'resource_id' ? 'columns.resourceId'
|
label={label (key === 'resource_id' ? 'columns.resourceId'
|
||||||
: 'fields.actor')}>
|
: 'fields.actor')}>
|
||||||
{() => <input value={draft.get (key) ?? ''}
|
{() => <div className="flex flex-wrap items-center gap-1">
|
||||||
|
<input value={draft.get (key) ?? ''}
|
||||||
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
||||||
: 'fields.actor').text}
|
: 'fields.actor').text}
|
||||||
className={inputClass (false, 'w-28 py-1')}
|
className={inputClass (false, 'w-28 py-1')}
|
||||||
onChange={event => update (key, event.target.value)}/>}
|
onChange={event => update (key, event.target.value)}/>
|
||||||
|
<ResourcePickerButton source={{ resource,
|
||||||
|
field: key === 'resource_id' ? key : 'user', params: draft }}
|
||||||
|
values={draft.get (key) ? [draft.get (key)!] : []}
|
||||||
|
onSelect={values => update (key, values[0] ?? '')}/>
|
||||||
|
</div>}
|
||||||
</FormField>)}
|
</FormField>)}
|
||||||
</div>
|
</div>
|
||||||
{resource === 'posts' && <FormField label={label ('columns.tags')}>
|
{resource === 'posts' && <FormField label={label ('columns.tags')}>
|
||||||
@@ -263,7 +270,7 @@ const CountsControls = (
|
|||||||
</>}
|
</>}
|
||||||
</div>}
|
</div>}
|
||||||
</FormField>}
|
</FormField>}
|
||||||
<CountsAdditionalConditions params={draft} update={update}
|
<CountsAdditionalConditions resource={resource} params={draft} update={update}
|
||||||
conditionLabels={(labelParams != null
|
conditionLabels={(labelParams != null
|
||||||
&& draft.get ('current') === labelParams.get ('current')
|
&& draft.get ('current') === labelParams.get ('current')
|
||||||
&& draft.get ('at') === labelParams.get ('at')
|
&& draft.get ('at') === labelParams.get ('at')
|
||||||
|
|||||||
@@ -227,11 +227,13 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<DialogueProvider key={active.id}>
|
||||||
{active.options.body (
|
{active.options.body (
|
||||||
formControls.current[active.id] ??= {
|
formControls.current[active.id] ??= {
|
||||||
close: () => closeRequest (active.id),
|
close: () => closeRequest (active.id),
|
||||||
setActions: actions => setRequestActions (active.id, actions),
|
setActions: actions => setRequestActions (active.id, actions),
|
||||||
confirm: options => openNestedConfirm (active.id, options) })}
|
confirm: options => openNestedConfirm (active.id, options) })}
|
||||||
|
</DialogueProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import PostTagsField from '@/components/posts/PostTagsField'
|
import PostTagsField from '@/components/posts/PostTagsField'
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
@@ -88,7 +89,12 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
|||||||
disabled={parentPostIds.disabled}
|
disabled={parentPostIds.disabled}
|
||||||
warnings={parentPostIds.warnings}
|
warnings={parentPostIds.warnings}
|
||||||
errors={parentPostIds.errors}
|
errors={parentPostIds.errors}
|
||||||
onChange={parentPostIds.onChange}/>
|
onChange={parentPostIds.onChange}
|
||||||
|
after={<ResourcePickerButton
|
||||||
|
source={{ resource: 'posts', field: 'resource_id' }}
|
||||||
|
values={parentPostIds.value.split (/\s+/).filter (Boolean)}
|
||||||
|
maximum={Number.MAX_SAFE_INTEGER} disabled={parentPostIds.disabled}
|
||||||
|
onSelect={values => parentPostIds.onChange (values.join (' '))}/>}/>
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
export default PostCoreDataFields
|
export default PostCoreDataFields
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const PostTextField: FC<Props> = (
|
|||||||
<FormField label={label} messages={errors}>
|
<FormField label={label} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -44,8 +46,10 @@ const PostTextField: FC<Props> = (
|
|||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
className={inputClass (invalid, className)}/>
|
className={inputClass (invalid, className)}/>
|
||||||
<FieldWarning messages={warnings}/>
|
</div>
|
||||||
{after}
|
{after}
|
||||||
|
</div>
|
||||||
|
<FieldWarning messages={warnings}/>
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
|
|
||||||
|
|||||||
@@ -89,18 +89,19 @@ export const fetchCountsDefinition = (
|
|||||||
.then (definition => localiseCountsDefinition (definition, locale))
|
.then (definition => localiseCountsDefinition (definition, locale))
|
||||||
|
|
||||||
export type CountsCandidate = {
|
export type CountsCandidate = {
|
||||||
value: string; id?: number; name: string | null; category?: string | null
|
value: string; id?: number | string; name: string | null; category?: string | null
|
||||||
names?: Record<string, unknown>
|
names?: Record<string, unknown>
|
||||||
kind?: 'internal' | 'external' }
|
kind?: 'internal' | 'external' | 'group' }
|
||||||
export type CountsCandidatesResult = {
|
export type CountsCandidatesResult = {
|
||||||
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
||||||
|
|
||||||
export const fetchCountsCandidates = (
|
export const fetchCountsCandidates = (
|
||||||
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CountsCandidatesResult> => apiGet (
|
): Promise<CountsCandidatesResult> => apiGet (
|
||||||
'/counts/definitions/posts/candidates', {
|
`/counts/definitions/${encodeURIComponent (resource)}/candidates`, {
|
||||||
params: Object.fromEntries (params), locale, signal })
|
params: Object.fromEntries (params), locale, signal })
|
||||||
|
|
||||||
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
||||||
@@ -155,3 +156,25 @@ export const countsAxisFields = (
|
|||||||
history: boolean,
|
history: boolean,
|
||||||
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
||||||
.map (key => ({ key, label: countsFieldLabel (key) }))
|
.map (key => ({ key, label: countsFieldLabel (key) }))
|
||||||
|
|
||||||
|
|
||||||
|
export const countsCandidateSource = (
|
||||||
|
resource: CountsResource,
|
||||||
|
field: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): { resource: CountsResource; field: string; params: URLSearchParams } | undefined => {
|
||||||
|
if (['resource_id', 'user', 'updated_by_user_id', 'file_blob_id', 'source_file_id']
|
||||||
|
.includes (field)) return { resource, field, params }
|
||||||
|
if (field === 'user.id') return { resource, field: 'user', params }
|
||||||
|
if ((field === 'tags.id' && resource === 'posts')
|
||||||
|
|| (field === 'members' && resource === 'tag_groups'))
|
||||||
|
return { resource, field, params }
|
||||||
|
const leaf = field.split ('.').at (-1) ?? ''
|
||||||
|
const targets: Record<string, CountsResource> = {
|
||||||
|
tagGroupId: 'tag_groups', tagId: 'tags', tag_id: 'tags', parent_id: 'materials',
|
||||||
|
parents: resource === 'posts' ? 'posts' : 'tags',
|
||||||
|
'parents.id': resource === 'posts' ? 'posts' : 'tags',
|
||||||
|
parent_post_ids: 'posts', parent_tag_ids: 'tags' }
|
||||||
|
const target = targets[field] ?? targets[leaf]
|
||||||
|
return target == null ? undefined : { resource: target, field: 'resource_id', params }
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,11 +100,10 @@ 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 = 'tag_group'
|
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: wikiKeys.index ({ title, resourceType }, locale),
|
queryKey: wikiKeys.index ({ title }, locale),
|
||||||
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) })
|
queryFn: () => fetchWikiPages ({ title }, locale) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -311,8 +310,7 @@ 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 === '/tag-groups',
|
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
|
||||||
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,7 +5,6 @@ import type {
|
|||||||
FetchPostsParams,
|
FetchPostsParams,
|
||||||
FetchTagsParams,
|
FetchTagsParams,
|
||||||
MaterialFilter,
|
MaterialFilter,
|
||||||
WikiPage,
|
|
||||||
WikiTagReference } from '@/types'
|
WikiTagReference } from '@/types'
|
||||||
|
|
||||||
export const localesKeys = {
|
export const localesKeys = {
|
||||||
@@ -131,7 +130,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, resourceType?: WikiPage['resourceType'] },
|
p: { title?: string },
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
) => ['wiki', locale, 'index', p] as const,
|
) => ['wiki', locale, 'index', p] as const,
|
||||||
show: (
|
show: (
|
||||||
@@ -175,6 +174,7 @@ export const theatresKeys = {
|
|||||||
export const countsKeys = {
|
export const countsKeys = {
|
||||||
definition: (resource: string, locale: LocaleCode = 'ja') =>
|
definition: (resource: string, locale: LocaleCode = 'ja') =>
|
||||||
['counts', 'definition', resource, locale] as const,
|
['counts', 'definition', resource, locale] as const,
|
||||||
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
|
candidates: (resource: string, params: string, locale: LocaleCode) =>
|
||||||
|
['counts', 'candidates', resource, locale, params] as const,
|
||||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||||
['counts', resource, locale, params] as const }
|
['counts', resource, locale, params] as const }
|
||||||
|
|||||||
+10
-54
@@ -1,6 +1,6 @@
|
|||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPut } from '@/lib/api'
|
import { apiGet } 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,54 +24,19 @@ export const fetchWikiChanges = (
|
|||||||
|
|
||||||
|
|
||||||
export const fetchWikiPages = async (
|
export const fetchWikiPages = async (
|
||||||
{ title, resourceType = 'tag_description' }: {
|
{ title }: { title?: string },
|
||||||
title?: string, resourceType?: WikiPage['resourceType'] },
|
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
): Promise<WikiPage[]> => apiGet (
|
): Promise<WikiPage[]> =>
|
||||||
resourceType === 'tag_group' ? '/tag_groups' : '/tags/descriptions',
|
await apiGet ('/wiki', { locale, params: { title } })
|
||||||
{ 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> =>
|
||||||
if (version != null)
|
await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
|
||||||
return apiGet (`/wiki/${encodeURIComponent (id)}`, { locale, params: { version } })
|
locale, params: version ? { 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 (
|
||||||
@@ -81,14 +46,7 @@ export const fetchWikiPageByTitle = async (
|
|||||||
): Promise<WikiPage | null> => {
|
): Promise<WikiPage | null> => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (version != null)
|
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -114,7 +72,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 fetchTagByName (reference, locale)
|
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
|
||||||
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +86,7 @@ export const fetchWikiTag = async (
|
|||||||
return fetchTagByName (reference.name, locale)
|
return fetchTagByName (reference.name, locale)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const page = await fetchWikiPage (reference.wikiId, { }, locale)
|
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
|
||||||
return (page.resourceType === 'tag_description' && page.tagId != null)
|
|
||||||
? fetchTag (String (page.tagId), locale) : null
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
"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,8 +13,6 @@
|
|||||||
"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:tagDescriptions')}>
|
<Tab name={label ('navigation:wiki')}>
|
||||||
<WikiBody
|
<WikiBody
|
||||||
title={material.tag.name}
|
title={material.tag.name}
|
||||||
language={localeLanguage (locale)}
|
language={localeLanguage (locale)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
@@ -96,13 +97,19 @@ const MaterialHistoryPage: FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormField label={label ('materialId')}>
|
<FormField label={label ('materialId')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={materialIdInput}
|
value={materialIdInput}
|
||||||
aria-label={message ('materialId').text}
|
aria-label={message ('materialId').text}
|
||||||
lang={message ('materialId').language}
|
lang={message ('materialId').language}
|
||||||
onChange={e => setMaterialIdInput (e.target.value)}
|
onChange={e => setMaterialIdInput (e.target.value)}
|
||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid, 'min-w-0 flex-1')}/>
|
||||||
|
<ResourcePickerButton
|
||||||
|
source={{ resource: 'materials', field: 'resource_id' }}
|
||||||
|
values={materialIdInput === '' ? [] : [materialIdInput]}
|
||||||
|
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
|
||||||
|
</div>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={label ('tagName')}>
|
<FormField label={label ('tagName')}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
|
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
@@ -126,11 +127,17 @@ const MaterialSyncSuppressionsPage: FC = () => {
|
|||||||
|
|
||||||
<FormField label={label ('suppressions.driveFileId')}>
|
<FormField label={label ('suppressions.driveFileId')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={driveFileId}
|
value={driveFileId}
|
||||||
onChange={e => setDriveFileId (e.target.value)}
|
onChange={e => setDriveFileId (e.target.value)}
|
||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid, 'min-w-0 flex-1')}/>
|
||||||
|
<ResourcePickerButton
|
||||||
|
source={{ resource: 'materials', field: 'drive_file_id' }}
|
||||||
|
values={driveFileId === '' ? [] : [driveFileId]}
|
||||||
|
onSelect={values => setDriveFileId (values[0] ?? '')}/>
|
||||||
|
</div>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={label ('suppressions.reason')}>
|
<FormField label={label ('suppressions.reason')}>
|
||||||
|
|||||||
@@ -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:tagDescriptions')}>
|
<Tab name={label ('navigation:wiki')}>
|
||||||
<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,8 +51,7 @@ 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 ()
|
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
||||||
.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 { fetchWikiPage, saveTagDocument } from '@/lib/wiki'
|
import { apiGet, apiPut } from '@/lib/api'
|
||||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { 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' | 'name' | 'body'
|
type WikiFormField = 'title' | '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: () => fetchWikiPage (id ?? '', { }, locale),
|
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (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,19 +60,22 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
useValidationErrors<WikiFormField> ()
|
useValidationErrors<WikiFormField> ()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (pending || !editable || wiki == null)
|
if (pending || !editable)
|
||||||
return
|
return
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
|
|
||||||
|
const formData = new FormData ()
|
||||||
|
formData.append ('title', title)
|
||||||
|
formData.append ('body', body)
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await saveTagDocument ({ title, body, page: wiki }, locale)
|
await apiPut (`/wiki/${ id }`, formData,
|
||||||
|
{ 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 })
|
||||||
await Promise.all ([
|
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||||
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) }`)
|
||||||
}
|
}
|
||||||
@@ -107,7 +110,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
{/* TODO: タグ補完 */}
|
{/* TODO: タグ補完 */}
|
||||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
||||||
{({ 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 { saveTagDocument } from '@/lib/wiki'
|
import { apiPost } from '@/lib/api'
|
||||||
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 } from '@/types'
|
import type { User, WikiPage } 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' | 'name' | 'body'
|
type WikiFormField = 'title' | 'body'
|
||||||
|
|
||||||
|
|
||||||
const WikiNewPage: FC<Props> = ({ user }) => {
|
const WikiNewPage: FC<Props> = ({ user }) => {
|
||||||
@@ -55,9 +55,14 @@ 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 saveTagDocument ({ title, body }, locale)
|
const data = await apiPost<WikiPage> ('/wiki', formData,
|
||||||
|
{ 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 ([
|
||||||
@@ -86,7 +91,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{/* タイトル */}
|
{/* タイトル */}
|
||||||
{/* TODO: タグ補完 */}
|
{/* TODO: タグ補完 */}
|
||||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
||||||
{({ 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 { fetchWikiPages } from '@/lib/wiki'
|
import { apiGet } from '@/lib/api'
|
||||||
import { wikiKeys } from '@/lib/queryKeys'
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -17,20 +17,16 @@ import type { FormEvent , FC } from 'react'
|
|||||||
import type { WikiPage } from '@/types'
|
import type { WikiPage } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
|
const WikiSearchPage: FC = () => {
|
||||||
{ 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, resourceType }, locale),
|
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
|
||||||
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) })
|
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
|
||||||
|
locale, params: { title: submittedTitle } }) })
|
||||||
|
|
||||||
const handleSearch = (ev: FormEvent) => {
|
const handleSearch = (ev: FormEvent) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
@@ -46,10 +42,10 @@ const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={message (heading)}/>
|
<PageHead title={message ('navigation:wiki')}/>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<PageTitle>{label (heading)}</PageTitle>
|
<PageTitle>{label ('navigation:wiki')}</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')}>
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする