コミットを比較

..
1 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 225f55a3b9 AI: address issue #447 2026-11-14 00:47:58 +09:00
31個のファイルの変更、380行の追加、397行の削除
+1 -64
ファイルの表示
@@ -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,
+7 -56
ファイルの表示
@@ -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
+34 -12
ファイルの表示
@@ -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
-15
ファイルの表示
@@ -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
+79 -12
ファイルの表示
@@ -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
-31
ファイルの表示
@@ -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
+1 -8
ファイルの表示
@@ -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 -7
ファイルの表示
@@ -166,13 +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="/tag-descriptions"/>, { path: 'wiki', element: <WikiSearchPage/>,
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,
+7 -1
ファイルの表示
@@ -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}
+14 -29
ファイルの表示
@@ -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,30 +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: 'tagDescriptions', path: '/tag-descriptions', type: 'tag_description' }, { ...message ('search'), to: '/wiki' },
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const) { ...message ('create'), to: '/wiki/new', visible: editable },
.map (({ key, path, type }): MenuVisibleItem => { { ...message ('allHistory'), to: '/wiki/changes' },
const current = (wikiResourceType ?? 'tag_description') === type { ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
const detailFlg = wikiPageFlg && current { component: <Separator/>, visible: wikiPageFlg },
const creationPath = { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
type === 'tag_group' visible: wikiPageFlg },
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new' { ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
return { ...message (key), to: path, { ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path, visible: wikiPageFlg && editable }] },
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' },
@@ -243,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 =>
@@ -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 }
+27
ファイルの表示
@@ -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
+24 -13
ファイルの表示
@@ -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,10 +95,12 @@ const ConditionEditor = (
} }
const CountsAdditionalConditions = ({ params, update, conditionLabels }: { const CountsAdditionalConditions = (
conditionLabels?: CountsResult['conditionLabels'] { resource, params, update, conditionLabels }: {
params: URLSearchParams resource: CountsResource
update: (key: string, value: string) => void }, conditionLabels?: CountsResult['conditionLabels']
params: URLSearchParams
update: (key: string, value: string) => void },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition () const definition = useCountsDefinition ()
@@ -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)
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'} const value = condition[key]
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')} return <>
aria-label={aria.text} lang={aria.language} <input value={condition[key] == null ? '' : String (condition[key])}
onChange={event => patch (index, { [key]: event.target.value })}/> disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
{source != null && <ResourcePickerButton source={source} disabled={condition.blank}
values={value == null ? [] : [value]}
onSelect={values => patch (index, { [key]: values[0] ?? '' })}/>}
</>
} }
return <fieldset className="min-w-0 space-y-1"> return <fieldset className="min-w-0 space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')} <legend className="flex items-center gap-2">{label ('additionalConditions')}
@@ -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) =>
+20 -4
ファイルの表示
@@ -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}
+13 -6
ファイルの表示
@@ -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">
aria-label={message (key === 'resource_id' ? 'columns.resourceId' <input value={draft.get (key) ?? ''}
: 'fields.actor').text} aria-label={message (key === 'resource_id' ? 'columns.resourceId'
className={inputClass (false, 'w-28 py-1')} : 'fields.actor').text}
onChange={event => update (key, event.target.value)}/>} className={inputClass (false, 'w-28 py-1')}
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')
+4 -2
ファイルの表示
@@ -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">
{active.options.body ( <DialogueProvider key={active.id}>
formControls.current[active.id] ??= { {active.options.body (
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
+7 -1
ファイルの表示
@@ -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
+26 -3
ファイルの表示
@@ -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 }
}
+3 -5
ファイルの表示
@@ -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 = url.pathname === '/tag-groups' ? 'tag_group' : 'tag_description'
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 => ['/wiki', '/tag-descriptions', '/tag-groups'].includes (u.pathname), { 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 },
+3 -3
ファイルの表示
@@ -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 }
+12 -56
ファイルの表示
@@ -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)
{ {
-2
ファイルの表示
@@ -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": "シノッ",
-2
ファイルの表示
@@ -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": "おたのしみ",
+1 -1
ファイルの表示
@@ -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)}
+9 -2
ファイルの表示
@@ -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 }) => (
<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)}/>
<ResourcePickerButton
source={{ resource: 'materials', field: 'resource_id' }}
values={materialIdInput === '' ? [] : [materialIdInput]}
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
</>)}
</FormField> </FormField>
<FormField label={label ('tagName')}> <FormField label={label ('tagName')}>
+9 -2
ファイルの表示
@@ -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 }) => (
<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)}/>
<ResourcePickerButton
source={{ resource: 'materials', field: 'drive_file_id' }}
values={driveFileId === '' ? [] : [driveFileId]}
onSelect={values => setDriveFileId (values[0] ?? '')}/>
</>)}
</FormField> </FormField>
<FormField label={label ('suppressions.reason')}> <FormField label={label ('suppressions.reason')}>
+1 -1
ファイルの表示
@@ -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')}/>
+1 -2
ファイルの表示
@@ -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])
+13 -10
ファイルの表示
@@ -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}
+10 -5
ファイルの表示
@@ -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}
+7 -11
ファイルの表示
@@ -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')}>