Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/350main
| @@ -18,12 +18,13 @@ npm install | |||
| npm run dev | |||
| npm run build | |||
| npm run lint | |||
| npm test | |||
| npm run test | |||
| npm run test:run | |||
| ``` | |||
| ### Full verification | |||
| ```sh | |||
| cd backend && bundle exec rspec | |||
| cd ../frontend && npm run build && npm run lint | |||
| cd ../frontend && npm run test:run && npm run build && npm run lint | |||
| ``` | |||
| @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' | |||
| import tseslint from 'typescript-eslint' | |||
| export default tseslint.config( | |||
| { ignores: ['dist'] }, | |||
| { ignores: ['dist', 'tailwind.config.js'] }, | |||
| { | |||
| extends: [js.configs.recommended, ...tseslint.configs.recommended], | |||
| files: ['**/*.{ts,tsx}'], | |||
| @@ -8,6 +8,8 @@ | |||
| "build": "tsc -b && vite build", | |||
| "postbuild": "node scripts/generate-sitemap.js", | |||
| "lint": "eslint .", | |||
| "test": "vitest", | |||
| "test:run": "vitest run", | |||
| "preview": "vite preview" | |||
| }, | |||
| "dependencies": { | |||
| @@ -45,6 +47,10 @@ | |||
| "devDependencies": { | |||
| "@eslint/js": "^9.25.0", | |||
| "@tailwindcss/typography": "^0.5.19", | |||
| "@testing-library/dom": "^10.4.1", | |||
| "@testing-library/jest-dom": "^6.9.1", | |||
| "@testing-library/react": "^16.3.2", | |||
| "@testing-library/user-event": "^14.6.1", | |||
| "@types/axios": "^0.14.4", | |||
| "@types/markdown-it": "^14.1.2", | |||
| "@types/mdx": "^2.0.13", | |||
| @@ -58,11 +64,13 @@ | |||
| "eslint-plugin-react-hooks": "^5.2.0", | |||
| "eslint-plugin-react-refresh": "^0.4.19", | |||
| "globals": "^16.0.0", | |||
| "jsdom": "^26.1.0", | |||
| "postcss": "^8.5.3", | |||
| "tailwindcss": "^3.4.13", | |||
| "typescript": "~5.8.3", | |||
| "typescript-eslint": "^8.30.1", | |||
| "vite": "^6.3.5" | |||
| "vite": "^6.3.5", | |||
| "vitest": "^4.1.5" | |||
| }, | |||
| "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", | |||
| "main": "eslint.config.js", | |||
| @@ -93,7 +93,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { | |||
| } | |||
| export default (() => { | |||
| const App: FC = () => { | |||
| const [user, setUser] = useState<User | null> (null) | |||
| const [status, setStatus] = useState (200) | |||
| @@ -156,4 +156,6 @@ export default (() => { | |||
| </DialogueProvider> | |||
| </BrowserRouter> | |||
| </>) | |||
| }) satisfies FC | |||
| } | |||
| export default App | |||
| @@ -19,7 +19,7 @@ type Props = { | |||
| sp?: boolean } | |||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { | |||
| const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { | |||
| const dndId = `tag-node:${ pathKey }` | |||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | |||
| @@ -96,4 +96,6 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| </motion.div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default DraggableDroppableTagRow | |||
| @@ -0,0 +1,32 @@ | |||
| import { render, screen } from '@testing-library/react' | |||
| import { HelmetProvider } from 'react-helmet-async' | |||
| import { describe, expect, it } from 'vitest' | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| describe ('ErrorScreen', () => { | |||
| it.each ([ | |||
| [403, '権限ないよ(笑)'], | |||
| [404, 'ページないよ(笑)'], | |||
| [500, '鯖でエラー出たって(嘲笑)'], | |||
| [503, '鯖死んでるよ(泣)'], | |||
| ]) ('renders status %s', (status, message) => { | |||
| render ( | |||
| <HelmetProvider> | |||
| <ErrorScreen status={status}/> | |||
| </HelmetProvider>, | |||
| ) | |||
| expect (screen.getByText (String (status))).toBeInTheDocument () | |||
| expect (screen.getByText (message)).toBeInTheDocument () | |||
| expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument () | |||
| }) | |||
| it ('throws for unsupported statuses', () => { | |||
| expect (() => render ( | |||
| <HelmetProvider> | |||
| <ErrorScreen status={418}/> | |||
| </HelmetProvider>, | |||
| )).toThrow () | |||
| }) | |||
| }) | |||
| @@ -10,7 +10,7 @@ import type { FC } from 'react' | |||
| type Props = { status: number } | |||
| export default (({ status }: Props) => { | |||
| const ErrorScreen: FC<Props> = ({ status }) => { | |||
| const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | |||
| switch (status) | |||
| { | |||
| @@ -58,4 +58,6 @@ export default (({ status }: Props) => { | |||
| <p className="mr-[-.5em]">{message}</p> | |||
| </div> | |||
| </MainArea>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default ErrorScreen | |||
| @@ -31,7 +31,7 @@ const setChildrenById = ( | |||
| })) | |||
| export default (() => { | |||
| const MaterialSidebar: FC = () => { | |||
| const [tags, setTags] = useState<TagWithDepth[]> ([]) | |||
| const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | |||
| const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | |||
| @@ -94,4 +94,6 @@ export default (() => { | |||
| {renderTags (tags)} | |||
| </ul> | |||
| </SidebarComponent>) | |||
| }) satisfies FC | |||
| } | |||
| export default MaterialSidebar | |||
| @@ -1,9 +1,11 @@ | |||
| import type { FC } from 'react' | |||
| export default (() => ( | |||
| const MenuSeparator: FC = () => ( | |||
| <> | |||
| <span className="hidden md:inline flex items-center px-2">|</span> | |||
| <hr className="block md:hidden w-full opacity-25 | |||
| border-t border-black dark:border-white"/> | |||
| </>)) satisfies FC | |||
| </>) | |||
| export default MenuSeparator | |||
| @@ -0,0 +1,69 @@ | |||
| import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import PostEditForm from '@/components/PostEditForm' | |||
| import { buildPost, buildTag } from '@/test/factories' | |||
| const postsApi = vi.hoisted (() => ({ | |||
| updatePost: vi.fn (), | |||
| })) | |||
| const api = vi.hoisted (() => ({ | |||
| isApiError: vi.fn (() => false), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/posts', () => postsApi) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| vi.mock ('@/components/dialogues/DialogueProvider', () => ({ | |||
| useDialogue: () => ({ | |||
| choice: vi.fn (), | |||
| }), | |||
| })) | |||
| describe ('PostEditForm', () => { | |||
| it ('submits edited post fields with the current base version', async () => { | |||
| const onSave = vi.fn () | |||
| const post = buildPost ({ | |||
| id: 8, | |||
| versionNo: 4, | |||
| title: 'old', | |||
| tags: [ | |||
| buildTag ({ name: 'general-tag', category: 'general' }), | |||
| buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }), | |||
| ], | |||
| parentPosts: [buildPost ({ id: 2, title: 'parent' })], | |||
| }) | |||
| postsApi.updatePost.mockResolvedValueOnce ({ | |||
| ...post, | |||
| versionNo: 5, | |||
| title: 'new', | |||
| tags: [buildTag ({ name: 'new-tag' })], | |||
| }) | |||
| render (<PostEditForm post={post} onSave={onSave}/>) | |||
| const [title, parentIds] = screen.getAllByRole ('textbox') | |||
| fireEvent.change (title, { target: { value: 'new' } }) | |||
| fireEvent.change (parentIds, { target: { value: '3 4' } }) | |||
| fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) | |||
| await waitFor (() => { | |||
| expect (postsApi.updatePost).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| id: 8, | |||
| title: 'new', | |||
| parentPostIds: '3 4', | |||
| tags: 'general-tag', | |||
| }), | |||
| { baseVersionNo: 4 }, | |||
| ) | |||
| }) | |||
| expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) | |||
| }) | |||
| }) | |||
| @@ -6,6 +6,7 @@ import Label from '@/components/common/Label' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import { Button } from '@/components/ui/button' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { isApiError } from '@/lib/api' | |||
| import { updatePost } from '@/lib/posts' | |||
| import type { FC, FormEvent } from 'react' | |||
| @@ -32,7 +33,7 @@ type Props = { post: Post | |||
| onSave: (newPost: Post) => void } | |||
| export default (({ post, onSave }: Props) => { | |||
| const PostEditForm: FC<Props> = ({ post, onSave }) => { | |||
| const [disabled, setDisabled] = useState (false) | |||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | |||
| useState<string | null> (post.originalCreatedBefore) | |||
| @@ -62,7 +63,7 @@ export default (({ post, onSave }: Props) => { | |||
| } | |||
| catch (e) | |||
| { | |||
| const response = (e as any)?.response | |||
| const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined | |||
| if (response?.status !== 409) | |||
| { | |||
| @@ -164,4 +165,6 @@ export default (({ post, onSave }: Props) => { | |||
| 更新 | |||
| </Button> | |||
| </form>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostEditForm | |||
| @@ -0,0 +1,63 @@ | |||
| import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import PostEmbed from '@/components/PostEmbed' | |||
| import { buildPost } from '@/test/factories' | |||
| const dialogue = vi.hoisted (() => ({ | |||
| confirm: vi.fn (), | |||
| })) | |||
| vi.mock ('@/components/dialogues/DialogueProvider', () => ({ | |||
| useDialogue: () => dialogue, | |||
| })) | |||
| vi.mock ('@/components/NicoViewer', () => ({ | |||
| default: ({ id }: { id: string }) => <div>Nico:{id}</div>, | |||
| })) | |||
| vi.mock ('react-youtube', () => ({ | |||
| default: ({ videoId }: { videoId: string }) => <div>YouTube:{videoId}</div>, | |||
| })) | |||
| describe ('PostEmbed', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| }) | |||
| it ('embeds nicovideo watch URLs', () => { | |||
| render (<PostEmbed post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}/>) | |||
| expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument () | |||
| }) | |||
| it ('embeds x/twitter status URLs', () => { | |||
| render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>) | |||
| expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument () | |||
| }) | |||
| it ('embeds youtube watch URLs', () => { | |||
| render (<PostEmbed post={buildPost ({ url: 'https://www.youtube.com/watch?v=abc123' })}/>) | |||
| expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument () | |||
| }) | |||
| it ('asks before framing unknown external pages', async () => { | |||
| dialogue.confirm.mockResolvedValueOnce (true) | |||
| render ( | |||
| <PostEmbed | |||
| post={buildPost ({ url: 'https://example.com/page', title: 'external' })}/>, | |||
| ) | |||
| fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' })) | |||
| await waitFor (() => { | |||
| expect (dialogue.confirm).toHaveBeenCalled () | |||
| }) | |||
| expect (await screen.findByTitle ('external')).toHaveAttribute ( | |||
| 'src', | |||
| 'https://example.com/page', | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -16,8 +16,9 @@ type Props = { | |||
| onMetadataChange?: (meta: NiconicoMetadata) => void } | |||
| export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { | |||
| const dialogue = useDialogue () | |||
| const [framed, setFramed] = useState (false) | |||
| const url = new URL (post.url) | |||
| @@ -44,7 +45,7 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| case 'twitter.com': | |||
| case 'x.com': | |||
| { | |||
| const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/) | |||
| const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) | |||
| const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) | |||
| if (!(mUserId) || !(mStatusId)) | |||
| break | |||
| @@ -72,8 +73,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| } | |||
| } | |||
| const [framed, setFramed] = useState (false) | |||
| return ( | |||
| <> | |||
| {framed | |||
| @@ -101,4 +100,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| </a> | |||
| </div>)} | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostEmbed | |||
| @@ -0,0 +1,34 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
| import { buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| describe ('PostFormTagsArea', () => { | |||
| it ('updates text and fetches autocomplete for the selected token', async () => { | |||
| const setTags = vi.fn () | |||
| api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) | |||
| renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>) | |||
| const textarea = screen.getByRole ('textbox') | |||
| fireEvent.focus (textarea) | |||
| fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) | |||
| fireEvent.change (textarea, { target: { value: '虹夏' } }) | |||
| await waitFor (() => { | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/tags/autocomplete', | |||
| { params: { q: '虹', nico: '0' } }, | |||
| ) | |||
| }) | |||
| expect (setTags).toHaveBeenCalledWith ('虹夏') | |||
| }) | |||
| }) | |||
| @@ -36,7 +36,7 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | ' | |||
| setTags: (tags: string) => void } | |||
| export default (({ tags, setTags, ...rest }: Props) => { | |||
| const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => { | |||
| const ref = useRef<HTMLTextAreaElement> (null) | |||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | |||
| @@ -97,4 +97,6 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||
| activeIndex={-1} | |||
| onSelect={handleTagSelect}/>)} | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostFormTagsArea | |||
| @@ -0,0 +1,44 @@ | |||
| import { fireEvent, screen } from '@testing-library/react' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import PostList from '@/components/PostList' | |||
| import { buildPost } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const prefetchers = vi.hoisted (() => ({ | |||
| prefetchForURL: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/prefetchers', () => prefetchers) | |||
| describe ('PostList', () => { | |||
| beforeEach (() => { | |||
| prefetchers.prefetchForURL.mockResolvedValue (undefined) | |||
| }) | |||
| it ('renders post thumbnails as links to post details', () => { | |||
| renderWithProviders ( | |||
| <PostList posts={[ | |||
| buildPost ({ id: 1, title: 'First', thumbnail: 'first.jpg' }), | |||
| buildPost ({ id: 2, title: null, url: 'https://example.com/second' }), | |||
| ]}/>, | |||
| ) | |||
| expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( | |||
| 'href', | |||
| '/posts/1', | |||
| ) | |||
| expect ( | |||
| screen.getByRole ('link', { name: 'https://example.com/second' }), | |||
| ).toHaveAttribute ('href', '/posts/2') | |||
| }) | |||
| it ('calls the optional click handler', () => { | |||
| const onClick = vi.fn () | |||
| renderWithProviders (<PostList posts={[buildPost ()]} onClick={onClick}/>) | |||
| fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' })) | |||
| expect (onClick).toHaveBeenCalledTimes (1) | |||
| }) | |||
| }) | |||
| @@ -14,7 +14,7 @@ type Props = { posts: Post[] | |||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | |||
| export default (({ posts, onClick }: Props) => { | |||
| const PostList: FC<Props> = ({ posts, onClick }) => { | |||
| const location = useLocation () | |||
| const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | |||
| @@ -70,4 +70,6 @@ export default (({ posts, onClick }: Props) => { | |||
| </PrefetchLink>) | |||
| })} | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostList | |||
| @@ -0,0 +1,63 @@ | |||
| import { fireEvent, render, screen } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||
| describe ('PostOriginalCreatedTimeField', () => { | |||
| it ('updates from and before values', () => { | |||
| const setFrom = vi.fn () | |||
| const setBefore = vi.fn () | |||
| render ( | |||
| <PostOriginalCreatedTimeField | |||
| originalCreatedFrom={null} | |||
| setOriginalCreatedFrom={setFrom} | |||
| originalCreatedBefore={null} | |||
| setOriginalCreatedBefore={setBefore}/>, | |||
| ) | |||
| const inputs = screen.getAllByDisplayValue ('') | |||
| fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) | |||
| fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) | |||
| expect (setFrom).toHaveBeenCalledWith (expect.any (String)) | |||
| expect (setBefore).toHaveBeenCalledWith (expect.any (String)) | |||
| }) | |||
| it ('infers an exclusive before value on blur', () => { | |||
| const setBefore = vi.fn () | |||
| render ( | |||
| <PostOriginalCreatedTimeField | |||
| originalCreatedFrom={null} | |||
| setOriginalCreatedFrom={vi.fn ()} | |||
| originalCreatedBefore={null} | |||
| setOriginalCreatedBefore={setBefore}/>, | |||
| ) | |||
| const input = screen.getAllByDisplayValue ('')[0] | |||
| fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) | |||
| expect (setBefore).toHaveBeenCalledWith (expect.any (String)) | |||
| }) | |||
| it ('resets both values', () => { | |||
| const setFrom = vi.fn () | |||
| const setBefore = vi.fn () | |||
| render ( | |||
| <PostOriginalCreatedTimeField | |||
| originalCreatedFrom="2026-01-01T00:00:00Z" | |||
| setOriginalCreatedFrom={setFrom} | |||
| originalCreatedBefore="2026-01-02T00:00:00Z" | |||
| setOriginalCreatedBefore={setBefore}/>, | |||
| ) | |||
| const buttons = screen.getAllByRole ('button', { name: 'リセット' }) | |||
| fireEvent.click (buttons[0]) | |||
| fireEvent.click (buttons[1]) | |||
| expect (setFrom).toHaveBeenCalledWith (null) | |||
| expect (setBefore).toHaveBeenCalledWith (null) | |||
| }) | |||
| }) | |||
| @@ -12,11 +12,11 @@ type Props = { | |||
| setOriginalCreatedBefore: (x: string | null) => void } | |||
| export default (({ disabled, | |||
| const PostOriginalCreatedTimeField: FC<Props> = ({ disabled, | |||
| originalCreatedFrom, | |||
| setOriginalCreatedFrom, | |||
| originalCreatedBefore, | |||
| setOriginalCreatedBefore }: Props) => ( | |||
| setOriginalCreatedBefore }) => ( | |||
| <div> | |||
| <Label>オリジナルの作成日時</Label> | |||
| <div className="my-1 flex"> | |||
| @@ -71,4 +71,6 @@ export default (({ disabled, | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </div>)) satisfies FC<Props> | |||
| </div>) | |||
| export default PostOriginalCreatedTimeField | |||
| @@ -0,0 +1,30 @@ | |||
| import { render, screen } from '@testing-library/react' | |||
| import { afterEach, describe, expect, it } from 'vitest' | |||
| import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay' | |||
| describe ('RouteBlockerOverlay', () => { | |||
| afterEach (() => { | |||
| useOverlayStore.setState ({ active: false }) | |||
| document.body.style.overflow = '' | |||
| document.body.removeAttribute ('aria-busy') | |||
| }) | |||
| it ('renders nothing while inactive', () => { | |||
| useOverlayStore.setState ({ active: false }) | |||
| const { container } = render (<RouteBlockerOverlay/>) | |||
| expect (container).toBeEmptyDOMElement () | |||
| }) | |||
| it ('renders a blocking progressbar and marks the body busy while active', () => { | |||
| useOverlayStore.setState ({ active: true }) | |||
| render (<RouteBlockerOverlay/>) | |||
| expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument () | |||
| expect (document.body).toHaveAttribute ('aria-busy', 'true') | |||
| expect (document.body.style.overflow).toBe ('hidden') | |||
| }) | |||
| }) | |||
| @@ -13,7 +13,7 @@ export const useOverlayStore = create<OverlayStore> (set => ({ | |||
| setActive: v => set ({ active: v }) })) | |||
| export default (() => { | |||
| const RouteBlockerOverlay: FC = () => { | |||
| const active = useOverlayStore (s => s.active) | |||
| useEffect (() => { | |||
| @@ -43,4 +43,6 @@ export default (() => { | |||
| </div> | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC | |||
| } | |||
| export default RouteBlockerOverlay | |||
| @@ -0,0 +1,39 @@ | |||
| import { screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import SortHeader from '@/components/SortHeader' | |||
| import { renderWithProviders } from '@/test/render' | |||
| describe ('SortHeader', () => { | |||
| it ('toggles the active sort direction and resets the page', () => { | |||
| renderWithProviders ( | |||
| <SortHeader | |||
| by="title" | |||
| label="タイトル" | |||
| currentOrder="title:asc" | |||
| defaultDirection={{ title: 'asc' }}/>, | |||
| { route: '/posts?tags=x&page=4&order=title%3Aasc' }, | |||
| ) | |||
| expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( | |||
| 'href', | |||
| '/posts?tags=x&page=1&order=title%3Adesc', | |||
| ) | |||
| }) | |||
| it ('uses default direction for inactive fields', () => { | |||
| renderWithProviders ( | |||
| <SortHeader | |||
| by="updated_at" | |||
| label="更新" | |||
| currentOrder="title:desc" | |||
| defaultDirection={{ title: 'asc', updated_at: 'desc' }}/>, | |||
| { route: '/posts?page=2' }, | |||
| ) | |||
| expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute ( | |||
| 'href', | |||
| '/posts?page=1&order=updated_at%3Adesc', | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -151,7 +151,7 @@ const DropSlot = ({ cat }: { cat: Category }) => { | |||
| type Props = { post: Post; sp?: boolean } | |||
| export default (({ post, sp }: Props) => { | |||
| const TagDetailSidebar: FC<Props> = ({ post, sp }) => { | |||
| sp = Boolean (sp) | |||
| const qc = useQueryClient () | |||
| @@ -376,4 +376,6 @@ export default (({ post, sp }: Props) => { | |||
| </DragOverlay> | |||
| </DndContext> | |||
| </SidebarComponent>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TagDetailSidebar | |||
| @@ -0,0 +1,45 @@ | |||
| import { screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import TagLink from '@/components/TagLink' | |||
| import { buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| describe ('TagLink', () => { | |||
| it ('links tag names to post search and shows counts', () => { | |||
| renderWithProviders ( | |||
| <TagLink tag={buildTag ({ name: '虹 夏', postCount: 4 })}/>, | |||
| ) | |||
| expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute ( | |||
| 'href', | |||
| '/posts?tags=%E8%99%B9+%E5%A4%8F', | |||
| ) | |||
| expect (screen.getByText ('4')).toBeInTheDocument () | |||
| }) | |||
| it ('links wiki markers to the correct detail route', () => { | |||
| renderWithProviders ( | |||
| <TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>, | |||
| ) | |||
| expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute ( | |||
| 'href', | |||
| '/wiki/a%2Fb', | |||
| ) | |||
| }) | |||
| it ('renders aliases and non-link tags when requested', () => { | |||
| renderWithProviders ( | |||
| <TagLink | |||
| tag={buildTag ({ matchedAlias: '別名', name: '正式名' })} | |||
| linkFlg={false} | |||
| withWiki={false} | |||
| withCount={false}/>, | |||
| ) | |||
| expect (screen.getByText ('別名')).toBeInTheDocument () | |||
| expect (screen.getByText ('正式名')).toBeInTheDocument () | |||
| expect (screen.queryByRole ('link')).not.toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -27,12 +27,12 @@ type Props = | |||
| | PropsWithoutLink | |||
| export default (({ tag, | |||
| const TagLink: FC<Props> = ({ tag, | |||
| nestLevel = 0, | |||
| linkFlg = true, | |||
| withWiki = true, | |||
| withCount = true, | |||
| ...props }: Props) => { | |||
| ...props }) => { | |||
| const spanClass = cn ( | |||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | |||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | |||
| @@ -126,4 +126,6 @@ export default (({ tag, | |||
| {withCount && ( | |||
| <span className="ml-1">{tag.postCount}</span>)} | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TagLink | |||
| @@ -12,7 +12,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react' | |||
| import type { Tag } from '@/types' | |||
| export default (() => { | |||
| const TagSearch: FC = () => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| @@ -115,4 +115,6 @@ export default (() => { | |||
| activeIndex={activeIndex} | |||
| onSelect={handleTagSelect}/> | |||
| </div>) | |||
| }) satisfies FC | |||
| } | |||
| export default TagSearch | |||
| @@ -0,0 +1,30 @@ | |||
| import { fireEvent, screen } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import TagSearchBox from '@/components/TagSearchBox' | |||
| import { buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| describe ('TagSearchBox', () => { | |||
| it ('renders suggestions and selects tags on mouse down', () => { | |||
| const handleSelect = vi.fn () | |||
| const tag = buildTag ({ id: 9, name: '候補', postCount: 2 }) | |||
| renderWithProviders ( | |||
| <TagSearchBox suggestions={[tag]} activeIndex={0} onSelect={handleSelect}/>, | |||
| ) | |||
| fireEvent.mouseDown (screen.getByText ('候補')) | |||
| expect (handleSelect).toHaveBeenCalledWith (tag) | |||
| expect (screen.getByText ('2')).toBeInTheDocument () | |||
| }) | |||
| it ('renders nothing when suggestions are empty', () => { | |||
| const { container } = renderWithProviders ( | |||
| <TagSearchBox suggestions={[]} activeIndex={-1} onSelect={vi.fn ()}/>, | |||
| ) | |||
| expect (container).toBeEmptyDOMElement () | |||
| }) | |||
| }) | |||
| @@ -10,7 +10,7 @@ type Props = { suggestions: Tag[] | |||
| onSelect: (tag: Tag) => void } | |||
| export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||
| const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => { | |||
| if (suggestions.length === 0) | |||
| return | |||
| @@ -26,4 +26,6 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||
| <TagLink tag={tag} linkFlg={false} withWiki={false}/> | |||
| </li>))} | |||
| </ul>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TagSearchBox | |||
| @@ -19,7 +19,7 @@ type Props = { posts: Post[] | |||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | |||
| export default (({ posts, onClick }: Props) => { | |||
| const TagSidebar: FC<Props> = ({ posts, onClick }) => { | |||
| const navigate = useNavigate () | |||
| const [tagsVsbl, setTagsVsbl] = useState (false) | |||
| @@ -126,4 +126,6 @@ export default (({ posts, onClick }: Props) => { | |||
| {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | |||
| </a> | |||
| </SidebarComponent>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TagSidebar | |||
| @@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||
| pathName: string }): Menu => { | |||
| const postCount = tag?.postCount ?? 0 | |||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | |||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) | |||
| const wikiTitle = pathName.split ('/')[2] ?? '' | |||
| const tagFlg = /^\/tags\/\d+/.test (pathName) | |||
| @@ -80,7 +80,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||
| } | |||
| export default (({ user }: Props) => { | |||
| const TopNav: FC<Props> = ({ user }) => { | |||
| const location = useLocation () | |||
| const dirRef = useRef<(-1) | 1> (1) | |||
| @@ -159,12 +159,12 @@ export default (({ user }: Props) => { | |||
| useEffect (() => { | |||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | |||
| return () => unsubscribe () | |||
| }, [activeIdx]) | |||
| }, []) | |||
| useEffect (() => { | |||
| setMenuOpen (false) | |||
| setOpenItemIdx (activeIdx) | |||
| }, [location]) | |||
| }, [activeIdx, location]) | |||
| return ( | |||
| <> | |||
| @@ -433,4 +433,6 @@ export default (({ user }: Props) => { | |||
| </motion.div>)} | |||
| </AnimatePresence> | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TopNav | |||
| @@ -0,0 +1,29 @@ | |||
| import { screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import TopNavUser from '@/components/TopNavUser' | |||
| import { buildUser } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| describe ('TopNavUser', () => { | |||
| it ('renders nothing without a user', () => { | |||
| const { container } = renderWithProviders (<TopNavUser user={null}/>) | |||
| expect (container).toBeEmptyDOMElement () | |||
| }) | |||
| it ('links named users to settings', () => { | |||
| renderWithProviders (<TopNavUser user={buildUser ({ name: '山田' })}/>) | |||
| expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute ( | |||
| 'href', | |||
| '/users/settings', | |||
| ) | |||
| }) | |||
| it ('uses the anonymous display name', () => { | |||
| renderWithProviders (<TopNavUser user={buildUser ({ name: null })}/>) | |||
| expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -10,7 +10,7 @@ type Props = { user: User | null, | |||
| sp?: boolean } | |||
| export default (({ user, sp }: Props) => { | |||
| const TopNavUser: FC<Props> = ({ user, sp }) => { | |||
| if (!(user)) | |||
| return | |||
| @@ -28,4 +28,6 @@ export default (({ user, sp }: Props) => { | |||
| {user.name || '名もなきニジラー'} | |||
| </PrefetchLink> | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TopNavUser | |||
| @@ -0,0 +1,19 @@ | |||
| import { render, screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import TwitterEmbed from '@/components/TwitterEmbed' | |||
| describe ('TwitterEmbed', () => { | |||
| it ('renders tweet and user links', () => { | |||
| render (<TwitterEmbed userId="user_name" statusId="12345"/>) | |||
| expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute ( | |||
| 'href', | |||
| 'https://twitter.com/user_name?ref_src=twsrc%3Etfw', | |||
| ) | |||
| expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute ( | |||
| 'href', | |||
| 'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw', | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -5,7 +5,7 @@ type Props = { | |||
| statusId: string } | |||
| export default (({ userId, statusId }: Props) => { | |||
| const TwitterEmbed: FC<Props> = ({ userId, statusId }) => { | |||
| const now = (new Date).toLocaleDateString () | |||
| return ( | |||
| @@ -18,4 +18,6 @@ export default (({ userId, statusId }: Props) => { | |||
| </blockquote> | |||
| <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TwitterEmbed | |||
| @@ -25,7 +25,7 @@ const mdComponents = { a: (({ href, children }) => ( | |||
| </a>))) } as const satisfies Components | |||
| export default (({ title, body }: Props) => { | |||
| const WikiBody: FC<Props> = ({ title, body }) => { | |||
| const { data } = useQuery ({ | |||
| enabled: Boolean (body), | |||
| queryKey: wikiKeys.index ({ }), | |||
| @@ -39,4 +39,6 @@ export default (({ title, body }: Props) => { | |||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| </ReactMarkdown>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default WikiBody | |||
| @@ -0,0 +1,27 @@ | |||
| import { fireEvent, render, screen } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import DateTimeField from '@/components/common/DateTimeField' | |||
| describe ('DateTimeField', () => { | |||
| it ('renders an ISO value as a datetime-local value', () => { | |||
| render (<DateTimeField aria-label="日時" value="2026-01-02T03:04:05.000Z"/>) | |||
| const input = screen.getByLabelText ('日時') | |||
| expect (input).toHaveValue ('2026-01-02T12:04') | |||
| }) | |||
| it ('reports local changes as ISO strings and empty values as null', () => { | |||
| const handleChange = vi.fn () | |||
| render (<DateTimeField aria-label="日時" onChange={handleChange}/>) | |||
| const input = screen.getByLabelText ('日時') | |||
| fireEvent.change (input, { target: { value: '2026-01-02T03:04' } }) | |||
| fireEvent.change (input, { target: { value: '' } }) | |||
| const first = handleChange.mock.calls[0]?.[0] | |||
| expect (new Date (first).getFullYear ()).toBe (2026) | |||
| expect (handleChange).toHaveBeenLastCalledWith (null) | |||
| }) | |||
| }) | |||
| @@ -25,7 +25,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { | |||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | |||
| export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||
| const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest }) => { | |||
| const [local, setLocal] = useState ('') | |||
| useEffect (() => { | |||
| @@ -44,4 +44,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||
| onChange?.(v ? (new Date (v)).toISOString () : null) | |||
| }} | |||
| onBlur={onBlur}/>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default DateTimeField | |||
| @@ -3,7 +3,9 @@ import type { FC, ReactNode } from 'react' | |||
| type Props = { children: ReactNode } | |||
| export default (({ children }: Props) => ( | |||
| const Form: FC<Props> = ({ children }) => ( | |||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||
| {children} | |||
| </div>)) satisfies FC<Props> | |||
| </div>) | |||
| export default Form | |||
| @@ -0,0 +1,26 @@ | |||
| import { fireEvent, render, screen } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import Label from '@/components/common/Label' | |||
| describe ('Label', () => { | |||
| it ('renders a plain label', () => { | |||
| render (<Label>名前</Label>) | |||
| expect (screen.getByText ('名前')).toBeInTheDocument () | |||
| }) | |||
| it ('renders and toggles the optional checkbox', () => { | |||
| const handleChange = vi.fn () | |||
| render ( | |||
| <Label checkBox={{ label: '不明', checked: false, onChange: handleChange }}> | |||
| 日時 | |||
| </Label>, | |||
| ) | |||
| fireEvent.click (screen.getByRole ('checkbox', { name: '不明' })) | |||
| expect (handleChange).toHaveBeenCalledTimes (1) | |||
| }) | |||
| }) | |||
| @@ -1,12 +1,14 @@ | |||
| import React from 'react' | |||
| import type { FC } from 'react' | |||
| type Props = { children: React.ReactNode | |||
| checkBox?: { label: string | |||
| checked: boolean | |||
| onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } } | |||
| export default ({ children, checkBox }: Props) => { | |||
| const Label: FC<Props> = ({ children, checkBox }) => { | |||
| if (!(checkBox)) | |||
| { | |||
| return ( | |||
| @@ -26,3 +28,5 @@ export default ({ children, checkBox }: Props) => { | |||
| </label> | |||
| </div>) | |||
| } | |||
| export default Label | |||
| @@ -0,0 +1,15 @@ | |||
| import { render, screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| describe ('PageTitle', () => { | |||
| it ('renders children as a level 1 heading', () => { | |||
| render (<PageTitle>Test title</PageTitle>) | |||
| const heading = screen.getByRole ('heading', { level: 1 }) | |||
| expect (heading.textContent).toBe ('Test title') | |||
| }) | |||
| }) | |||
| @@ -1,9 +1,13 @@ | |||
| import React from 'react' | |||
| import type { FC } from 'react' | |||
| type Props = { children: React.ReactNode } | |||
| export default ({ children }: Props) => ( | |||
| const PageTitle: FC<Props> = ({ children }) => ( | |||
| <h1 className="text-2xl font-bold mb-2"> | |||
| {children} | |||
| </h1>) | |||
| export default PageTitle | |||
| @@ -0,0 +1,38 @@ | |||
| import { screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import { renderWithProviders } from '@/test/render' | |||
| describe ('Pagination', () => { | |||
| it ('builds page links while preserving existing query parameters', () => { | |||
| renderWithProviders ( | |||
| <Pagination page={3} totalPages={5} siblingCount={1}/>, | |||
| { route: '/posts?tags=abc&page=3' }, | |||
| ) | |||
| expect (screen.getByLabelText ('前のページ')).toHaveAttribute ( | |||
| 'href', | |||
| '/posts?tags=abc&page=2', | |||
| ) | |||
| expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( | |||
| 'href', | |||
| '/posts?tags=abc&page=4', | |||
| ) | |||
| expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page') | |||
| }) | |||
| it ('does not render active previous and next controls at the edges', () => { | |||
| const { rerender } = renderWithProviders ( | |||
| <Pagination page={1} totalPages={1}/>, | |||
| { route: '/tags' }, | |||
| ) | |||
| expect (screen.queryByLabelText ('前のページ')).not.toBeInTheDocument () | |||
| expect (screen.queryByLabelText ('次のページ')).not.toBeInTheDocument () | |||
| rerender (<Pagination page={1} totalPages={2}/>) | |||
| expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2') | |||
| }) | |||
| }) | |||
| @@ -48,7 +48,7 @@ const getPages = ( | |||
| } | |||
| export default (({ page, totalPages, siblingCount = 3 }) => { | |||
| const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => { | |||
| const location = useLocation () | |||
| const buildTo = (p: number) => { | |||
| @@ -124,4 +124,6 @@ export default (({ page, totalPages, siblingCount = 3 }) => { | |||
| </>)} | |||
| </div> | |||
| </nav>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default Pagination | |||
| @@ -5,7 +5,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react' | |||
| type Props = ComponentPropsWithoutRef<'h2'> | |||
| export default (({ children, className, ...rest }: Props) => ( | |||
| const SectionTitle: FC<Props> = ({ children, className, ...rest }) => ( | |||
| <h2 {...rest} className={cn ('text-xl my-4', className)}> | |||
| {children} | |||
| </h2>)) satisfies FC<Props> | |||
| </h2>) | |||
| export default SectionTitle | |||
| @@ -1,9 +1,13 @@ | |||
| import React from 'react' | |||
| import type { FC } from 'react' | |||
| type Props = { children: React.ReactNode } | |||
| export default ({ children }: Props) => ( | |||
| const SubsectionTitle: FC<Props> = ({ children }) => ( | |||
| <h3 className="my-2"> | |||
| {children} | |||
| </h3>) | |||
| export default SubsectionTitle | |||
| @@ -0,0 +1,23 @@ | |||
| import { fireEvent, render, screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import TabGroup, { Tab } from '@/components/common/TabGroup' | |||
| describe ('TabGroup', () => { | |||
| it ('uses the init tab and switches tabs when clicked', () => { | |||
| render ( | |||
| <TabGroup> | |||
| <Tab name="A">Alpha</Tab> | |||
| <Tab name="B" init>Beta</Tab> | |||
| </TabGroup>, | |||
| ) | |||
| expect (screen.queryByText ('Alpha')).not.toBeInTheDocument () | |||
| expect (screen.getByText ('Beta')).toBeInTheDocument () | |||
| fireEvent.click (screen.getByText ('A')) | |||
| expect (screen.getByText ('Alpha')).toBeInTheDocument () | |||
| expect (screen.queryByText ('Beta')).not.toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -1,3 +1,5 @@ | |||
| import type { FC } from 'react' | |||
| import React, { useState } from 'react' | |||
| import { cn } from '@/lib/utils' | |||
| @@ -10,7 +12,7 @@ type Props = { children: React.ReactNode } | |||
| export const Tab = ({ children }: TabProps) => <>{children}</> | |||
| export default ({ children }: Props) => { | |||
| const TabGroup: FC<Props> = ({ children }) => { | |||
| const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] | |||
| const [current, setCurrent] = useState<number> (() => { | |||
| @@ -37,3 +39,5 @@ export default ({ children }: Props) => { | |||
| </div> | |||
| </div>) | |||
| } | |||
| export default TabGroup | |||
| @@ -0,0 +1,44 @@ | |||
| import { fireEvent, render, 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' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| describe ('TagInput', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| }) | |||
| it ('updates value and fetches autocomplete for the last token', async () => { | |||
| const setValue = vi.fn () | |||
| api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) | |||
| render (<TagInput value="ぼっち 虹" setValue={setValue}/>) | |||
| fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } }) | |||
| await waitFor (() => { | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/tags/autocomplete', | |||
| { params: { q: '虹夏' } }, | |||
| ) | |||
| }) | |||
| expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') | |||
| }) | |||
| it ('does not fetch when the last token is blank', () => { | |||
| const setValue = vi.fn () | |||
| render (<TagInput value="" setValue={setValue}/>) | |||
| fireEvent.change (screen.getByRole ('textbox'), { target: { value: ' ' } }) | |||
| expect (api.apiGet).not.toHaveBeenCalled () | |||
| expect (setValue).toHaveBeenCalledWith (' ') | |||
| }) | |||
| }) | |||
| @@ -12,7 +12,7 @@ type Props = { | |||
| value: string | |||
| setValue: (value: string) => void } | |||
| export default (({ value, setValue }: Props) => { | |||
| const TagInput: FC<Props> = ({ value, setValue }) => { | |||
| const [activeIndex, setActiveIndex] = useState (-1) | |||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | |||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||
| @@ -62,9 +62,12 @@ export default (({ value, setValue }: Props) => { | |||
| case 'Enter': | |||
| if (activeIndex < 0) | |||
| break | |||
| ev.preventDefault () | |||
| const selected = suggestions[activeIndex] | |||
| selected && handleTagSelect (selected) | |||
| { | |||
| ev.preventDefault () | |||
| const selected = suggestions[activeIndex] | |||
| if (selected) | |||
| handleTagSelect (selected) | |||
| } | |||
| break | |||
| case 'Escape': | |||
| @@ -94,4 +97,6 @@ export default (({ value, setValue }: Props) => { | |||
| activeIndex={activeIndex} | |||
| onSelect={handleTagSelect}/> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default TagInput | |||
| @@ -0,0 +1,37 @@ | |||
| import { createRef } from 'react' | |||
| import { render, screen } from '@testing-library/react' | |||
| import { describe, expect, it } from 'vitest' | |||
| import Form from '@/components/common/Form' | |||
| import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import TextArea from '@/components/common/TextArea' | |||
| describe ('common typography and form components', () => { | |||
| it ('renders Form children inside the standard container', () => { | |||
| render (<Form><span>Content</span></Form>) | |||
| expect (screen.getByText ('Content')).toBeInTheDocument () | |||
| }) | |||
| it ('renders SectionTitle as an h2', () => { | |||
| render (<SectionTitle>Section</SectionTitle>) | |||
| expect (screen.getByRole ('heading', { level: 2, name: 'Section' })).toBeInTheDocument () | |||
| }) | |||
| it ('renders SubsectionTitle as an h3', () => { | |||
| render (<SubsectionTitle>Subsection</SubsectionTitle>) | |||
| expect (screen.getByRole ('heading', { level: 3, name: 'Subsection' })).toBeInTheDocument () | |||
| }) | |||
| it ('forwards refs and props to TextArea', () => { | |||
| const ref = createRef<HTMLTextAreaElement> () | |||
| render (<TextArea ref={ref} aria-label="Body" defaultValue="text"/>) | |||
| expect (ref.current).toBe (screen.getByLabelText ('Body')) | |||
| expect (screen.getByLabelText ('Body')).toHaveValue ('text') | |||
| }) | |||
| }) | |||
| @@ -57,7 +57,7 @@ let nextDialogueId = 1 | |||
| type Props = { children: ReactNode } | |||
| export default (({ children }: Props) => { | |||
| const DialogueProvider: FC<Props> = ({ children }) => { | |||
| const [queue, setQueue] = useState<DialogueRequest[]> ([]) | |||
| const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | |||
| @@ -174,7 +174,7 @@ export default (({ children }: Props) => { | |||
| </DialogContent>)} | |||
| </Dialog> | |||
| </DialogueContext.Provider>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export const useDialogue = () => { | |||
| @@ -185,3 +185,5 @@ export const useDialogue = () => { | |||
| return dialogue | |||
| } | |||
| export default DialogueProvider | |||
| @@ -9,10 +9,12 @@ type Props = { | |||
| className?: string } | |||
| export default (({ children, className }: Props) => ( | |||
| const MainArea: FC<Props> = ({ children, className }) => ( | |||
| <motion.main | |||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | |||
| className={cn ('flex-1 overflow-y-auto p-4', className)} | |||
| layout="position"> | |||
| {children} | |||
| </motion.main>)) satisfies FC<Props> | |||
| </motion.main>) | |||
| export default MainArea | |||
| @@ -6,7 +6,7 @@ import type { FC, ReactNode } from 'react' | |||
| type Props = { children: ReactNode } | |||
| export default (({ children }: Props) => ( | |||
| const SidebarComponent: FC<Props> = ({ children }) => ( | |||
| <motion.div | |||
| layout="position" | |||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | |||
| @@ -27,4 +27,6 @@ export default (({ children }: Props) => ( | |||
| </Helmet> | |||
| {children} | |||
| </motion.div>)) satisfies FC<Props> | |||
| </motion.div>) | |||
| export default SidebarComponent | |||
| @@ -18,13 +18,6 @@ type ToasterToast = ToastProps & { | |||
| action?: ToastActionElement | |||
| } | |||
| const actionTypes = { | |||
| ADD_TOAST: "ADD_TOAST", | |||
| UPDATE_TOAST: "UPDATE_TOAST", | |||
| DISMISS_TOAST: "DISMISS_TOAST", | |||
| REMOVE_TOAST: "REMOVE_TOAST", | |||
| } as const | |||
| let count = 0 | |||
| function genId() { | |||
| @@ -32,23 +25,21 @@ function genId() { | |||
| return count.toString() | |||
| } | |||
| type ActionType = typeof actionTypes | |||
| type Action = | |||
| | { | |||
| type: ActionType["ADD_TOAST"] | |||
| type: "ADD_TOAST" | |||
| toast: ToasterToast | |||
| } | |||
| | { | |||
| type: ActionType["UPDATE_TOAST"] | |||
| type: "UPDATE_TOAST" | |||
| toast: Partial<ToasterToast> | |||
| } | |||
| | { | |||
| type: ActionType["DISMISS_TOAST"] | |||
| type: "DISMISS_TOAST" | |||
| toastId?: ToasterToast["id"] | |||
| } | |||
| | { | |||
| type: ActionType["REMOVE_TOAST"] | |||
| type: "REMOVE_TOAST" | |||
| toastId?: ToasterToast["id"] | |||
| } | |||
| @@ -1,3 +1,5 @@ | |||
| import type { FC } from 'react' | |||
| import { useState } from 'react' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| @@ -18,7 +20,7 @@ type Props = { visible: boolean | |||
| setUser: (user: User) => void } | |||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | |||
| const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => { | |||
| const dialogue = useDialogue () | |||
| const [inputCode, setInputCode] = useState ('') | |||
| @@ -68,3 +70,5 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||
| </DialogContent> | |||
| </Dialog>) | |||
| } | |||
| export default InheritDialogue | |||
| @@ -1,3 +1,5 @@ | |||
| import type { FC } from 'react' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import { Button } from '@/components/ui/button' | |||
| import { Dialog, | |||
| @@ -17,7 +19,7 @@ type Props = { visible: boolean | |||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | |||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||
| const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => { | |||
| const dialogue = useDialogue () | |||
| const handleChange = async () => { | |||
| @@ -69,3 +71,5 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||
| </DialogContent> | |||
| </Dialog>) | |||
| } | |||
| export default UserCodeDialogue | |||
| @@ -0,0 +1,96 @@ | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| const mocks = vi.hoisted (() => { | |||
| const client = { | |||
| delete: vi.fn (), | |||
| get: vi.fn (), | |||
| patch: vi.fn (), | |||
| post: vi.fn (), | |||
| put: vi.fn (), | |||
| } | |||
| return { | |||
| client, | |||
| isAxiosError: vi.fn (), | |||
| } | |||
| }) | |||
| vi.mock ('axios', () => ({ | |||
| default: { | |||
| create: vi.fn (() => mocks.client), | |||
| isAxiosError: mocks.isAxiosError, | |||
| }, | |||
| })) | |||
| vi.mock ('@/config', () => ({ | |||
| API_BASE_URL: '/api', | |||
| })) | |||
| describe ('api helpers', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| localStorage.clear () | |||
| }) | |||
| it ('adds the transfer code header and camelizes get responses', async () => { | |||
| localStorage.setItem ('user_code', 'abc123') | |||
| mocks.client.get.mockResolvedValueOnce ({ | |||
| data: { post_id: 1, nested_value: { created_at: 'now' } }, | |||
| }) | |||
| const { apiGet } = await import ('@/lib/api') | |||
| const data = await apiGet<{ postId: number; nestedValue: { createdAt: string } }> ( | |||
| '/posts/1', | |||
| { headers: { 'X-Extra': '1' }, params: { page: 2 } }, | |||
| ) | |||
| expect (mocks.client.get).toHaveBeenCalledWith ( | |||
| '/posts/1', | |||
| { | |||
| headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' }, | |||
| params: { page: 2 }, | |||
| }, | |||
| ) | |||
| expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } }) | |||
| }) | |||
| it ('passes an empty body for post-like requests when body is omitted', async () => { | |||
| mocks.client.patch.mockResolvedValueOnce ({ data: { ok_value: true } }) | |||
| const { apiPatch } = await import ('@/lib/api') | |||
| const data = await apiPatch<{ okValue: boolean }> ('/posts/1') | |||
| expect (mocks.client.patch).toHaveBeenCalledWith ( | |||
| '/posts/1', | |||
| {}, | |||
| { headers: { 'X-Transfer-Code': '' } }, | |||
| ) | |||
| expect (data.okValue).toBe (true) | |||
| }) | |||
| it ('does not camelize blob responses', async () => { | |||
| const blob = new Blob (['csv']) | |||
| mocks.client.get.mockResolvedValueOnce ({ data: blob }) | |||
| const { apiGet } = await import ('@/lib/api') | |||
| const data = await apiGet<Blob> ('/exports', { responseType: 'blob' }) | |||
| expect (data).toBe (blob) | |||
| }) | |||
| it ('delegates deletes and exposes axios error detection', async () => { | |||
| const err = new Error ('bad') | |||
| mocks.client.delete.mockResolvedValueOnce ({}) | |||
| mocks.isAxiosError.mockReturnValueOnce (true) | |||
| const { apiDelete, isApiError } = await import ('@/lib/api') | |||
| await apiDelete ('/posts/1') | |||
| expect (mocks.client.delete).toHaveBeenCalledWith ( | |||
| '/posts/1', | |||
| { headers: { 'X-Transfer-Code': '' } }, | |||
| ) | |||
| expect (isApiError (err)).toBe (true) | |||
| expect (mocks.isAxiosError).toHaveBeenCalledWith (err) | |||
| }) | |||
| }) | |||
| @@ -28,7 +28,7 @@ const apiP = async <T> ( | |||
| const res = await client[method] (path, body ?? { }, withUserCode (opt)) | |||
| if (opt?.responseType === 'blob') | |||
| return res.data as T | |||
| return toCamel (res.data as any, { deep: true }) as T | |||
| return toCamel (res.data as Record<string, unknown>, { deep: true }) as T | |||
| } | |||
| @@ -39,7 +39,7 @@ export const apiGet = async <T> ( | |||
| const res = await client.get (path, withUserCode (opt)) | |||
| if (opt?.responseType === 'blob') | |||
| return res.data as T | |||
| return toCamel (res.data as any, { deep: true }) as T | |||
| return toCamel (res.data as Record<string, unknown>, { deep: true }) as T | |||
| } | |||
| @@ -72,4 +72,5 @@ export const apiDelete = async ( | |||
| } | |||
| export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err) | |||
| export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> => | |||
| axios.isAxiosError (err) | |||
| @@ -0,0 +1,117 @@ | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import { fetchPostChanges, fetchPosts, toggleViewedFlg, updatePost } from '@/lib/posts' | |||
| import type { FetchPostsParams } from '@/types' | |||
| const api = vi.hoisted (() => ({ | |||
| apiDelete: vi.fn (), | |||
| apiGet: vi.fn (), | |||
| apiPost: vi.fn (), | |||
| apiPut: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| const baseParams: FetchPostsParams = { | |||
| url: '', | |||
| title: '', | |||
| tags: '', | |||
| match: 'all', | |||
| originalCreatedFrom: '', | |||
| originalCreatedTo: '', | |||
| createdFrom: '', | |||
| createdTo: '', | |||
| updatedFrom: '', | |||
| updatedTo: '', | |||
| page: 1, | |||
| limit: 20, | |||
| order: 'updated_at:desc', | |||
| } | |||
| describe ('posts API functions', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| }) | |||
| it ('maps post search parameters to backend snake_case names', async () => { | |||
| api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 }) | |||
| await fetchPosts ({ | |||
| ...baseParams, | |||
| title: 'title', | |||
| tags: 'a b', | |||
| originalCreatedFrom: '2026-01-01', | |||
| updatedTo: '2026-02-01', | |||
| }) | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/posts', | |||
| { | |||
| params: { | |||
| title: 'title', | |||
| tags: 'a b', | |||
| match: 'all', | |||
| original_created_from: '2026-01-01', | |||
| updated_to: '2026-02-01', | |||
| page: 1, | |||
| limit: 20, | |||
| order: 'updated_at:desc', | |||
| }, | |||
| }, | |||
| ) | |||
| }) | |||
| it ('updates posts with version and merge controls', async () => { | |||
| api.apiPut.mockResolvedValueOnce ({ id: 5 }) | |||
| await updatePost ( | |||
| { | |||
| id: 5, | |||
| title: 'new title', | |||
| tags: 'tag', | |||
| parentPostIds: '1 2', | |||
| originalCreatedFrom: null, | |||
| originalCreatedBefore: '2026-01-02T00:00:00Z', | |||
| }, | |||
| { baseVersionNo: 7, force: true, merge: false }, | |||
| ) | |||
| expect (api.apiPut).toHaveBeenCalledWith ( | |||
| '/posts/5', | |||
| { | |||
| title: 'new title', | |||
| tags: 'tag', | |||
| parent_post_ids: '1 2', | |||
| original_created_from: null, | |||
| original_created_before: '2026-01-02T00:00:00Z', | |||
| }, | |||
| { | |||
| params: { | |||
| base_version_no: '7', | |||
| force: '1', | |||
| merge: '0', | |||
| }, | |||
| }, | |||
| ) | |||
| }) | |||
| it ('uses the viewed endpoint method matching the requested state', async () => { | |||
| await toggleViewedFlg ('9', true) | |||
| await toggleViewedFlg ('9', false) | |||
| expect (api.apiPost).toHaveBeenCalledWith ('/posts/9/viewed') | |||
| expect (api.apiDelete).toHaveBeenCalledWith ('/posts/9/viewed') | |||
| }) | |||
| it ('keeps optional post history filters out when blank', async () => { | |||
| api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 }) | |||
| await fetchPostChanges ({ page: 2, limit: 50 }) | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/posts/versions', | |||
| { params: { page: 2, limit: 50 } }, | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,112 @@ | |||
| import { QueryClient } from '@tanstack/react-query' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import { prefetchForURL } from '@/lib/prefetchers' | |||
| const postsApi = vi.hoisted (() => ({ | |||
| fetchPost: vi.fn (), | |||
| fetchPostChanges: vi.fn (), | |||
| fetchPosts: vi.fn (), | |||
| })) | |||
| const tagsApi = vi.hoisted (() => ({ | |||
| fetchTag: vi.fn (), | |||
| fetchTagByName: vi.fn (), | |||
| fetchTagChanges: vi.fn (), | |||
| fetchTags: vi.fn (), | |||
| })) | |||
| const wikiApi = vi.hoisted (() => ({ | |||
| fetchWikiPage: vi.fn (), | |||
| fetchWikiPageByTitle: vi.fn (), | |||
| fetchWikiPages: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/posts', () => postsApi) | |||
| vi.mock ('@/lib/tags', () => tagsApi) | |||
| vi.mock ('@/lib/wiki', () => wikiApi) | |||
| const qc = () => new QueryClient ({ | |||
| defaultOptions: { queries: { retry: false } }, | |||
| }) | |||
| describe ('prefetchForURL', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) | |||
| postsApi.fetchPost.mockResolvedValue ({ id: 1 }) | |||
| postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 }) | |||
| tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) | |||
| tagsApi.fetchTag.mockResolvedValue ({ id: 1 }) | |||
| tagsApi.fetchTagByName.mockResolvedValue (null) | |||
| tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 }) | |||
| wikiApi.fetchWikiPages.mockResolvedValue ([]) | |||
| wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 }) | |||
| wikiApi.fetchWikiPageByTitle.mockResolvedValue (null) | |||
| }) | |||
| it ('prefetches post indexes from query parameters', async () => { | |||
| await prefetchForURL ( | |||
| qc (), | |||
| 'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc', | |||
| ) | |||
| expect (postsApi.fetchPosts).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| tags: 'a b', | |||
| match: 'any', | |||
| page: 2, | |||
| limit: 5, | |||
| order: 'title:asc', | |||
| }), | |||
| ) | |||
| }) | |||
| it ('prefetches post detail pages', async () => { | |||
| await prefetchForURL (qc (), 'http://localhost/posts/12') | |||
| expect (postsApi.fetchPost).toHaveBeenCalledWith ('12') | |||
| }) | |||
| it ('prefetches tag indexes from query parameters', async () => { | |||
| await prefetchForURL ( | |||
| qc (), | |||
| 'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10', | |||
| ) | |||
| expect (tagsApi.fetchTags).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| post: 9, | |||
| name: 'x', | |||
| category: 'general', | |||
| page: 4, | |||
| postCountLTE: 10, | |||
| }), | |||
| ) | |||
| }) | |||
| it ('prefetches wiki show pages and related tag/post data', async () => { | |||
| wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({ | |||
| id: 3, | |||
| title: 'Actual', | |||
| body: 'body', | |||
| }) | |||
| await prefetchForURL (qc (), 'http://localhost/wiki/Alias') | |||
| expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined }) | |||
| expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {}) | |||
| expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual') | |||
| expect (postsApi.fetchPosts).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ tags: 'Actual', limit: 8 }), | |||
| ) | |||
| }) | |||
| it ('ignores routes without a prefetcher', async () => { | |||
| await prefetchForURL (qc (), 'http://localhost/unknown') | |||
| expect (postsApi.fetchPosts).not.toHaveBeenCalled () | |||
| expect (tagsApi.fetchTags).not.toHaveBeenCalled () | |||
| expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled () | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,14 @@ | |||
| import { describe, expect, it } from 'vitest' | |||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||
| describe ('query keys', () => { | |||
| it ('uses stable namespaces for posts, tags, and wiki', () => { | |||
| expect (postsKeys.show ('3')).toEqual (['posts', '3']) | |||
| expect (postsKeys.related ('3')).toEqual (['related', '3']) | |||
| expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7']) | |||
| expect (wikiKeys.show ('Title', { version: '2' })).toEqual ( | |||
| ['wiki', 'Title', { version: '2' }], | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,68 @@ | |||
| import { describe, expect, it } from 'vitest' | |||
| import remarkWikiAutolink from '@/lib/remark-wiki-autolink' | |||
| import type { Root } from 'mdast' | |||
| describe ('remarkWikiAutolink', () => { | |||
| it ('links matching wiki page names and prefers longer matches', () => { | |||
| const tree: Root = { | |||
| type: 'root', | |||
| children: [{ | |||
| type: 'paragraph', | |||
| children: [{ type: 'text', value: '虹夏 and 虹' }], | |||
| }], | |||
| } | |||
| remarkWikiAutolink (['虹', '虹夏']) (tree) | |||
| expect (tree.children[0]).toMatchObject ({ | |||
| type: 'paragraph', | |||
| children: [ | |||
| { | |||
| type: 'link', | |||
| url: '/wiki/%E8%99%B9%E5%A4%8F', | |||
| children: [{ type: 'text', value: '虹夏' }], | |||
| }, | |||
| { type: 'text', value: ' and ' }, | |||
| { | |||
| type: 'link', | |||
| url: '/wiki/%E8%99%B9', | |||
| children: [{ type: 'text', value: '虹' }], | |||
| }, | |||
| ], | |||
| }) | |||
| }) | |||
| it ('does not link text inside existing links or code', () => { | |||
| const tree: Root = { | |||
| type: 'root', | |||
| children: [ | |||
| { | |||
| type: 'paragraph', | |||
| children: [{ | |||
| type: 'link', | |||
| url: '/existing', | |||
| children: [{ type: 'text', value: '虹' }], | |||
| }], | |||
| }, | |||
| { | |||
| type: 'code', | |||
| value: '虹', | |||
| }, | |||
| ], | |||
| } | |||
| remarkWikiAutolink (['虹']) (tree) | |||
| expect (tree.children[0]).toMatchObject ({ | |||
| type: 'paragraph', | |||
| children: [{ | |||
| type: 'link', | |||
| url: '/existing', | |||
| children: [{ type: 'text', value: '虹' }], | |||
| }], | |||
| }) | |||
| expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' }) | |||
| }) | |||
| }) | |||
| @@ -38,7 +38,7 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||
| let last = 0 | |||
| const parts: RootContent[] = [] | |||
| while (m = re.exec (value)) | |||
| while ((m = re.exec (value)) !== null) | |||
| { | |||
| const start = m.index | |||
| const end = start + m[0].length | |||
| @@ -70,7 +70,7 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||
| } | |||
| } | |||
| const maybeChidren = (node as any).children | |||
| const maybeChidren = 'children' in node ? node.children : undefined | |||
| if (Array.isArray (maybeChidren)) | |||
| { | |||
| const parent = node as Parent | |||
| @@ -0,0 +1,67 @@ | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import { fetchTag, fetchTagByName, fetchTags } from '@/lib/tags' | |||
| import type { FetchTagsParams } from '@/types' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| const baseParams: FetchTagsParams = { | |||
| post: null, | |||
| name: '', | |||
| category: null, | |||
| postCountGTE: 0, | |||
| postCountLTE: null, | |||
| createdFrom: '', | |||
| createdTo: '', | |||
| updatedFrom: '', | |||
| updatedTo: '', | |||
| page: 1, | |||
| limit: 30, | |||
| order: 'updated_at:desc', | |||
| } | |||
| describe ('tags API functions', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| }) | |||
| it ('maps tag filters to backend parameters', async () => { | |||
| api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 }) | |||
| await fetchTags ({ | |||
| ...baseParams, | |||
| name: '虹', | |||
| category: 'character', | |||
| postCountGTE: 10, | |||
| postCountLTE: 20, | |||
| }) | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/tags', | |||
| { | |||
| params: { | |||
| name: '虹', | |||
| category: 'character', | |||
| post_count_gte: 10, | |||
| post_count_lte: 20, | |||
| page: 1, | |||
| limit: 30, | |||
| order: 'updated_at:desc', | |||
| }, | |||
| }, | |||
| ) | |||
| }) | |||
| it ('returns null when tag fetches fail', async () => { | |||
| api.apiGet.mockRejectedValueOnce (new Error ('missing')) | |||
| api.apiGet.mockRejectedValueOnce (new Error ('missing')) | |||
| await expect (fetchTag ('1')).resolves.toBeNull () | |||
| await expect (fetchTagByName ('unknown')).resolves.toBeNull () | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,28 @@ | |||
| import { describe, expect, it } from 'vitest' | |||
| import { cn, originalCreatedAtString, toDate } from '@/lib/utils' | |||
| describe ('utils', () => { | |||
| it ('converts strings to dates and leaves date instances intact', () => { | |||
| const date = new Date ('2026-01-02T03:04:05Z') | |||
| expect (toDate ('2026-01-02T03:04:05Z')).toBeInstanceOf (Date) | |||
| expect (toDate (date)).toBe (date) | |||
| }) | |||
| it ('merges conditional Tailwind classes', () => { | |||
| const hidden = false | |||
| expect (cn ('p-2', hidden && 'hidden', 'p-4')).toBe ('p-4') | |||
| }) | |||
| it ('renders unknown original creation time ranges', () => { | |||
| expect (originalCreatedAtString (null, null)).toBe ('年月日不詳') | |||
| expect ( | |||
| originalCreatedAtString ( | |||
| '2026-01-01T00:00:00+09:00', | |||
| '2026-01-02T00:00:00+09:00', | |||
| ), | |||
| ).toContain ('時刻不詳') | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,48 @@ | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| describe ('wiki API functions', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| }) | |||
| it ('fetches wiki index and show pages with expected parameters', async () => { | |||
| api.apiGet.mockResolvedValueOnce ([]) | |||
| api.apiGet.mockResolvedValueOnce ({ id: 1 }) | |||
| await fetchWikiPages ({ title: '虹' }) | |||
| await fetchWikiPage ('1', { version: '3' }) | |||
| expect (api.apiGet).toHaveBeenNthCalledWith ( | |||
| 1, | |||
| '/wiki', | |||
| { params: { title: '虹' } }, | |||
| ) | |||
| expect (api.apiGet).toHaveBeenNthCalledWith ( | |||
| 2, | |||
| '/wiki/1', | |||
| { params: { version: '3' } }, | |||
| ) | |||
| }) | |||
| it ('encodes title path segments and returns null on misses', async () => { | |||
| api.apiGet.mockResolvedValueOnce ({ id: 2 }) | |||
| api.apiGet.mockRejectedValueOnce (new Error ('missing')) | |||
| await fetchWikiPageByTitle ('a/b c', { version: undefined }) | |||
| await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull () | |||
| expect (api.apiGet).toHaveBeenNthCalledWith ( | |||
| 1, | |||
| '/wiki/title/a%2Fb%20c', | |||
| { params: { version: undefined } }, | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -1,4 +1,8 @@ | |||
| import type { FC } from 'react' | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={403}/> | |||
| const Forbidden: FC = () => <ErrorScreen status={403}/> | |||
| export default Forbidden | |||
| @@ -11,7 +11,7 @@ import type { FC } from 'react' | |||
| import type { User } from '@/types' | |||
| export default (() => { | |||
| const MorePage: FC = () => { | |||
| const menu = menuOutline ( | |||
| { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) | |||
| @@ -43,4 +43,6 @@ export default (() => { | |||
| </section>))} | |||
| </div>))} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default MorePage | |||
| @@ -1,4 +1,8 @@ | |||
| import type { FC } from 'react' | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={404}/> | |||
| const NotFound: FC = () => <ErrorScreen status={404}/> | |||
| export default NotFound | |||
| @@ -1,4 +1,8 @@ | |||
| import type { FC } from 'react' | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| export default () => <ErrorScreen status={503}/> | |||
| const ServiceUnavailable: FC = () => <ErrorScreen status={503}/> | |||
| export default ServiceUnavailable | |||
| @@ -1,5 +1,5 @@ | |||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | |||
| import { useEffect, useState } from 'react' | |||
| import { useEffect, useMemo, useState } from 'react' | |||
| import { useParams } from 'react-router-dom' | |||
| import TagLink from '@/components/TagLink' | |||
| @@ -18,7 +18,7 @@ import type { FC, FormEvent } from 'react' | |||
| import type { Deerjikist, Platform } from '@/types' | |||
| export default (() => { | |||
| const DeerjikistDetailPage: FC = () => { | |||
| const { id } = useParams () | |||
| const tagId = String (id ?? '') | |||
| const tagKey = tagsKeys.deerjikists (tagId) | |||
| @@ -26,7 +26,7 @@ export default (() => { | |||
| const { data: qData, isLoading: loading } = | |||
| useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) | |||
| const tag = qData?.tag | |||
| const deerjikists = qData?.deerjikists ?? [] | |||
| const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData]) | |||
| const [data, setData] = | |||
| useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) | |||
| @@ -152,4 +152,6 @@ export default (() => { | |||
| </div> | |||
| )} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default DeerjikistDetailPage | |||
| @@ -0,0 +1,26 @@ | |||
| import { screen } from '@testing-library/react' | |||
| import { Route, Routes } from 'react-router-dom' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import MaterialBasePage from '@/pages/materials/MaterialBasePage' | |||
| import { renderWithProviders } from '@/test/render' | |||
| vi.mock ('@/components/MaterialSidebar', () => ({ | |||
| default: () => <aside>Material sidebar</aside>, | |||
| })) | |||
| describe ('MaterialBasePage', () => { | |||
| it ('renders the material sidebar and nested route outlet', () => { | |||
| renderWithProviders ( | |||
| <Routes> | |||
| <Route path="/materials" element={<MaterialBasePage/>}> | |||
| <Route index element={<div>Outlet content</div>}/> | |||
| </Route> | |||
| </Routes>, | |||
| { route: '/materials' }, | |||
| ) | |||
| expect (screen.getByText ('Material sidebar')).toBeInTheDocument () | |||
| expect (screen.getByText ('Outlet content')).toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -5,8 +5,10 @@ import MaterialSidebar from '@/components/MaterialSidebar' | |||
| import type { FC } from 'react' | |||
| export default (() => ( | |||
| const MaterialBasePage: FC = () => ( | |||
| <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | |||
| <MaterialSidebar/> | |||
| <Outlet/> | |||
| </div>)) satisfies FC | |||
| </div>) | |||
| export default MaterialBasePage | |||
| @@ -0,0 +1,86 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { Route, Routes } from 'react-router-dom' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' | |||
| import { buildMaterial, buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| apiPut: vi.fn (), | |||
| })) | |||
| const wikiApi = vi.hoisted (() => ({ | |||
| fetchWikiPages: vi.fn (), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/lib/wiki', () => wikiApi) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| const renderPage = () => | |||
| renderWithProviders ( | |||
| <Routes> | |||
| <Route path="/materials/:id" element={<MaterialDetailPage/>}/> | |||
| </Routes>, | |||
| { route: '/materials/8' }, | |||
| ) | |||
| describe ('MaterialDetailPage', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| api.apiGet.mockResolvedValue ([]) | |||
| wikiApi.fetchWikiPages.mockResolvedValue ([]) | |||
| vi.stubGlobal ('fetch', vi.fn (async () => ({ | |||
| blob: async () => new Blob (['image'], { type: 'image/png' }), | |||
| }))) | |||
| }) | |||
| it ('loads and displays material detail', async () => { | |||
| api.apiGet.mockResolvedValueOnce ( | |||
| buildMaterial ({ | |||
| id: 8, | |||
| tag: buildTag ({ name: '素材タグ' }), | |||
| file: 'image.png', | |||
| contentType: 'image/png', | |||
| }), | |||
| ) | |||
| renderPage () | |||
| await waitFor (() => { | |||
| expect (api.apiGet).toHaveBeenCalledWith ('/materials/8') | |||
| }) | |||
| expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png') | |||
| }) | |||
| it ('submits edited material fields', async () => { | |||
| api.apiGet.mockResolvedValueOnce ( | |||
| buildMaterial ({ id: 8, tag: buildTag ({ name: 'old' }), url: '' }), | |||
| ) | |||
| api.apiPut.mockResolvedValueOnce ( | |||
| buildMaterial ({ id: 8, tag: buildTag ({ name: 'new' }) }), | |||
| ) | |||
| renderPage () | |||
| fireEvent.click (await screen.findByText ('編輯')) | |||
| const textboxes = screen.getAllByRole ('textbox') | |||
| fireEvent.change (textboxes[0], { target: { value: 'new' } }) | |||
| fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } }) | |||
| fireEvent.click (screen.getByRole ('button', { name: '更新' })) | |||
| await waitFor (() => { | |||
| expect (api.apiPut).toHaveBeenCalledWith ('/materials/8', expect.any (FormData)) | |||
| }) | |||
| const formData = api.apiPut.mock.calls[0]?.[1] as FormData | |||
| expect (formData.get ('tag')).toBe ('new') | |||
| expect (formData.get ('url')).toBe ('https://example.com/ref') | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) | |||
| }) | |||
| }) | |||
| @@ -21,7 +21,7 @@ import type { Material, Tag } from '@/types' | |||
| type MaterialWithTag = Material & { tag: Tag } | |||
| export default (() => { | |||
| const MaterialDetailPage: FC = () => { | |||
| const { id } = useParams () | |||
| const [file, setFile] = useState<File | null> (null) | |||
| @@ -179,4 +179,6 @@ export default (() => { | |||
| </TabGroup> | |||
| </>))} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default MaterialDetailPage | |||
| @@ -0,0 +1,62 @@ | |||
| import { screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import MaterialListPage from '@/pages/materials/MaterialListPage' | |||
| import { buildMaterial, buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| describe ('MaterialListPage', () => { | |||
| it ('shows the empty selection guide without a tag query', () => { | |||
| renderWithProviders (<MaterialListPage/>, { route: '/materials' }) | |||
| expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument () | |||
| expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute ( | |||
| 'href', | |||
| '/materials/new', | |||
| ) | |||
| }) | |||
| it ('loads materials for a tag query', async () => { | |||
| const tag = { | |||
| ...buildTag ({ | |||
| id: 4, | |||
| name: '素材タグ', | |||
| category: 'material', | |||
| }), | |||
| material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }), | |||
| children: [], | |||
| } | |||
| api.apiGet.mockResolvedValueOnce (tag) | |||
| renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' }) | |||
| await waitFor (() => { | |||
| expect (api.apiGet).toHaveBeenCalledWith ( | |||
| '/tags/name/%E7%B4%A0%E6%9D%90/materials', | |||
| ) | |||
| }) | |||
| expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument () | |||
| expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8') | |||
| }) | |||
| it ('offers adding a missing non-meme material', async () => { | |||
| api.apiGet.mockResolvedValueOnce ({ | |||
| ...buildTag ({ name: '未登録', category: 'material' }), | |||
| material: null, | |||
| children: [], | |||
| }) | |||
| renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=x' }) | |||
| expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute ( | |||
| 'href', | |||
| '/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2', | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -41,7 +41,7 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { | |||
| } | |||
| export default (() => { | |||
| const MaterialListPage: FC = () => { | |||
| const [loading, setLoading] = useState (false) | |||
| const [tag, setTag] = useState<TagWithMaterial | null> (null) | |||
| @@ -69,7 +69,7 @@ export default (() => { | |||
| setLoading (false) | |||
| } | |||
| }) () | |||
| }, [location.search]) | |||
| }, [location.search, tagQuery]) | |||
| return ( | |||
| <MainArea> | |||
| @@ -163,4 +163,6 @@ export default (() => { | |||
| </ul> | |||
| </>))} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default MaterialListPage | |||
| @@ -0,0 +1,38 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import MaterialNewPage from '@/pages/materials/MaterialNewPage' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiPost: vi.fn (), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| describe ('MaterialNewPage', () => { | |||
| it ('initializes tag from query and submits form data', async () => { | |||
| api.apiPost.mockResolvedValueOnce ({}) | |||
| renderWithProviders (<MaterialNewPage/>, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' }) | |||
| expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏') | |||
| fireEvent.change (screen.getAllByRole ('textbox')[1], { | |||
| target: { value: 'https://example.com/ref' }, | |||
| }) | |||
| fireEvent.click (screen.getByRole ('button', { name: '追加' })) | |||
| await waitFor (() => { | |||
| expect (api.apiPost).toHaveBeenCalledWith ('/materials', expect.any (FormData)) | |||
| }) | |||
| const formData = api.apiPost.mock.calls[0]?.[1] as FormData | |||
| expect (formData.get ('tag')).toBe ('虹夏') | |||
| expect (formData.get ('url')).toBe ('https://example.com/ref') | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' }) | |||
| }) | |||
| }) | |||
| @@ -15,7 +15,7 @@ import { apiPost } from '@/lib/api' | |||
| import type { FC } from 'react' | |||
| export default (() => { | |||
| const MaterialNewPage: FC = () => { | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const tagQuery = query.get ('tag') ?? '' | |||
| @@ -121,4 +121,6 @@ export default (() => { | |||
| </Button> | |||
| </Form> | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default MaterialNewPage | |||
| @@ -10,7 +10,7 @@ import { SITE_TITLE } from '@/config' | |||
| import type { FC, FormEvent } from 'react' | |||
| export default (() => { | |||
| const MaterialSearchPage: FC = () => { | |||
| const [tagName, setTagName] = useState ('') | |||
| const [parentTagName, setParentTagName] = useState ('') | |||
| @@ -46,4 +46,6 @@ export default (() => { | |||
| </form> | |||
| </div> | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default MaterialSearchPage | |||
| @@ -0,0 +1,109 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { Route, Routes } from 'react-router-dom' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import PostDetailPage from '@/pages/posts/PostDetailPage' | |||
| import { buildPost, buildUser } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| import type { ReactNode } from 'react' | |||
| const postsApi = vi.hoisted (() => ({ | |||
| fetchPost: vi.fn (), | |||
| toggleViewedFlg: vi.fn (), | |||
| })) | |||
| const api = vi.hoisted (() => ({ | |||
| isApiError: vi.fn (() => false), | |||
| })) | |||
| vi.mock ('@/lib/posts', () => postsApi) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/PostEmbed', () => ({ | |||
| default: ({ post }: { post: { url: string } }) => <div>Embed:{post.url}</div>, | |||
| })) | |||
| vi.mock ('@/components/TagDetailSidebar', () => ({ | |||
| default: () => <aside>Tag sidebar</aside>, | |||
| })) | |||
| vi.mock ('@/components/PostEditForm', () => ({ | |||
| default: () => <div>Post edit form</div>, | |||
| })) | |||
| vi.mock ('framer-motion', () => ({ | |||
| motion: { | |||
| div: ({ children }: { children?: ReactNode }) => <div>{children}</div>, | |||
| main: ({ children }: { children?: ReactNode }) => <main>{children}</main>, | |||
| }, | |||
| })) | |||
| const renderPage = (user = buildUser ({ role: 'member' })) => | |||
| renderWithProviders ( | |||
| <Routes> | |||
| <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> | |||
| </Routes>, | |||
| { route: '/posts/9' }, | |||
| ) | |||
| describe ('PostDetailPage', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| postsApi.toggleViewedFlg.mockResolvedValue (undefined) | |||
| }) | |||
| it ('loads and displays a post detail', async () => { | |||
| postsApi.fetchPost.mockResolvedValue ( | |||
| buildPost ({ | |||
| id: 9, | |||
| url: 'https://example.com/9', | |||
| related: [], | |||
| thumbnail: null, | |||
| thumbnailBase: null, | |||
| }), | |||
| ) | |||
| renderPage () | |||
| await waitFor (() => { | |||
| expect (postsApi.fetchPost).toHaveBeenCalledWith ('9') | |||
| }) | |||
| expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument () | |||
| expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument () | |||
| expect (screen.getByText ('まだないよ(笑)')).toBeInTheDocument () | |||
| }) | |||
| it ('toggles viewed state through the mutation', async () => { | |||
| postsApi.fetchPost.mockResolvedValue ( | |||
| buildPost ({ id: 9, viewed: false, thumbnail: null, thumbnailBase: null }), | |||
| ) | |||
| renderPage () | |||
| fireEvent.click (await screen.findByRole ('button', { name: '未閲覧' })) | |||
| await waitFor (() => { | |||
| expect (postsApi.toggleViewedFlg).toHaveBeenCalledWith ('9', true) | |||
| }) | |||
| }) | |||
| it ('shows the edit tab for members', async () => { | |||
| postsApi.fetchPost.mockResolvedValue ( | |||
| buildPost ({ id: 9, thumbnail: null, thumbnailBase: null }), | |||
| ) | |||
| renderPage (buildUser ({ role: 'member' })) | |||
| fireEvent.click (await screen.findByText ('編輯')) | |||
| expect (screen.getByText ('Post edit form')).toBeInTheDocument () | |||
| }) | |||
| it ('hides the edit tab for guests', async () => { | |||
| postsApi.fetchPost.mockResolvedValue ( | |||
| buildPost ({ id: 9, thumbnail: null, thumbnailBase: null }), | |||
| ) | |||
| renderPage (buildUser ({ role: 'guest' })) | |||
| expect (await screen.findByText ('関聯')).toBeInTheDocument () | |||
| expect (screen.queryByText ('編輯')).not.toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -13,6 +13,7 @@ import MainArea from '@/components/layout/MainArea' | |||
| import { Button } from '@/components/ui/button' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { isApiError } from '@/lib/api' | |||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { cn } from '@/lib/utils' | |||
| @@ -26,7 +27,7 @@ import type { NiconicoViewerHandle, Post, User } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default (({ user }: Props) => { | |||
| const PostDetailPage: FC<Props> = ({ user }) => { | |||
| const { id } = useParams () | |||
| const postId = String (id ?? '') | |||
| const postKey = postsKeys.show (postId) | |||
| @@ -43,17 +44,15 @@ export default (({ user }: Props) => { | |||
| const [status, setStatus] = useState (200) | |||
| const changeViewedFlg = useMutation ({ | |||
| mutationFn: async () => { | |||
| const cur = qc.getQueryData<any> (postKey) | |||
| const next = !(cur?.viewed) | |||
| mutationFn: async (next: boolean) => { | |||
| await toggleViewedFlg (postId, next) | |||
| return next | |||
| }, | |||
| onMutate: async () => { | |||
| onMutate: async (next: boolean) => { | |||
| await qc.cancelQueries ({ queryKey: postKey }) | |||
| const prev = qc.getQueryData<any> (postKey) | |||
| const prev = qc.getQueryData<Post> (postKey) | |||
| qc.setQueryData (postKey, | |||
| (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | |||
| (cur: Post | undefined) => cur ? { ...cur, viewed: next } : cur) | |||
| return { prev } | |||
| }, | |||
| onError: (...[, , ctx]) => { | |||
| @@ -69,7 +68,7 @@ export default (({ user }: Props) => { | |||
| if (!(errorFlg)) | |||
| return | |||
| const code = (error as any)?.response.status ?? (error as any)?.status | |||
| const code = isApiError (error) ? error.response?.status : undefined | |||
| if (code) | |||
| setStatus (code) | |||
| }, [errorFlg, error]) | |||
| @@ -154,7 +153,7 @@ export default (({ user }: Props) => { | |||
| ref={embedRef} | |||
| post={post} | |||
| onLoadComplete={() => embedRef.current?.play ()}/> | |||
| <Button onClick={() => changeViewedFlg.mutate ()} | |||
| <Button onClick={() => changeViewedFlg.mutate (!(post.viewed))} | |||
| disabled={changeViewedFlg.isPending} | |||
| className={cn ('text-white', viewedClass)}> | |||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||
| @@ -169,9 +168,9 @@ export default (({ user }: Props) => { | |||
| <Tab name="編輯"> | |||
| <PostEditForm | |||
| post={post} | |||
| onSave={newPost => { | |||
| onSave={newPost => { | |||
| qc.setQueryData (postsKeys.show (postId), | |||
| (prev: any) => newPost ?? prev) | |||
| (prev: Post | undefined) => newPost ?? prev) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| }}/> | |||
| @@ -185,4 +184,6 @@ export default (({ user }: Props) => { | |||
| {post && <TagDetailSidebar post={post} sp/>} | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostDetailPage | |||
| @@ -35,7 +35,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||
| </>) | |||
| export default (() => { | |||
| const PostHistoryPage: FC = () => { | |||
| const dialogue = useDialogue () | |||
| const location = useLocation () | |||
| @@ -48,11 +48,11 @@ export default (() => { | |||
| // 投稿列の結合で使用 | |||
| let rowsCnt: number | |||
| const { data: tag } = | |||
| tagId | |||
| ? useQuery ({ queryKey: tagsKeys.show (tagId), | |||
| queryFn: () => fetchTag (tagId) }) | |||
| : { data: null } | |||
| const tagQueryId = tagId ?? '' | |||
| const { data: tag } = useQuery ({ | |||
| enabled: Boolean (tagId), | |||
| queryKey: tagsKeys.show (tagQueryId), | |||
| queryFn: () => fetchTag (tagQueryId) }) | |||
| const { data, isLoading: loading } = useQuery ({ | |||
| queryKey: postsKeys.changes ({ ...(id && { post: id }), | |||
| @@ -290,4 +290,6 @@ export default (() => { | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>)} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default PostHistoryPage | |||
| @@ -0,0 +1,74 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import PostListPage from '@/pages/posts/PostListPage' | |||
| import { buildPost, buildTag, buildWikiPage } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const postsApi = vi.hoisted (() => ({ | |||
| fetchPosts: vi.fn (), | |||
| })) | |||
| const wikiApi = vi.hoisted (() => ({ | |||
| fetchWikiPageByTitle: vi.fn (), | |||
| fetchWikiPages: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/posts', () => postsApi) | |||
| vi.mock ('@/lib/wiki', () => wikiApi) | |||
| describe ('PostListPage', () => { | |||
| it ('loads posts from the current query and renders the plaza tab', async () => { | |||
| const tag = buildTag ({ name: '虹夏' }) | |||
| postsApi.fetchPosts.mockResolvedValueOnce ({ | |||
| posts: [buildPost ({ id: 5, title: '投稿5', tags: [tag] })], | |||
| count: 1, | |||
| }) | |||
| wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({ title: '虹夏' })) | |||
| wikiApi.fetchWikiPages.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) | |||
| renderWithProviders (<PostListPage/>, { route: '/posts?tags=%E8%99%B9%E5%A4%8F&page=2' }) | |||
| await waitFor (() => { | |||
| expect (postsApi.fetchPosts).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| tags: '虹夏', | |||
| match: 'all', | |||
| page: 2, | |||
| limit: 20, | |||
| }), | |||
| ) | |||
| }) | |||
| expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute ( | |||
| 'href', | |||
| '/posts/5', | |||
| ) | |||
| expect (screen.getByText ('広場')).toBeInTheDocument () | |||
| }) | |||
| it ('shows the empty state when loading finishes without posts', async () => { | |||
| postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) | |||
| renderWithProviders (<PostListPage/>, { route: '/posts' }) | |||
| expect (await screen.findByText ('広場には何もありませんよ.')).toBeInTheDocument () | |||
| }) | |||
| it ('renders the wiki tab for single-tag pages', async () => { | |||
| postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) | |||
| wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ( | |||
| buildWikiPage ({ title: '虹夏', body: 'Wiki body' }), | |||
| ) | |||
| wikiApi.fetchWikiPages.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) | |||
| renderWithProviders (<PostListPage/>, { route: '/posts?tags=%E8%99%B9%E5%A4%8F' }) | |||
| fireEvent.click (await screen.findByText ('Wiki')) | |||
| expect (await screen.findByText ('Wiki body')).toBeInTheDocument () | |||
| expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute ( | |||
| 'href', | |||
| '/wiki/%E8%99%B9%E5%A4%8F', | |||
| ) | |||
| }) | |||
| }) | |||
| @@ -1,5 +1,5 @@ | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import { useLayoutEffect, useRef, useState } from 'react' | |||
| import { useLayoutEffect, useMemo, useRef, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useLocation } from 'react-router-dom' | |||
| @@ -20,7 +20,7 @@ import type { FC } from 'react' | |||
| import type { WikiPage } from '@/types' | |||
| export default (() => { | |||
| const PostListPage: FC = () => { | |||
| const containerRef = useRef<HTMLDivElement | null> (null) | |||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | |||
| @@ -30,7 +30,7 @@ export default (() => { | |||
| const tagsQuery = query.get ('tags') ?? '' | |||
| const anyFlg = query.get ('match') === 'any' | |||
| const match = anyFlg ? 'any' : 'all' | |||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | |||
| const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) | |||
| const tagsKey = tags.join (' ') | |||
| const page = Number (query.get ('page') ?? 1) | |||
| const limit = Number (query.get ('limit') ?? 20) | |||
| @@ -66,7 +66,7 @@ export default (() => { | |||
| ; | |||
| } | |||
| }) () | |||
| }, [location.search]) | |||
| }, [location.search, tags]) | |||
| return ( | |||
| <div | |||
| @@ -76,7 +76,7 @@ export default (() => { | |||
| <title> | |||
| {tags.length | |||
| ? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }` | |||
| : `${ SITE_TITLE } 〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||
| : `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||
| </title> | |||
| </Helmet> | |||
| @@ -112,4 +112,6 @@ export default (() => { | |||
| </TabGroup> | |||
| </MainArea> | |||
| </div>) | |||
| }) satisfies FC | |||
| } | |||
| export default PostListPage | |||
| @@ -0,0 +1,58 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import PostNewPage from '@/pages/posts/PostNewPage' | |||
| import { buildUser } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| apiPost: vi.fn (), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| describe ('PostNewPage', () => { | |||
| it ('blocks guests', () => { | |||
| renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>) | |||
| expect (screen.getByText ('403')).toBeInTheDocument () | |||
| }) | |||
| it ('submits a new post with manual title and thumbnail settings', async () => { | |||
| api.apiPost.mockResolvedValueOnce ({}) | |||
| api.apiGet.mockResolvedValue ([]) | |||
| renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) | |||
| const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) | |||
| fireEvent.click (checkboxes[0]) | |||
| fireEvent.click (checkboxes[1]) | |||
| const textboxes = screen.getAllByRole ('textbox') | |||
| fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) | |||
| fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) | |||
| fireEvent.change (textboxes[2], { target: { value: '1 2' } }) | |||
| fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } }) | |||
| fireEvent.click (screen.getByRole ('button', { name: '追加' })) | |||
| await waitFor (() => { | |||
| expect (api.apiPost).toHaveBeenCalledWith ( | |||
| '/posts', | |||
| expect.any (FormData), | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }, | |||
| ) | |||
| }) | |||
| const formData = api.apiPost.mock.calls[0]?.[1] as FormData | |||
| expect (formData.get ('url')).toBe ('https://example.com/post') | |||
| expect (formData.get ('title')).toBe ('投稿タイトル') | |||
| expect (formData.get ('parent_post_ids')).toBe ('1 2') | |||
| expect (formData.get ('tags')).toBe ('tag1 tag2') | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) | |||
| }) | |||
| }) | |||
| @@ -1,4 +1,4 @@ | |||
| import { useEffect, useState, useRef } from 'react' | |||
| import { useCallback, useEffect, useState, useRef } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useNavigate } from 'react-router-dom' | |||
| @@ -21,9 +21,8 @@ import type { User } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default (({ user }: Props) => { | |||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||
| return <Forbidden/> | |||
| const PostNewPage: FC<Props> = ({ user }) => { | |||
| const editable = ['admin', 'member'].some (r => user?.role === r) | |||
| const navigate = useNavigate () | |||
| @@ -41,6 +40,7 @@ export default (({ user }: Props) => { | |||
| const [url, setURL] = useState ('') | |||
| const previousURLRef = useRef ('') | |||
| const thumbnailPreviewRef = useRef ('') | |||
| const handleSubmit = async () => { | |||
| const formData = new FormData | |||
| @@ -67,16 +67,6 @@ export default (({ user }: Props) => { | |||
| } | |||
| } | |||
| useEffect (() => { | |||
| if (titleAutoFlg && url) | |||
| fetchTitle () | |||
| }, [titleAutoFlg]) | |||
| useEffect (() => { | |||
| if (thumbnailAutoFlg && url) | |||
| fetchThumbnail () | |||
| }, [thumbnailAutoFlg]) | |||
| const handleURLBlur = () => { | |||
| if (!(url) || url === previousURLRef.current) | |||
| return | |||
| @@ -88,20 +78,20 @@ export default (({ user }: Props) => { | |||
| previousURLRef.current = url | |||
| } | |||
| const fetchTitle = async () => { | |||
| const fetchTitle = useCallback (async () => { | |||
| setTitle ('') | |||
| setTitleLoading (true) | |||
| const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | |||
| setTitle (data.title || '') | |||
| setTitleLoading (false) | |||
| } | |||
| }, [url]) | |||
| const fetchThumbnail = async () => { | |||
| const fetchThumbnail = useCallback (async () => { | |||
| setThumbnailPreview ('') | |||
| setThumbnailFile (null) | |||
| setThumbnailLoading (true) | |||
| if (thumbnailPreview) | |||
| URL.revokeObjectURL (thumbnailPreview) | |||
| if (thumbnailPreviewRef.current) | |||
| URL.revokeObjectURL (thumbnailPreviewRef.current) | |||
| const data = await apiGet<Blob> ('/preview/thumbnail', | |||
| { params: { url }, responseType: 'blob' }) | |||
| const imageURL = URL.createObjectURL (data) | |||
| @@ -110,7 +100,24 @@ export default (({ user }: Props) => { | |||
| 'thumbnail.png', | |||
| { type: data.type || 'image/png' })) | |||
| setThumbnailLoading (false) | |||
| } | |||
| }, [url]) | |||
| useEffect (() => { | |||
| thumbnailPreviewRef.current = thumbnailPreview | |||
| }, [thumbnailPreview]) | |||
| useEffect (() => { | |||
| if (titleAutoFlg && url) | |||
| fetchTitle () | |||
| }, [fetchTitle, titleAutoFlg, url]) | |||
| useEffect (() => { | |||
| if (thumbnailAutoFlg && url) | |||
| fetchThumbnail () | |||
| }, [fetchThumbnail, thumbnailAutoFlg, url]) | |||
| if (!(editable)) | |||
| return <Forbidden/> | |||
| return ( | |||
| <MainArea> | |||
| @@ -207,4 +214,6 @@ export default (({ user }: Props) => { | |||
| </Button> | |||
| </Form> | |||
| </MainArea>) | |||
| }) satisfies FC<Props> | |||
| } | |||
| export default PostNewPage | |||
| @@ -0,0 +1,84 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | |||
| import PostSearchPage from '@/pages/posts/PostSearchPage' | |||
| import { buildPost, buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const postsApi = vi.hoisted (() => ({ | |||
| fetchPosts: vi.fn (), | |||
| })) | |||
| const api = vi.hoisted (() => ({ | |||
| apiGet: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/posts', () => postsApi) | |||
| vi.mock ('@/lib/api', () => api) | |||
| describe ('PostSearchPage', () => { | |||
| beforeEach (() => { | |||
| vi.clearAllMocks () | |||
| api.apiGet.mockResolvedValue ([]) | |||
| }) | |||
| it ('loads posts from URL search filters', async () => { | |||
| postsApi.fetchPosts.mockResolvedValueOnce ({ | |||
| posts: [buildPost ({ id: 4, title: '検索対象', tags: [buildTag ({ name: '虹夏' })] })], | |||
| count: 1, | |||
| }) | |||
| renderWithProviders ( | |||
| <PostSearchPage/>, | |||
| { route: '/posts/search?title=%E6%A4%9C%E7%B4%A2&tags=x&match=any&page=2' }, | |||
| ) | |||
| await waitFor (() => { | |||
| expect (postsApi.fetchPosts).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| title: '検索', | |||
| tags: 'x', | |||
| match: 'any', | |||
| page: 2, | |||
| }), | |||
| ) | |||
| }) | |||
| expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute ( | |||
| 'href', | |||
| '/posts/4', | |||
| ) | |||
| }) | |||
| it ('submits form state into a new search', async () => { | |||
| postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) | |||
| renderWithProviders (<PostSearchPage/>, { route: '/posts/search' }) | |||
| const textboxes = screen.getAllByRole ('textbox') | |||
| fireEvent.change (textboxes[0], { target: { value: 'title' } }) | |||
| fireEvent.change (textboxes[1], { target: { value: 'https://example.com' } }) | |||
| fireEvent.change (textboxes[2], { target: { value: 'tag' } }) | |||
| fireEvent.click (screen.getByRole ('radio', { name: 'OR' })) | |||
| fireEvent.click (screen.getByRole ('button', { name: '検索' })) | |||
| await waitFor (() => { | |||
| expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( | |||
| expect.objectContaining ({ | |||
| title: 'title', | |||
| url: 'https://example.com', | |||
| tags: 'tag', | |||
| match: 'any', | |||
| page: 1, | |||
| }), | |||
| ) | |||
| }) | |||
| }) | |||
| it ('shows the no-result message', async () => { | |||
| postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) | |||
| renderWithProviders (<PostSearchPage/>, { route: '/posts/search' }) | |||
| expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -32,7 +32,7 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { | |||
| } | |||
| export default (() => { | |||
| const PostSearchPage: FC = () => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| @@ -96,7 +96,8 @@ export default (() => { | |||
| setUpdatedTo (qUpdatedTo) | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, | |||
| qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) | |||
| const search = async () => { | |||
| const qs = new URLSearchParams () | |||
| @@ -336,4 +337,6 @@ export default (() => { | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </div>) : '結果ないよ(笑)')} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default PostSearchPage | |||
| @@ -1,4 +1,6 @@ | |||
| import { useEffect, useRef, useState } from 'react' | |||
| import type { FC } from 'react' | |||
| import { useCallback, useEffect, useRef, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import TagLink from '@/components/TagLink' | |||
| @@ -14,7 +16,7 @@ import type { NicoTag, Tag, User } from '@/types' | |||
| type Props = { user: User | null } | |||
| export default ({ user }: Props) => { | |||
| const NicoTagListPage: FC<Props> = ({ user }) => { | |||
| const [cursor, setCursor] = useState ('') | |||
| const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | |||
| const [loading, setLoading] = useState (false) | |||
| @@ -25,12 +27,8 @@ export default ({ user }: Props) => { | |||
| const memberFlg = ['admin', 'member'].some (r => user?.role === r) | |||
| const loadMore = async (withCursor: boolean) => { | |||
| setLoading (true) | |||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ( | |||
| '/tags/nico', { params: withCursor ? { cursor } : { } }) | |||
| const applyLoadedTags = useCallback ((data: { tags: NicoTag[]; nextCursor: string }, | |||
| withCursor: boolean) => { | |||
| setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | |||
| setCursor (data.nextCursor) | |||
| @@ -40,9 +38,26 @@ export default ({ user }: Props) => { | |||
| const newRawTags = Object.fromEntries ( | |||
| data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) | |||
| setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) | |||
| }, []) | |||
| const loadInitial = useCallback (async () => { | |||
| setLoading (true) | |||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ('/tags/nico') | |||
| applyLoadedTags (data, false) | |||
| setLoading (false) | |||
| } | |||
| }, [applyLoadedTags]) | |||
| const loadMore = useCallback (async () => { | |||
| setLoading (true) | |||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ( | |||
| '/tags/nico', { params: { cursor } }) | |||
| applyLoadedTags (data, true) | |||
| setLoading (false) | |||
| }, [applyLoadedTags, cursor]) | |||
| const handleEdit = async (id: number) => { | |||
| if (editing[id]) | |||
| @@ -67,7 +82,7 @@ export default ({ user }: Props) => { | |||
| useEffect(() => { | |||
| const observer = new IntersectionObserver (entries => { | |||
| if (entries[0].isIntersecting && !(loading) && cursor) | |||
| loadMore (true) | |||
| loadMore () | |||
| }, { threshold: 1 }) | |||
| const target = loaderRef.current | |||
| @@ -78,12 +93,12 @@ export default ({ user }: Props) => { | |||
| if (target) | |||
| observer.unobserve (target) | |||
| } | |||
| }, [loaderRef, loading]) | |||
| }, [cursor, loadMore, loading]) | |||
| useEffect (() => { | |||
| setNicoTags ([]) | |||
| loadMore (false) | |||
| }, []) | |||
| loadInitial () | |||
| }, [loadInitial]) | |||
| return ( | |||
| <MainArea> | |||
| @@ -147,3 +162,5 @@ export default ({ user }: Props) => { | |||
| </div> | |||
| </MainArea>) | |||
| } | |||
| export default NicoTagListPage | |||
| @@ -0,0 +1,71 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { Route, Routes } from 'react-router-dom' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import TagDetailPage from '@/pages/tags/TagDetailPage' | |||
| import { buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const tagsApi = vi.hoisted (() => ({ | |||
| fetchTag: vi.fn (), | |||
| })) | |||
| const api = vi.hoisted (() => ({ | |||
| apiPut: vi.fn (), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/tags', () => tagsApi) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| const renderPage = () => | |||
| renderWithProviders ( | |||
| <Routes> | |||
| <Route path="/tags/:id" element={<TagDetailPage/>}/> | |||
| </Routes>, | |||
| { route: '/tags/7' }, | |||
| ) | |||
| describe ('TagDetailPage', () => { | |||
| it ('loads and displays an editable tag', async () => { | |||
| tagsApi.fetchTag.mockResolvedValueOnce ( | |||
| buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }), | |||
| ) | |||
| renderPage () | |||
| expect (await screen.findByDisplayValue ('虹夏')).toBeInTheDocument () | |||
| expect (screen.getByRole ('combobox')).toHaveValue ('character') | |||
| expect (screen.getByDisplayValue ('drums')).toBeInTheDocument () | |||
| }) | |||
| it ('submits edited tag fields', async () => { | |||
| tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' })) | |||
| api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] })) | |||
| renderPage () | |||
| const name = await screen.findByDisplayValue ('old') | |||
| fireEvent.change (name, { target: { value: 'new' } }) | |||
| fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) | |||
| await waitFor (() => { | |||
| expect (api.apiPut).toHaveBeenCalledWith ('/tags/7', expect.any (FormData)) | |||
| }) | |||
| const formData = api.apiPut.mock.calls[0]?.[1] as FormData | |||
| expect (formData.get ('name')).toBe ('new') | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) | |||
| }) | |||
| it ('keeps nico tags disabled', async () => { | |||
| tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ category: 'nico' })) | |||
| renderPage () | |||
| expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled () | |||
| }) | |||
| }) | |||
| @@ -18,7 +18,7 @@ import type { FC, FormEvent } from 'react' | |||
| import type { Category, Tag } from '@/types' | |||
| export default (() => { | |||
| const TagDetailPage: FC = () => { | |||
| const { id } = useParams () | |||
| const tagId = String (id ?? '') | |||
| const tagKey = tagsKeys.show (tagId) | |||
| @@ -155,4 +155,6 @@ export default (() => { | |||
| </form> | |||
| </div>)} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default TagDetailPage | |||
| @@ -31,7 +31,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||
| </>) | |||
| export default (() => { | |||
| const TagHistoryPage: FC = () => { | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const id = query.get ('id') | |||
| @@ -209,4 +209,6 @@ export default (() => { | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </>)} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default TagHistoryPage | |||
| @@ -0,0 +1,71 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import TagListPage from '@/pages/tags/TagListPage' | |||
| import { buildTag } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const tagsApi = vi.hoisted (() => ({ | |||
| fetchTags: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/tags', () => tagsApi) | |||
| describe ('TagListPage', () => { | |||
| it ('loads tags from URL filters and renders the results table', async () => { | |||
| tagsApi.fetchTags.mockResolvedValueOnce ({ | |||
| tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })], | |||
| count: 1, | |||
| }) | |||
| renderWithProviders ( | |||
| <TagListPage/>, | |||
| { route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' }, | |||
| ) | |||
| await waitFor (() => { | |||
| expect (tagsApi.fetchTags).toHaveBeenCalledWith ( | |||
| expect.objectContaining ({ | |||
| name: '虹', | |||
| category: 'character', | |||
| page: 3, | |||
| postCountGTE: 5, | |||
| }), | |||
| ) | |||
| }) | |||
| expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute ( | |||
| 'href', | |||
| '/tags/7', | |||
| ) | |||
| expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) | |||
| }) | |||
| it ('navigates to a normalized search URL on submit', async () => { | |||
| tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) | |||
| renderWithProviders (<TagListPage/>, { route: '/tags' }) | |||
| fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) | |||
| fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } }) | |||
| fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) | |||
| await waitFor (() => { | |||
| expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( | |||
| expect.objectContaining ({ | |||
| name: '虹夏', | |||
| category: 'character', | |||
| page: 1, | |||
| }), | |||
| ) | |||
| }) | |||
| expect (screen.getByRole ('textbox')).toHaveValue ('虹夏') | |||
| }) | |||
| it ('shows the no-result message', async () => { | |||
| tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 }) | |||
| renderWithProviders (<TagListPage/>, { route: '/tags' }) | |||
| expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () | |||
| }) | |||
| }) | |||
| @@ -29,7 +29,7 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { | |||
| } | |||
| export default (() => { | |||
| const TagListPage: FC = () => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| @@ -87,7 +87,8 @@ export default (() => { | |||
| setUpdatedTo (qUpdatedTo) | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, | |||
| qPostCountLTE, qUpdatedFrom, qUpdatedTo]) | |||
| const handleSearch = (e: FormEvent) => { | |||
| e.preventDefault () | |||
| @@ -296,4 +297,6 @@ export default (() => { | |||
| <Pagination page={page} totalPages={totalPages}/> | |||
| </div>) : '結果ないよ(笑)')} | |||
| </MainArea>) | |||
| }) satisfies FC | |||
| } | |||
| export default TagListPage | |||
| @@ -9,7 +9,7 @@ import TagDetailSidebar from '@/components/TagDetailSidebar' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' | |||
| import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' | |||
| import { fetchPost } from '@/lib/posts' | |||
| import { dateString } from '@/lib/utils' | |||
| @@ -34,11 +34,12 @@ const INITIAL_THEATRE_INFO = | |||
| watchingUsers: [] as { id: number; name: string }[] } as const | |||
| export default (() => { | |||
| const TheatreDetailPage: FC = () => { | |||
| const { id } = useParams () | |||
| const commentsRef = useRef<HTMLDivElement> (null) | |||
| const embedRef = useRef<NiconicoViewerHandle> (null) | |||
| const loadingRef = useRef (false) | |||
| const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | |||
| const videoLengthRef = useRef (0) | |||
| const lastCommentNoRef = useRef (0) | |||
| @@ -53,6 +54,10 @@ export default (() => { | |||
| const [post, setPost] = useState<Post | null> (null) | |||
| const [videoLength, setVideoLength] = useState (0) | |||
| useEffect (() => { | |||
| loadingRef.current = loading | |||
| }, [loading]) | |||
| useEffect (() => { | |||
| theatreInfoRef.current = theatreInfo | |||
| }, [theatreInfo]) | |||
| @@ -87,7 +92,7 @@ export default (() => { | |||
| } | |||
| catch (error) | |||
| { | |||
| setStatus ((error as any)?.response.status ?? 200) | |||
| setStatus (isApiError (error) ? error.response?.status ?? 200 : 200) | |||
| } | |||
| }) () | |||
| @@ -160,7 +165,7 @@ export default (() => { | |||
| }, [id]) | |||
| useEffect (() => { | |||
| if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) | |||
| if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) | |||
| return | |||
| let cancelled = false | |||
| @@ -338,4 +343,6 @@ export default (() => { | |||
| {post && <TagDetailSidebar post={post} sp/>} | |||
| </div> | |||
| </div>) | |||
| }) satisfies FC | |||
| } | |||
| export default TheatreDetailPage | |||
| @@ -0,0 +1,54 @@ | |||
| import { fireEvent, screen, waitFor } from '@testing-library/react' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import SettingPage from '@/pages/users/SettingPage' | |||
| import { buildUser } from '@/test/factories' | |||
| import { renderWithProviders } from '@/test/render' | |||
| const api = vi.hoisted (() => ({ | |||
| apiPut: vi.fn (), | |||
| })) | |||
| const toastApi = vi.hoisted (() => ({ | |||
| toast: vi.fn (), | |||
| })) | |||
| vi.mock ('@/lib/api', () => api) | |||
| vi.mock ('@/components/ui/use-toast', () => toastApi) | |||
| vi.mock ('@/components/users/UserCodeDialogue', () => ({ | |||
| default: () => null, | |||
| })) | |||
| vi.mock ('@/components/users/InheritDialogue', () => ({ | |||
| default: () => null, | |||
| })) | |||
| describe ('SettingPage', () => { | |||
| it ('shows loading when user is absent', () => { | |||
| renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>) | |||
| expect (screen.getByText ('Loading...')).toBeInTheDocument () | |||
| }) | |||
| it ('updates the current user name', async () => { | |||
| const user = buildUser ({ id: 11, name: 'old' }) | |||
| const setUser = vi.fn () | |||
| api.apiPut.mockResolvedValueOnce ({ ...user, name: 'new' }) | |||
| renderWithProviders (<SettingPage user={user} setUser={setUser}/>) | |||
| fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } }) | |||
| fireEvent.click (screen.getByRole ('button', { name: '更新' })) | |||
| await waitFor (() => { | |||
| expect (api.apiPut).toHaveBeenCalledWith ( | |||
| '/users/11', | |||
| expect.any (FormData), | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }, | |||
| ) | |||
| }) | |||
| const formData = api.apiPut.mock.calls[0]?.[1] as FormData | |||
| expect (formData.get ('name')).toBe ('new') | |||
| expect (setUser).toHaveBeenCalled () | |||
| expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) | |||
| }) | |||
| }) | |||
| @@ -1,3 +1,5 @@ | |||
| import type { FC } from 'react' | |||
| import { useEffect, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| @@ -18,7 +20,7 @@ type Props = { user: User | null | |||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | |||
| export default ({ user, setUser }: Props) => { | |||
| const SettingPage: FC<Props> = ({ user, setUser }) => { | |||
| const [name, setName] = useState ('') | |||
| const [userCodeVsbl, setUserCodeVsbl] = useState (false) | |||
| const [inheritVsbl, setInheritVsbl] = useState (false) | |||
| @@ -110,3 +112,5 @@ export default ({ user, setUser }: Props) => { | |||
| setUser={setUser}/> | |||
| </MainArea>) | |||
| } | |||
| export default SettingPage | |||