コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ b9d7c4fef5 #444 2026-11-08 01:56:45 +09:00
みてるぞ 10b3717886 Merge remote-tracking branch 'origin/main' into feature/444 2026-11-07 20:06:40 +09:00
みてるぞ 9e803d473a #444 2026-11-07 15:50:00 +09:00
24個のファイルの変更、134行の追加、501行の削除
+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
-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 -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,
-120
ファイルの表示
@@ -1,120 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+22 -42
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する. // TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -20,7 +20,6 @@ const TagSearch: FC = () => {
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1) const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
@@ -75,34 +74,7 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}, [locale]) }, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key) switch (ev.key)
{ {
case 'ArrowDown': case 'ArrowDown':
@@ -118,11 +90,12 @@ const TagSearch: FC = () => {
break break
case 'Enter': case 'Enter':
{ if (activeIndex < 0)
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break break
ev.preventDefault () ev.preventDefault ()
{
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected) handleTagSelect (selected)
} }
break break
@@ -132,6 +105,20 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
break break
} }
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
} }
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
@@ -157,9 +144,8 @@ const TagSearch: FC = () => {
}, [location.search]) }, [location.search])
return ( return (
<form onSubmit={handleSubmit} className="relative w-full"> <div className="relative w-full">
<input type="text" <input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true" data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text} placeholder={message ('labels.tagSearch').text}
value={search} value={search}
@@ -167,18 +153,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false, className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</form>) </div>)
} }
export default TagSearch export default TagSearch
+11 -25
ファイルの表示
@@ -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 =>
-10
ファイルの表示
@@ -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 ()
+1 -1
ファイルの表示
@@ -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>
+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 = '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 },
+1 -2
ファイルの表示
@@ -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: (
+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)
{ {
-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)}
+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')}/>
+2 -9
ファイルの表示
@@ -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 ()
+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')}>