Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = { id?: string
|
||||
messages?: string[] }
|
||||
messages?: ReactNode[] }
|
||||
|
||||
|
||||
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = { id?: string
|
||||
messages?: string[] }
|
||||
messages?: ReactNode[] }
|
||||
|
||||
|
||||
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
|
||||
className?: string
|
||||
label: ReactNode
|
||||
messages?: string[] }
|
||||
messages?: ReactNode[] }
|
||||
|
||||
|
||||
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { currentRenderContext } from '@/lib/renderContext'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type LocalisedTitle = { text: string; language: string }
|
||||
type Props = {
|
||||
title?: string | LocalisedTitle
|
||||
includeSiteName?: boolean
|
||||
description?: LocalisedTitle
|
||||
canonical?: string
|
||||
children?: ReactNode }
|
||||
|
||||
|
||||
const PageHead = (
|
||||
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const origin = currentRenderContext ()?.origin
|
||||
const pageCanonical = canonical ?? (origin == null
|
||||
? undefined : new URL (location.pathname + location.search, origin).href)
|
||||
const { message, siteTitle } = useUITranslation ('shared')
|
||||
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
||||
const fullTitle =
|
||||
pageTitle == null
|
||||
? siteTitle.text
|
||||
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text)
|
||||
const description = pageDescription ?? pageTitle
|
||||
?? message ('head.description', { brand: siteTitle.text })
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
||||
<meta name="description" content={description.text} lang={description.language}/>
|
||||
{pageCanonical != null && <link rel="canonical" href={pageCanonical}/>}
|
||||
{children}
|
||||
</Helmet>)
|
||||
}
|
||||
|
||||
|
||||
export default PageHead
|
||||
@@ -13,11 +13,11 @@ describe ('Pagination', () => {
|
||||
|
||||
expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
|
||||
'href',
|
||||
'/posts?tags=abc&page=2',
|
||||
'/ja/posts?tags=abc&page=2',
|
||||
)
|
||||
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
|
||||
'href',
|
||||
'/posts?tags=abc&page=4',
|
||||
'/ja/posts?tags=abc&page=4',
|
||||
)
|
||||
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
|
||||
})
|
||||
@@ -33,6 +33,6 @@ describe ('Pagination', () => {
|
||||
|
||||
rerender (<Pagination page={1} totalPages={2}/>)
|
||||
|
||||
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
|
||||
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/ja/tags?page=2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -49,6 +50,7 @@ const getPages = (
|
||||
|
||||
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
const location = useLocation ()
|
||||
const { message } = useUITranslation ()
|
||||
|
||||
const buildTo = (p: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
const pages = getPages (page, totalPages, siblingCount)
|
||||
|
||||
return (
|
||||
<nav className="mt-4 flex justify-center" aria-label="Pagination">
|
||||
<nav className="mt-4 flex justify-center"
|
||||
lang={message ('pagination.label').language}
|
||||
aria-label={message ('pagination.label').text}>
|
||||
<div className="flex items-center gap-2">
|
||||
{(page > 1)
|
||||
? (
|
||||
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (1)}
|
||||
aria-label="最初のページ">
|
||||
lang={message ('pagination.first').language}
|
||||
aria-label={message ('pagination.first').text}>
|
||||
|<
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page - 1)}
|
||||
aria-label="前のページ">
|
||||
lang={message ('pagination.previous').language}
|
||||
aria-label={message ('pagination.previous').text}>
|
||||
<
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page + 1)}
|
||||
aria-label="次のページ">
|
||||
lang={message ('pagination.next').language}
|
||||
aria-label={message ('pagination.next').text}>
|
||||
>
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (totalPages)}
|
||||
aria-label="最後のページ">
|
||||
lang={message ('pagination.last').language}
|
||||
aria-label={message ('pagination.last').text}>
|
||||
>|
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TabProps = { name: string
|
||||
type TabProps = { name: React.ReactNode
|
||||
init?: boolean
|
||||
children: React.ReactNode }
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
import { buildTag } from '@/test/factories'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
@@ -26,7 +27,8 @@ describe ('TagInput', () => {
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete',
|
||||
{ params: { q: '虹夏', nico: '0' } },
|
||||
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
|
||||
@@ -53,7 +55,8 @@ describe ('TagInput', () => {
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/tags/autocomplete',
|
||||
{ params: { q: '虹夏', nico: '1' } },
|
||||
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
|
||||
signal: expect.any (AbortSignal) },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -24,35 +25,69 @@ const TagInput: FC<Props> = ({
|
||||
value,
|
||||
setValue,
|
||||
}) => {
|
||||
const locale = useLocale ()
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||
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 = async (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue (ev.target.value)
|
||||
|
||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
||||
if (!(q))
|
||||
{
|
||||
setSuggestions ([])
|
||||
return
|
||||
}
|
||||
|
||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
||||
q,
|
||||
nico: includeNico ? '1' : '0' } })
|
||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||
setSuggestions (nextSuggestions)
|
||||
setSuggestionsVsbl (nextSuggestions.length > 0)
|
||||
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, nico: includeNico ? '1' : '0' } })
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, includeNico, request,
|
||||
tags: tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setResult ({ locale, includeNico, request, tags: [] })
|
||||
}
|
||||
}) ()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale, includeNico])
|
||||
|
||||
useEffect (() => {
|
||||
setActiveIndex (-1)
|
||||
setSuggestionsVsbl (false)
|
||||
setRequest ({ query: '' })
|
||||
}, [locale, includeNico])
|
||||
|
||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
const parts = value?.split (' ')
|
||||
parts[parts.length - 1] = tag.name
|
||||
setValue (parts.join (' ') + ' ')
|
||||
setSuggestions ([])
|
||||
setRequest ({ query: '' })
|
||||
setActiveIndex (-1)
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする