コミットを比較
2
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
1b22ed43bd | ||
|
|
b46d53e347 |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
|
||||||
describe ('PostFormTagsArea', () => {
|
describe ('PostFormTagsArea', () => {
|
||||||
it ('updates text and fetches autocomplete for the selected token', async () => {
|
it ('replaces the selected token and restores the caret without losing other tags', async () => {
|
||||||
const setTags = vi.fn ()
|
const setTags = vi.fn ()
|
||||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
|
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
|
||||||
|
|
||||||
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
|
const { rerender } = renderWithProviders (
|
||||||
|
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
|
||||||
|
|
||||||
const textarea = screen.getByRole ('textbox')
|
const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox')
|
||||||
fireEvent.focus (textarea)
|
fireEvent.focus (textarea)
|
||||||
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
|
fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } })
|
||||||
fireEvent.change (textarea, { target: { value: '虹夏' } })
|
rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>)
|
||||||
|
fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } })
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹', nico: '0' },
|
locale: 'ja', params: { q: '虹な', nico: '0' },
|
||||||
signal: expect.any (AbortSignal) },
|
signal: expect.any (AbortSignal) })
|
||||||
)
|
|
||||||
})
|
})
|
||||||
expect (setTags).toHaveBeenCalledWith ('虹夏')
|
expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続')
|
||||||
|
|
||||||
|
fireEvent.mouseDown (await screen.findByText ('虹夏'))
|
||||||
|
expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続')
|
||||||
|
rerender (<PostFormTagsArea tags="ぼっち 虹夏 後続" setTags={setTags}/>)
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (textarea).toHaveFocus ()
|
||||||
|
expect (textarea.selectionStart).toBe (7)
|
||||||
|
expect (textarea.selectionEnd).toBe (7)
|
||||||
|
})
|
||||||
|
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
// TODO: TagSearch と共通化する.
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import TextArea from '@/components/common/TextArea'
|
import TextArea from '@/components/common/TextArea'
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
|
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
|
||||||
|
|
||||||
@@ -41,24 +38,13 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | '
|
|||||||
|
|
||||||
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||||
const { label } = useUITranslation ('navigation')
|
const { label } = useUITranslation ('navigation')
|
||||||
const locale = useLocale ()
|
|
||||||
const ref = useRef<HTMLTextAreaElement> (null)
|
const ref = useRef<HTMLTextAreaElement> (null)
|
||||||
|
|
||||||
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
|
||||||
const [focused, setFocused] = useState (false)
|
const [focused, setFocused] = useState (false)
|
||||||
const [request, setRequest] = useState ({ query: '' })
|
|
||||||
const [result, setResult] = useState<{
|
|
||||||
locale: string
|
|
||||||
request: { query: string }
|
|
||||||
tags: Tag[] }> ({ locale, request, tags: [] })
|
|
||||||
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
|
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
|
||||||
const suggestions = (result.locale === locale && result.request === request
|
|
||||||
&& request.query === currentToken) ? result.tags : []
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
|
||||||
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
const textarea = ref.current!
|
const textarea = ref.current!
|
||||||
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
|
||||||
setTags (newValue)
|
setTags (newValue)
|
||||||
@@ -70,52 +56,18 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { inputProps, suggestionProps, requestSuggestions } =
|
||||||
|
useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect })
|
||||||
|
|
||||||
const recompute = (pos: number, v: string = tags) => {
|
const recompute = (pos: number, v: string = tags) => {
|
||||||
const { start, end, token } = getTokenAt (v, pos)
|
const { start, end, token } = getTokenAt (v, pos)
|
||||||
setRequest ({ query: token })
|
requestSuggestions (token)
|
||||||
setSuggestionsVsbl (token.trim () !== '')
|
|
||||||
if (!(token.trim ()))
|
if (!(token.trim ()))
|
||||||
{
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
setBounds ({ start, end })
|
setBounds ({ start, end })
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (request.query.trim () === '')
|
|
||||||
return
|
|
||||||
const controller = new AbortController ()
|
|
||||||
let active = true
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
|
||||||
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
|
|
||||||
if (active)
|
|
||||||
{
|
|
||||||
setResult ({ locale, request,
|
|
||||||
tags: tags.filter (tag => tag.postCount > 0) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (active)
|
|
||||||
setResult ({ locale, request, tags: [] })
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
controller.abort ()
|
|
||||||
}
|
|
||||||
}, [request, locale])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
}, [locale])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
@@ -134,15 +86,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
onFocus={() => setFocused (true)}
|
onFocus={() => setFocused (true)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused (false)
|
setFocused (false)
|
||||||
setSuggestionsVsbl (false)
|
inputProps.onBlur ()
|
||||||
}}/>
|
}}/>
|
||||||
{focused && (
|
{focused && (
|
||||||
<TagSearchBox
|
<TagSearchBox {...suggestionProps}/>)}
|
||||||
suggestions={suggestionsVsbl && suggestions.length > 0
|
|
||||||
? suggestions
|
|
||||||
: [] as Tag[]}
|
|
||||||
activeIndex={-1}
|
|
||||||
onSelect={handleTagSelect}/>)}
|
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react'
|
import { act, fireEvent, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -8,6 +8,8 @@ import * as api from '@/lib/api'
|
|||||||
import { buildTag } from '@/test/factories'
|
import { buildTag } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const LocationOutput = () => {
|
const LocationOutput = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -117,4 +119,39 @@ describe ('TagSearch', () => {
|
|||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
||||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('aborts obsolete requests and ignores their late suggestions', async () => {
|
||||||
|
const responses: ((tags: Tag[]) => void)[] = []
|
||||||
|
vi.mocked (api.apiGet).mockImplementation (() => new Promise<Tag[]> (resolve => {
|
||||||
|
responses.push (resolve)
|
||||||
|
}))
|
||||||
|
const { unmount } = renderSearch ()
|
||||||
|
const input = screen.getByRole ('textbox')
|
||||||
|
|
||||||
|
fireEvent.change (input, { target: { value: '虹' } })
|
||||||
|
const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
|
||||||
|
expect (previousSignal).toBeInstanceOf (AbortSignal)
|
||||||
|
expect (previousSignal?.aborted).toBe (false)
|
||||||
|
|
||||||
|
fireEvent.change (input, { target: { value: 'ぼ' } })
|
||||||
|
expect (previousSignal?.aborted).toBe (true)
|
||||||
|
expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', {
|
||||||
|
locale: 'ja', params: { q: 'ぼ', nico: '1' },
|
||||||
|
signal: expect.any (AbortSignal) })
|
||||||
|
const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
|
||||||
|
|
||||||
|
await act (async () => {
|
||||||
|
responses[1] ([buildTag ({ name: 'ぼっち' })])
|
||||||
|
})
|
||||||
|
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
|
||||||
|
|
||||||
|
await act (async () => {
|
||||||
|
responses[0] ([buildTag ({ name: '虹夏' })])
|
||||||
|
})
|
||||||
|
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
|
||||||
|
|
||||||
|
unmount ()
|
||||||
|
expect (currentSignal?.aborted).toBe (true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,82 +1,46 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
|
import { useLocaleNavigate } from '@/lib/useLocale'
|
||||||
|
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import TagSearchBox from './TagSearchBox'
|
import type { ChangeEvent, FC, FormEvent } from 'react'
|
||||||
|
|
||||||
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
|
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const TagSearch: FC = () => {
|
const TagSearch: FC = () => {
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const compositionFlgRef = useRef (false)
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
const [request, setRequest] = useState ({ query: '' })
|
|
||||||
const [result, setResult] = useState<{
|
|
||||||
locale: string
|
|
||||||
request: { query: string }
|
|
||||||
tags: Tag[] }> ({ locale, request, tags: [] })
|
|
||||||
const suggestions = result.locale === locale && result.request === request
|
|
||||||
? result.tags : []
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
|
||||||
|
|
||||||
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
const value = ev.target.value
|
const parts = search.split (' ')
|
||||||
const query = value.trim ().split (' ').at (-1) ?? ''
|
parts[parts.length - 1] = (
|
||||||
|
(parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:')
|
||||||
|
? ('not:' + tag.name)
|
||||||
|
: tag.name)
|
||||||
|
setSearch (parts.join (' ') + ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inputProps, suggestionProps, requestSuggestions,
|
||||||
|
clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
|
||||||
|
token: search.trim ().split (' ').at (-1) ?? '',
|
||||||
|
nicoFlg: true,
|
||||||
|
onSelect: handleTagSelect })
|
||||||
|
|
||||||
|
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value
|
||||||
setSearch (value)
|
setSearch (value)
|
||||||
setRequest ({ query })
|
requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
|
||||||
setActiveIndex (-1)
|
|
||||||
setSuggestionsVsbl (query !== '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
if (request.query === '')
|
event.preventDefault ()
|
||||||
return
|
|
||||||
const controller = new AbortController ()
|
|
||||||
let active = true
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
|
||||||
locale, signal: controller.signal, params: { q: request.query } })
|
|
||||||
if (active)
|
|
||||||
{
|
|
||||||
setResult ({ locale, request,
|
|
||||||
tags: tags.filter (tag => tag.postCount > 0) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (active)
|
|
||||||
setResult ({ locale, request, tags: [] })
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
controller.abort ()
|
|
||||||
}
|
|
||||||
}, [request, locale])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
setActiveIndex (-1)
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}, [locale])
|
|
||||||
|
|
||||||
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
|
|
||||||
ev.preventDefault ()
|
|
||||||
if (compositionFlgRef.current)
|
if (compositionFlgRef.current)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -89,61 +53,7 @@ const TagSearch: FC = () => {
|
|||||||
.filter (t => t !== 'type:or')
|
.filter (t => t !== 'type:or')
|
||||||
.join (' '),
|
.join (' '),
|
||||||
match })).toString () }`)
|
match })).toString () }`)
|
||||||
setSuggestionsVsbl (false)
|
clearSuggestions ()
|
||||||
setRequest ({ query: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (compositionFlgRef.current
|
|
||||||
|| ev.nativeEvent.isComposing
|
|
||||||
|| ev.nativeEvent.keyCode === 229)
|
|
||||||
{
|
|
||||||
if (ev.key === 'Enter')
|
|
||||||
ev.preventDefault ()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (ev.key)
|
|
||||||
{
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Enter':
|
|
||||||
{
|
|
||||||
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
|
|
||||||
if (selected == null)
|
|
||||||
break
|
|
||||||
ev.preventDefault ()
|
|
||||||
handleTagSelect (selected)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
|
||||||
const parts = search.split (' ')
|
|
||||||
parts[parts.length - 1] = (
|
|
||||||
(parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:')
|
|
||||||
? ('not:' + tag.name)
|
|
||||||
: tag.name)
|
|
||||||
setSearch (parts.join (' ') + ' ')
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setActiveIndex (-1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -151,33 +61,21 @@ const TagSearch: FC = () => {
|
|||||||
const anyFlg = query.get ('match') === 'any'
|
const anyFlg = query.get ('match') === 'any'
|
||||||
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
|
||||||
setSearch (tagsQuery)
|
setSearch (tagsQuery)
|
||||||
setRequest ({ query: '' })
|
clearSuggestions ()
|
||||||
setActiveIndex (-1)
|
}, [location.search, clearSuggestions])
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative w-full">
|
<form onSubmit={handleSubmit} className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
{...inputProps}
|
||||||
enterKeyHint="search"
|
enterKeyHint="search"
|
||||||
data-shortcut-focus-search="true"
|
data-shortcut-focus-search="true"
|
||||||
placeholder={message ('labels.tagSearch').text}
|
placeholder={message ('labels.tagSearch').text}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onCompositionStart={() => {
|
|
||||||
compositionFlgRef.current = true
|
|
||||||
}}
|
|
||||||
onCompositionEnd={() => {
|
|
||||||
compositionFlgRef.current = false
|
|
||||||
}}
|
|
||||||
className={inputClass (false,
|
className={inputClass (false,
|
||||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
<TagSearchBox {...suggestionProps}/>
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
</form>)
|
</form>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
@@ -20,18 +20,20 @@ describe ('TagInput', () => {
|
|||||||
const setValue = vi.fn ()
|
const setValue = vi.fn ()
|
||||||
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
|
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
|
||||||
|
|
||||||
render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
|
const { rerender } = render (
|
||||||
|
<TagInput value="ぼっち 虹" setValue={setValue}/>)
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
|
||||||
|
rerender (<TagInput value="ぼっち 虹夏" setValue={setValue}/>)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
|
locale: 'ja', params: { q: '虹夏', nico: '0' },
|
||||||
signal: expect.any (AbortSignal) },
|
signal: expect.any (AbortSignal) })
|
||||||
)
|
|
||||||
})
|
})
|
||||||
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
||||||
|
expect (await screen.findByText ('虹夏')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('does not fetch when the last token is blank', () => {
|
it ('does not fetch when the last token is blank', () => {
|
||||||
@@ -54,10 +56,51 @@ describe ('TagInput', () => {
|
|||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
'/tags/autocomplete',
|
'/tags/autocomplete', {
|
||||||
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
|
locale: 'ja', params: { q: '虹夏', nico: '1' },
|
||||||
signal: expect.any (AbortSignal) },
|
signal: expect.any (AbortSignal) })
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('preserves negation and blocks suggestion selection during composition', async () => {
|
||||||
|
const setValue = vi.fn ()
|
||||||
|
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })])
|
||||||
|
const { rerender } = render (<TagInput value="" setValue={setValue}/>)
|
||||||
|
const input = screen.getByRole ('textbox')
|
||||||
|
|
||||||
|
fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } })
|
||||||
|
rerender (<TagInput value="ぼっち NOT:虹" setValue={setValue}/>)
|
||||||
|
await screen.findByText ('虹夏')
|
||||||
|
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||||
|
'/tags/autocomplete', {
|
||||||
|
locale: 'ja', params: { q: '虹', nico: '0' },
|
||||||
|
signal: expect.any (AbortSignal) })
|
||||||
|
|
||||||
|
fireEvent.keyDown (input, { key: 'ArrowDown' })
|
||||||
|
fireEvent.compositionStart (input)
|
||||||
|
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false)
|
||||||
|
expect (setValue).toHaveBeenCalledTimes (1)
|
||||||
|
|
||||||
|
fireEvent.compositionEnd (input)
|
||||||
|
fireEvent.keyDown (input, { key: 'Enter' })
|
||||||
|
|
||||||
|
expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ')
|
||||||
|
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => {
|
||||||
|
const tag = buildTag ({ name: '虹夏', postCount: 0 })
|
||||||
|
const response = Promise.resolve ([tag])
|
||||||
|
api.apiGet.mockReturnValueOnce (response)
|
||||||
|
const props = { setValue: vi.fn (), includeUnused: unusedFlg }
|
||||||
|
const { rerender } = render (<TagInput {...props} value=""/>)
|
||||||
|
|
||||||
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } })
|
||||||
|
rerender (<TagInput {...props} value="虹"/>)
|
||||||
|
await act (async () => {
|
||||||
|
await response
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import { apiGet } from '@/lib/api'
|
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
|
import type { FC, ChangeEvent } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
@@ -19,136 +16,39 @@ type Props = {
|
|||||||
setValue: (value: string) => void }
|
setValue: (value: string) => void }
|
||||||
|
|
||||||
|
|
||||||
const TagInput: FC<Props> = ({
|
const TagInput: FC<Props> = (
|
||||||
includeNico = false,
|
{ includeNico: nicoFlg = false,
|
||||||
includeUnused = false,
|
includeUnused: unusedFlg = false,
|
||||||
describedBy,
|
describedBy, invalid, value, setValue },
|
||||||
invalid,
|
) => {
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
}) => {
|
|
||||||
const locale = useLocale ()
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [request, setRequest] = useState ({ query: '' })
|
|
||||||
const [result, setResult] = useState<{
|
|
||||||
locale: string
|
|
||||||
includeNico: boolean
|
|
||||||
request: { query: string }
|
|
||||||
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
|
|
||||||
const suggestions = (result.locale === locale && result.includeNico === includeNico
|
|
||||||
&& result.request === request
|
|
||||||
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setValue (ev.target.value)
|
|
||||||
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
|
|
||||||
setRequest ({ query })
|
|
||||||
setActiveIndex (-1)
|
|
||||||
setSuggestionsVsbl (query !== '')
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (request.query === '')
|
|
||||||
return
|
|
||||||
const controller = new AbortController ()
|
|
||||||
let active = true
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
|
||||||
locale, signal: controller.signal,
|
|
||||||
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
|
|
||||||
if (active)
|
|
||||||
{
|
|
||||||
setResult ({ locale, includeNico, request,
|
|
||||||
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (active)
|
|
||||||
setResult ({ locale, includeNico, request, tags: [] })
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
controller.abort ()
|
|
||||||
}
|
|
||||||
}, [request, locale, includeNico, includeUnused])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
setActiveIndex (-1)
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
}, [locale, includeNico])
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
const parts = value?.split (' ')
|
const parts = value?.split (' ')
|
||||||
const negative = /^not:/i.test (parts[parts.length - 1])
|
const negative = /^not:/i.test (parts[parts.length - 1])
|
||||||
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
||||||
setValue (parts.join (' ') + ' ')
|
setValue (parts.join (' ') + ' ')
|
||||||
setRequest ({ query: '' })
|
|
||||||
setActiveIndex (-1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
const { inputProps, suggestionProps, requestSuggestions } =
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
|
||||||
switch (ev.key)
|
nicoFlg, unusedFlg, onSelect: handleTagSelect })
|
||||||
{
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
ev.preventDefault ()
|
setValue (event.target.value)
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
const token = event.target.value.trim ().split (' ').at (-1) ?? ''
|
||||||
setSuggestionsVsbl (true)
|
requestSuggestions (token, token.replace (/^not:/i, ''))
|
||||||
break
|
|
||||||
|
|
||||||
case 'Enter':
|
|
||||||
if (activeIndex < 0)
|
|
||||||
break
|
|
||||||
{
|
|
||||||
ev.preventDefault ()
|
|
||||||
const selected = suggestions[activeIndex]
|
|
||||||
if (selected)
|
|
||||||
handleTagSelect (selected)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
|
||||||
{
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
{...inputProps}
|
||||||
type="text"
|
type="text"
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className={inputClass (invalid)}/>
|
className={inputClass (invalid)}/>
|
||||||
<TagSearchBox
|
<TagSearchBox {...suggestionProps}/>
|
||||||
suggestions={
|
|
||||||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,16 +70,6 @@ describe ('SidebarComponent', () => {
|
|||||||
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
|
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
|
|
||||||
renderSidebar ()
|
|
||||||
|
|
||||||
// jsdom has no layout; guard the responsive classes responsible for clipping.
|
|
||||||
const sidebar = screen.getByText ('Sidebar body')
|
|
||||||
|
|
||||||
expect (sidebar).not.toHaveClass ('overflow-y-auto')
|
|
||||||
expect (sidebar).toHaveClass ('md:overflow-y-auto')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
|
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
|
||||||
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
|
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
|
||||||
const onWidthChange = vi.fn ()
|
const onWidthChange = vi.fn ()
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
</style>
|
</style>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="h-full md:overflow-y-auto p-4 sidebar">
|
<div className="h-full overflow-y-auto p-4 sidebar">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import type { Deerjikist,
|
|||||||
TagVersion } from '@/types'
|
TagVersion } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchTagAutocomplete = async (
|
||||||
|
query: string,
|
||||||
|
nicoFlg: boolean,
|
||||||
|
locale: LocaleCode,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Tag[]> =>
|
||||||
|
await apiGet ('/tags/autocomplete', {
|
||||||
|
locale, signal,
|
||||||
|
params: { q: query, nico: nicoFlg ? '1' : '0' } })
|
||||||
|
|
||||||
|
|
||||||
export const fetchTags = async (
|
export const fetchTags = async (
|
||||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, deprecated,
|
updatedFrom, updatedTo, deprecated,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { fetchTagAutocomplete } from '@/lib/tags'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
|
||||||
|
import type { KeyboardEvent } from 'react'
|
||||||
|
|
||||||
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
|
export const useTagAutocomplete = (
|
||||||
|
{ token, onSelect, nicoFlg = false, unusedFlg = false }: {
|
||||||
|
token: string
|
||||||
|
onSelect: (tag: Tag) => void
|
||||||
|
nicoFlg?: boolean
|
||||||
|
unusedFlg?: boolean },
|
||||||
|
) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const compositionFlgRef = useRef (false)
|
||||||
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
|
const [visible, setVisible] = useState (false)
|
||||||
|
const [request, setRequest] = useState ({ token: '', query: '' })
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
locale: string
|
||||||
|
nicoFlg: boolean
|
||||||
|
unusedFlg: boolean
|
||||||
|
request: { token: string; query: string }
|
||||||
|
tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] })
|
||||||
|
const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg
|
||||||
|
&& result.unusedFlg === unusedFlg && result.request === request
|
||||||
|
&& request.token === token) ? result.tags : []
|
||||||
|
|
||||||
|
const requestSuggestions = (token: string, query: string = token) => {
|
||||||
|
setRequest ({ token, query })
|
||||||
|
setActiveIndex (-1)
|
||||||
|
setVisible (token.trim () !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSuggestions = useCallback (() => {
|
||||||
|
setRequest ({ token: '', query: '' })
|
||||||
|
setActiveIndex (-1)
|
||||||
|
setVisible (false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (request.token.trim () === '')
|
||||||
|
return
|
||||||
|
const controller = new AbortController ()
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const tags = await fetchTagAutocomplete (
|
||||||
|
request.query, nicoFlg, locale, controller.signal)
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
setResult ({ locale, nicoFlg, unusedFlg, request,
|
||||||
|
tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] })
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort ()
|
||||||
|
}
|
||||||
|
}, [request, locale, nicoFlg, unusedFlg])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
clearSuggestions ()
|
||||||
|
}, [locale, nicoFlg, clearSuggestions])
|
||||||
|
|
||||||
|
const handleTagSelect = (tag: Tag) => {
|
||||||
|
clearSuggestions ()
|
||||||
|
onSelect (tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (compositionFlgRef.current
|
||||||
|
|| event.nativeEvent.isComposing
|
||||||
|
|| event.nativeEvent.keyCode === 229)
|
||||||
|
{
|
||||||
|
if (event.key === 'Enter')
|
||||||
|
event.preventDefault ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key)
|
||||||
|
{
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault ()
|
||||||
|
setActiveIndex (index => Math.min (index + 1, suggestions.length - 1))
|
||||||
|
setVisible (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault ()
|
||||||
|
setActiveIndex (index => Math.max (index - 1, -1))
|
||||||
|
setVisible (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
{
|
||||||
|
const selected = visible ? suggestions[activeIndex] : null
|
||||||
|
if (selected == null)
|
||||||
|
{
|
||||||
|
setVisible (false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
event.preventDefault ()
|
||||||
|
handleTagSelect (selected)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault ()
|
||||||
|
setVisible (false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestSuggestions, clearSuggestions, compositionFlgRef,
|
||||||
|
inputProps: {
|
||||||
|
onFocus: () => setVisible (true),
|
||||||
|
onBlur: () => setVisible (false),
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
onCompositionStart: () => {
|
||||||
|
compositionFlgRef.current = true
|
||||||
|
},
|
||||||
|
onCompositionEnd: () => {
|
||||||
|
compositionFlgRef.current = false
|
||||||
|
} },
|
||||||
|
suggestionProps: {
|
||||||
|
suggestions: visible ? suggestions : [],
|
||||||
|
activeIndex,
|
||||||
|
onSelect: handleTagSelect } }
|
||||||
|
}
|
||||||
+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)}
|
||||||
|
|||||||
@@ -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')}/>
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ const renderPage = () =>
|
|||||||
describe ('TagDetailPage', () => {
|
describe ('TagDetailPage', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
tagsApi.fetchTag.mockReset ()
|
|
||||||
api.apiPut.mockReset ()
|
|
||||||
api.isApiError.mockReset ()
|
|
||||||
toastApi.toast.mockReset ()
|
|
||||||
api.isApiError.mockReturnValue (false)
|
api.isApiError.mockReturnValue (false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,11 +51,8 @@ describe ('TagDetailPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it ('submits edited tag fields', async () => {
|
it ('submits edited tag fields', async () => {
|
||||||
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] })
|
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
|
||||||
tagsApi.fetchTag
|
api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
|
||||||
.mockResolvedValue (updatedTag)
|
|
||||||
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
|
|
||||||
api.apiPut.mockResolvedValueOnce (updatedTag)
|
|
||||||
|
|
||||||
renderPage ()
|
renderPage ()
|
||||||
|
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする