| @@ -18,12 +18,13 @@ npm install | |||||
| npm run dev | npm run dev | ||||
| npm run build | npm run build | ||||
| npm run lint | npm run lint | ||||
| npm test | |||||
| npm run test | |||||
| npm run test:run | |||||
| ``` | ``` | ||||
| ### Full verification | ### Full verification | ||||
| ```sh | ```sh | ||||
| cd backend && bundle exec rspec | 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' | import tseslint from 'typescript-eslint' | ||||
| export default tseslint.config( | export default tseslint.config( | ||||
| { ignores: ['dist'] }, | |||||
| { ignores: ['dist', 'tailwind.config.js'] }, | |||||
| { | { | ||||
| extends: [js.configs.recommended, ...tseslint.configs.recommended], | extends: [js.configs.recommended, ...tseslint.configs.recommended], | ||||
| files: ['**/*.{ts,tsx}'], | files: ['**/*.{ts,tsx}'], | ||||
| @@ -8,6 +8,8 @@ | |||||
| "build": "tsc -b && vite build", | "build": "tsc -b && vite build", | ||||
| "postbuild": "node scripts/generate-sitemap.js", | "postbuild": "node scripts/generate-sitemap.js", | ||||
| "lint": "eslint .", | "lint": "eslint .", | ||||
| "test": "vitest", | |||||
| "test:run": "vitest run", | |||||
| "preview": "vite preview" | "preview": "vite preview" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| @@ -45,6 +47,10 @@ | |||||
| "devDependencies": { | "devDependencies": { | ||||
| "@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
| "@tailwindcss/typography": "^0.5.19", | "@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/axios": "^0.14.4", | ||||
| "@types/markdown-it": "^14.1.2", | "@types/markdown-it": "^14.1.2", | ||||
| "@types/mdx": "^2.0.13", | "@types/mdx": "^2.0.13", | ||||
| @@ -58,11 +64,13 @@ | |||||
| "eslint-plugin-react-hooks": "^5.2.0", | "eslint-plugin-react-hooks": "^5.2.0", | ||||
| "eslint-plugin-react-refresh": "^0.4.19", | "eslint-plugin-react-refresh": "^0.4.19", | ||||
| "globals": "^16.0.0", | "globals": "^16.0.0", | ||||
| "jsdom": "^26.1.0", | |||||
| "postcss": "^8.5.3", | "postcss": "^8.5.3", | ||||
| "tailwindcss": "^3.4.13", | "tailwindcss": "^3.4.13", | ||||
| "typescript": "~5.8.3", | "typescript": "~5.8.3", | ||||
| "typescript-eslint": "^8.30.1", | "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.", | "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", | ||||
| "main": "eslint.config.js", | "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 [user, setUser] = useState<User | null> (null) | ||||
| const [status, setStatus] = useState (200) | const [status, setStatus] = useState (200) | ||||
| @@ -156,4 +156,6 @@ export default (() => { | |||||
| </DialogueProvider> | </DialogueProvider> | ||||
| </BrowserRouter> | </BrowserRouter> | ||||
| </>) | </>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default App | |||||
| @@ -19,7 +19,7 @@ type Props = { | |||||
| sp?: boolean } | 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 dndId = `tag-node:${ pathKey }` | ||||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | 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}/> | <TagLink tag={tag} nestLevel={nestLevel}/> | ||||
| </motion.div> | </motion.div> | ||||
| </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 } | type Props = { status: number } | ||||
| export default (({ status }: Props) => { | |||||
| const ErrorScreen: FC<Props> = ({ status }) => { | |||||
| const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | ||||
| switch (status) | switch (status) | ||||
| { | { | ||||
| @@ -58,4 +58,6 @@ export default (({ status }: Props) => { | |||||
| <p className="mr-[-.5em]">{message}</p> | <p className="mr-[-.5em]">{message}</p> | ||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default ErrorScreen | |||||
| @@ -31,7 +31,7 @@ const setChildrenById = ( | |||||
| })) | })) | ||||
| export default (() => { | |||||
| const MaterialSidebar: FC = () => { | |||||
| const [tags, setTags] = useState<TagWithDepth[]> ([]) | const [tags, setTags] = useState<TagWithDepth[]> ([]) | ||||
| const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | ||||
| const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | ||||
| @@ -94,4 +94,6 @@ export default (() => { | |||||
| {renderTags (tags)} | {renderTags (tags)} | ||||
| </ul> | </ul> | ||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialSidebar | |||||
| @@ -1,9 +1,11 @@ | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| export default (() => ( | |||||
| const MenuSeparator: FC = () => ( | |||||
| <> | <> | ||||
| <span className="hidden md:inline flex items-center px-2">|</span> | <span className="hidden md:inline flex items-center px-2">|</span> | ||||
| <hr className="block md:hidden w-full opacity-25 | <hr className="block md:hidden w-full opacity-25 | ||||
| border-t border-black dark:border-white"/> | 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 { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { isApiError } from '@/lib/api' | |||||
| import { updatePost } from '@/lib/posts' | import { updatePost } from '@/lib/posts' | ||||
| import type { FC, FormEvent } from 'react' | import type { FC, FormEvent } from 'react' | ||||
| @@ -32,7 +33,7 @@ type Props = { post: Post | |||||
| onSave: (newPost: Post) => void } | onSave: (newPost: Post) => void } | ||||
| export default (({ post, onSave }: Props) => { | |||||
| const PostEditForm: FC<Props> = ({ post, onSave }) => { | |||||
| const [disabled, setDisabled] = useState (false) | const [disabled, setDisabled] = useState (false) | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | const [originalCreatedBefore, setOriginalCreatedBefore] = | ||||
| useState<string | null> (post.originalCreatedBefore) | useState<string | null> (post.originalCreatedBefore) | ||||
| @@ -62,7 +63,7 @@ export default (({ post, onSave }: Props) => { | |||||
| } | } | ||||
| catch (e) | catch (e) | ||||
| { | { | ||||
| const response = (e as any)?.response | |||||
| const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined | |||||
| if (response?.status !== 409) | if (response?.status !== 409) | ||||
| { | { | ||||
| @@ -164,4 +165,6 @@ export default (({ post, onSave }: Props) => { | |||||
| 更新 | 更新 | ||||
| </Button> | </Button> | ||||
| </form>) | </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 } | onMetadataChange?: (meta: NiconicoMetadata) => void } | ||||
| export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const [framed, setFramed] = useState (false) | |||||
| const url = new URL (post.url) | const url = new URL (post.url) | ||||
| @@ -44,7 +45,7 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| case 'twitter.com': | case 'twitter.com': | ||||
| case 'x.com': | case 'x.com': | ||||
| { | { | ||||
| const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/) | |||||
| const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) | |||||
| const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) | const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) | ||||
| if (!(mUserId) || !(mStatusId)) | if (!(mUserId) || !(mStatusId)) | ||||
| break | break | ||||
| @@ -72,8 +73,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| const [framed, setFramed] = useState (false) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| {framed | {framed | ||||
| @@ -101,4 +100,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| </a> | </a> | ||||
| </div>)} | </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 } | setTags: (tags: string) => void } | ||||
| export default (({ tags, setTags, ...rest }: Props) => { | |||||
| const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => { | |||||
| const ref = useRef<HTMLTextAreaElement> (null) | const ref = useRef<HTMLTextAreaElement> (null) | ||||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | ||||
| @@ -97,4 +97,6 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||||
| activeIndex={-1} | activeIndex={-1} | ||||
| onSelect={handleTagSelect}/>)} | onSelect={handleTagSelect}/>)} | ||||
| </div>) | </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 } | onClick?: (event: MouseEvent<HTMLElement>) => void } | ||||
| export default (({ posts, onClick }: Props) => { | |||||
| const PostList: FC<Props> = ({ posts, onClick }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | ||||
| @@ -70,4 +70,6 @@ export default (({ posts, onClick }: Props) => { | |||||
| </PrefetchLink>) | </PrefetchLink>) | ||||
| })} | })} | ||||
| </div>) | </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 } | setOriginalCreatedBefore: (x: string | null) => void } | ||||
| export default (({ disabled, | |||||
| const PostOriginalCreatedTimeField: FC<Props> = ({ disabled, | |||||
| originalCreatedFrom, | originalCreatedFrom, | ||||
| setOriginalCreatedFrom, | setOriginalCreatedFrom, | ||||
| originalCreatedBefore, | originalCreatedBefore, | ||||
| setOriginalCreatedBefore }: Props) => ( | |||||
| setOriginalCreatedBefore }) => ( | |||||
| <div> | <div> | ||||
| <Label>オリジナルの作成日時</Label> | <Label>オリジナルの作成日時</Label> | ||||
| <div className="my-1 flex"> | <div className="my-1 flex"> | ||||
| @@ -71,4 +71,6 @@ export default (({ disabled, | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| </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 }) })) | setActive: v => set ({ active: v }) })) | ||||
| export default (() => { | |||||
| const RouteBlockerOverlay: FC = () => { | |||||
| const active = useOverlayStore (s => s.active) | const active = useOverlayStore (s => s.active) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -43,4 +43,6 @@ export default (() => { | |||||
| </div> | </div> | ||||
| </div> | </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 } | type Props = { post: Post; sp?: boolean } | ||||
| export default (({ post, sp }: Props) => { | |||||
| const TagDetailSidebar: FC<Props> = ({ post, sp }) => { | |||||
| sp = Boolean (sp) | sp = Boolean (sp) | ||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| @@ -376,4 +376,6 @@ export default (({ post, sp }: Props) => { | |||||
| </DragOverlay> | </DragOverlay> | ||||
| </DndContext> | </DndContext> | ||||
| </SidebarComponent>) | </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 | | PropsWithoutLink | ||||
| export default (({ tag, | |||||
| const TagLink: FC<Props> = ({ tag, | |||||
| nestLevel = 0, | nestLevel = 0, | ||||
| linkFlg = true, | linkFlg = true, | ||||
| withWiki = true, | withWiki = true, | ||||
| withCount = true, | withCount = true, | ||||
| ...props }: Props) => { | |||||
| ...props }) => { | |||||
| const spanClass = cn ( | const spanClass = cn ( | ||||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | ||||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | ||||
| @@ -126,4 +126,6 @@ export default (({ tag, | |||||
| {withCount && ( | {withCount && ( | ||||
| <span className="ml-1">{tag.postCount}</span>)} | <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' | import type { Tag } from '@/types' | ||||
| export default (() => { | |||||
| const TagSearch: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -115,4 +115,6 @@ export default (() => { | |||||
| activeIndex={activeIndex} | activeIndex={activeIndex} | ||||
| onSelect={handleTagSelect}/> | onSelect={handleTagSelect}/> | ||||
| </div>) | </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 } | onSelect: (tag: Tag) => void } | ||||
| export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||||
| const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => { | |||||
| if (suggestions.length === 0) | if (suggestions.length === 0) | ||||
| return | return | ||||
| @@ -26,4 +26,6 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||||
| <TagLink tag={tag} linkFlg={false} withWiki={false}/> | <TagLink tag={tag} linkFlg={false} withWiki={false}/> | ||||
| </li>))} | </li>))} | ||||
| </ul>) | </ul>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagSearchBox | |||||
| @@ -19,7 +19,7 @@ type Props = { posts: Post[] | |||||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | onClick?: (event: MouseEvent<HTMLElement>) => void } | ||||
| export default (({ posts, onClick }: Props) => { | |||||
| const TagSidebar: FC<Props> = ({ posts, onClick }) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [tagsVsbl, setTagsVsbl] = useState (false) | const [tagsVsbl, setTagsVsbl] = useState (false) | ||||
| @@ -126,4 +126,6 @@ export default (({ posts, onClick }: Props) => { | |||||
| {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | ||||
| </a> | </a> | ||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagSidebar | |||||
| @@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| pathName: string }): Menu => { | pathName: string }): Menu => { | ||||
| const postCount = tag?.postCount ?? 0 | 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 wikiTitle = pathName.split ('/')[2] ?? '' | ||||
| const tagFlg = /^\/tags\/\d+/.test (pathName) | 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 location = useLocation () | ||||
| const dirRef = useRef<(-1) | 1> (1) | const dirRef = useRef<(-1) | 1> (1) | ||||
| @@ -159,12 +159,12 @@ export default (({ user }: Props) => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | const unsubscribe = WikiIdBus.subscribe (setWikiId) | ||||
| return () => unsubscribe () | return () => unsubscribe () | ||||
| }, [activeIdx]) | |||||
| }, []) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setMenuOpen (false) | setMenuOpen (false) | ||||
| setOpenItemIdx (activeIdx) | setOpenItemIdx (activeIdx) | ||||
| }, [location]) | |||||
| }, [activeIdx, location]) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -433,4 +433,6 @@ export default (({ user }: Props) => { | |||||
| </motion.div>)} | </motion.div>)} | ||||
| </AnimatePresence> | </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 } | sp?: boolean } | ||||
| export default (({ user, sp }: Props) => { | |||||
| const TopNavUser: FC<Props> = ({ user, sp }) => { | |||||
| if (!(user)) | if (!(user)) | ||||
| return | return | ||||
| @@ -28,4 +28,6 @@ export default (({ user, sp }: Props) => { | |||||
| {user.name || '名もなきニジラー'} | {user.name || '名もなきニジラー'} | ||||
| </PrefetchLink> | </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 } | statusId: string } | ||||
| export default (({ userId, statusId }: Props) => { | |||||
| const TwitterEmbed: FC<Props> = ({ userId, statusId }) => { | |||||
| const now = (new Date).toLocaleDateString () | const now = (new Date).toLocaleDateString () | ||||
| return ( | return ( | ||||
| @@ -18,4 +18,6 @@ export default (({ userId, statusId }: Props) => { | |||||
| </blockquote> | </blockquote> | ||||
| <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TwitterEmbed | |||||
| @@ -25,7 +25,7 @@ const mdComponents = { a: (({ href, children }) => ( | |||||
| </a>))) } as const satisfies Components | </a>))) } as const satisfies Components | ||||
| export default (({ title, body }: Props) => { | |||||
| const WikiBody: FC<Props> = ({ title, body }) => { | |||||
| const { data } = useQuery ({ | const { data } = useQuery ({ | ||||
| enabled: Boolean (body), | enabled: Boolean (body), | ||||
| queryKey: wikiKeys.index ({ }), | queryKey: wikiKeys.index ({ }), | ||||
| @@ -39,4 +39,6 @@ export default (({ title, body }: Props) => { | |||||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | ||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | ||||
| </ReactMarkdown>) | </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 } | 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 ('') | const [local, setLocal] = useState ('') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -44,4 +44,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | onChange?.(v ? (new Date (v)).toISOString () : null) | ||||
| }} | }} | ||||
| onBlur={onBlur}/>) | onBlur={onBlur}/>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default DateTimeField | |||||
| @@ -3,7 +3,9 @@ import type { FC, ReactNode } from 'react' | |||||
| type Props = { children: ReactNode } | 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"> | <div className="max-w-xl mx-auto p-4 space-y-4"> | ||||
| {children} | {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 React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode | type Props = { children: React.ReactNode | ||||
| checkBox?: { label: string | checkBox?: { label: string | ||||
| checked: boolean | checked: boolean | ||||
| onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } } | onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } } | ||||
| export default ({ children, checkBox }: Props) => { | |||||
| const Label: FC<Props> = ({ children, checkBox }) => { | |||||
| if (!(checkBox)) | if (!(checkBox)) | ||||
| { | { | ||||
| return ( | return ( | ||||
| @@ -26,3 +28,5 @@ export default ({ children, checkBox }: Props) => { | |||||
| </label> | </label> | ||||
| </div>) | </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 React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode } | type Props = { children: React.ReactNode } | ||||
| export default ({ children }: Props) => ( | |||||
| const PageTitle: FC<Props> = ({ children }) => ( | |||||
| <h1 className="text-2xl font-bold mb-2"> | <h1 className="text-2xl font-bold mb-2"> | ||||
| {children} | {children} | ||||
| </h1>) | </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 location = useLocation () | ||||
| const buildTo = (p: number) => { | const buildTo = (p: number) => { | ||||
| @@ -124,4 +124,6 @@ export default (({ page, totalPages, siblingCount = 3 }) => { | |||||
| </>)} | </>)} | ||||
| </div> | </div> | ||||
| </nav>) | </nav>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default Pagination | |||||
| @@ -5,7 +5,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react' | |||||
| type Props = ComponentPropsWithoutRef<'h2'> | 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)}> | <h2 {...rest} className={cn ('text-xl my-4', className)}> | ||||
| {children} | {children} | ||||
| </h2>)) satisfies FC<Props> | |||||
| </h2>) | |||||
| export default SectionTitle | |||||
| @@ -1,9 +1,13 @@ | |||||
| import React from 'react' | import React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode } | type Props = { children: React.ReactNode } | ||||
| export default ({ children }: Props) => ( | |||||
| const SubsectionTitle: FC<Props> = ({ children }) => ( | |||||
| <h3 className="my-2"> | <h3 className="my-2"> | ||||
| {children} | {children} | ||||
| </h3>) | </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 React, { useState } from 'react' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| @@ -10,7 +12,7 @@ type Props = { children: React.ReactNode } | |||||
| export const Tab = ({ children }: TabProps) => <>{children}</> | 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 tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] | ||||
| const [current, setCurrent] = useState<number> (() => { | const [current, setCurrent] = useState<number> (() => { | ||||
| @@ -37,3 +39,5 @@ export default ({ children }: Props) => { | |||||
| </div> | </div> | ||||
| </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 | value: string | ||||
| setValue: (value: string) => void } | setValue: (value: string) => void } | ||||
| export default (({ value, setValue }: Props) => { | |||||
| const TagInput: FC<Props> = ({ value, setValue }) => { | |||||
| const [activeIndex, setActiveIndex] = useState (-1) | const [activeIndex, setActiveIndex] = useState (-1) | ||||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | const [suggestions, setSuggestions] = useState<Tag[]> ([]) | ||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | ||||
| @@ -62,9 +62,12 @@ export default (({ value, setValue }: Props) => { | |||||
| case 'Enter': | case 'Enter': | ||||
| if (activeIndex < 0) | if (activeIndex < 0) | ||||
| break | break | ||||
| ev.preventDefault () | |||||
| const selected = suggestions[activeIndex] | |||||
| selected && handleTagSelect (selected) | |||||
| { | |||||
| ev.preventDefault () | |||||
| const selected = suggestions[activeIndex] | |||||
| if (selected) | |||||
| handleTagSelect (selected) | |||||
| } | |||||
| break | break | ||||
| case 'Escape': | case 'Escape': | ||||
| @@ -94,4 +97,6 @@ export default (({ value, setValue }: Props) => { | |||||
| activeIndex={activeIndex} | activeIndex={activeIndex} | ||||
| onSelect={handleTagSelect}/> | onSelect={handleTagSelect}/> | ||||
| </div>) | </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 } | type Props = { children: ReactNode } | ||||
| export default (({ children }: Props) => { | |||||
| const DialogueProvider: FC<Props> = ({ children }) => { | |||||
| const [queue, setQueue] = useState<DialogueRequest[]> ([]) | const [queue, setQueue] = useState<DialogueRequest[]> ([]) | ||||
| const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | ||||
| @@ -174,7 +174,7 @@ export default (({ children }: Props) => { | |||||
| </DialogContent>)} | </DialogContent>)} | ||||
| </Dialog> | </Dialog> | ||||
| </DialogueContext.Provider>) | </DialogueContext.Provider>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export const useDialogue = () => { | export const useDialogue = () => { | ||||
| @@ -185,3 +185,5 @@ export const useDialogue = () => { | |||||
| return dialogue | return dialogue | ||||
| } | } | ||||
| export default DialogueProvider | |||||
| @@ -9,10 +9,12 @@ type Props = { | |||||
| className?: string } | className?: string } | ||||
| export default (({ children, className }: Props) => ( | |||||
| const MainArea: FC<Props> = ({ children, className }) => ( | |||||
| <motion.main | <motion.main | ||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | transition={{ layout: { duration: .2, ease: 'easeOut' } }} | ||||
| className={cn ('flex-1 overflow-y-auto p-4', className)} | className={cn ('flex-1 overflow-y-auto p-4', className)} | ||||
| layout="position"> | layout="position"> | ||||
| {children} | {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 } | type Props = { children: ReactNode } | ||||
| export default (({ children }: Props) => ( | |||||
| const SidebarComponent: FC<Props> = ({ children }) => ( | |||||
| <motion.div | <motion.div | ||||
| layout="position" | layout="position" | ||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | transition={{ layout: { duration: .2, ease: 'easeOut' } }} | ||||
| @@ -27,4 +27,6 @@ export default (({ children }: Props) => ( | |||||
| </Helmet> | </Helmet> | ||||
| {children} | {children} | ||||
| </motion.div>)) satisfies FC<Props> | |||||
| </motion.div>) | |||||
| export default SidebarComponent | |||||
| @@ -18,13 +18,6 @@ type ToasterToast = ToastProps & { | |||||
| action?: ToastActionElement | 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 | let count = 0 | ||||
| function genId() { | function genId() { | ||||
| @@ -32,23 +25,21 @@ function genId() { | |||||
| return count.toString() | return count.toString() | ||||
| } | } | ||||
| type ActionType = typeof actionTypes | |||||
| type Action = | type Action = | ||||
| | { | | { | ||||
| type: ActionType["ADD_TOAST"] | |||||
| type: "ADD_TOAST" | |||||
| toast: ToasterToast | toast: ToasterToast | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["UPDATE_TOAST"] | |||||
| type: "UPDATE_TOAST" | |||||
| toast: Partial<ToasterToast> | toast: Partial<ToasterToast> | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["DISMISS_TOAST"] | |||||
| type: "DISMISS_TOAST" | |||||
| toastId?: ToasterToast["id"] | toastId?: ToasterToast["id"] | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["REMOVE_TOAST"] | |||||
| type: "REMOVE_TOAST" | |||||
| toastId?: ToasterToast["id"] | toastId?: ToasterToast["id"] | ||||
| } | } | ||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | import { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| @@ -18,7 +20,7 @@ type Props = { visible: boolean | |||||
| setUser: (user: User) => void } | setUser: (user: User) => void } | ||||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const [inputCode, setInputCode] = useState ('') | const [inputCode, setInputCode] = useState ('') | ||||
| @@ -68,3 +70,5 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </Dialog>) | ||||
| } | } | ||||
| export default InheritDialogue | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | import { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| @@ -17,7 +19,7 @@ type Props = { visible: boolean | |||||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | 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 dialogue = useDialogue () | ||||
| const handleChange = async () => { | const handleChange = async () => { | ||||
| @@ -69,3 +71,5 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </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)) | const res = await client[method] (path, body ?? { }, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | if (opt?.responseType === 'blob') | ||||
| return res.data as T | 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)) | const res = await client.get (path, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | if (opt?.responseType === 'blob') | ||||
| return res.data as T | 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 | let last = 0 | ||||
| const parts: RootContent[] = [] | const parts: RootContent[] = [] | ||||
| while (m = re.exec (value)) | |||||
| while ((m = re.exec (value)) !== null) | |||||
| { | { | ||||
| const start = m.index | const start = m.index | ||||
| const end = start + m[0].length | 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)) | if (Array.isArray (maybeChidren)) | ||||
| { | { | ||||
| const parent = node as Parent | 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' | 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' | import type { User } from '@/types' | ||||
| export default (() => { | |||||
| const MorePage: FC = () => { | |||||
| const menu = menuOutline ( | const menu = menuOutline ( | ||||
| { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) | { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) | ||||
| @@ -43,4 +43,6 @@ export default (() => { | |||||
| </section>))} | </section>))} | ||||
| </div>))} | </div>))} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MorePage | |||||
| @@ -1,4 +1,8 @@ | |||||
| import type { FC } from 'react' | |||||
| import ErrorScreen from '@/components/ErrorScreen' | 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' | 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 { useQuery, useQueryClient } from '@tanstack/react-query' | ||||
| import { useEffect, useState } from 'react' | |||||
| import { useEffect, useMemo, useState } from 'react' | |||||
| import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -18,7 +18,7 @@ import type { FC, FormEvent } from 'react' | |||||
| import type { Deerjikist, Platform } from '@/types' | import type { Deerjikist, Platform } from '@/types' | ||||
| export default (() => { | |||||
| const DeerjikistDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const tagId = String (id ?? '') | const tagId = String (id ?? '') | ||||
| const tagKey = tagsKeys.deerjikists (tagId) | const tagKey = tagsKeys.deerjikists (tagId) | ||||
| @@ -26,7 +26,7 @@ export default (() => { | |||||
| const { data: qData, isLoading: loading } = | const { data: qData, isLoading: loading } = | ||||
| useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) | useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) | ||||
| const tag = qData?.tag | const tag = qData?.tag | ||||
| const deerjikists = qData?.deerjikists ?? [] | |||||
| const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData]) | |||||
| const [data, setData] = | const [data, setData] = | ||||
| useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) | useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) | ||||
| @@ -152,4 +152,6 @@ export default (() => { | |||||
| </div> | </div> | ||||
| )} | )} | ||||
| </MainArea>) | </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' | import type { FC } from 'react' | ||||
| export default (() => ( | |||||
| const MaterialBasePage: FC = () => ( | |||||
| <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | ||||
| <MaterialSidebar/> | <MaterialSidebar/> | ||||
| <Outlet/> | <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 } | type MaterialWithTag = Material & { tag: Tag } | ||||
| export default (() => { | |||||
| const MaterialDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const [file, setFile] = useState<File | null> (null) | const [file, setFile] = useState<File | null> (null) | ||||
| @@ -179,4 +179,6 @@ export default (() => { | |||||
| </TabGroup> | </TabGroup> | ||||
| </>))} | </>))} | ||||
| </MainArea>) | </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 [loading, setLoading] = useState (false) | ||||
| const [tag, setTag] = useState<TagWithMaterial | null> (null) | const [tag, setTag] = useState<TagWithMaterial | null> (null) | ||||
| @@ -69,7 +69,7 @@ export default (() => { | |||||
| setLoading (false) | setLoading (false) | ||||
| } | } | ||||
| }) () | }) () | ||||
| }, [location.search]) | |||||
| }, [location.search, tagQuery]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -163,4 +163,6 @@ export default (() => { | |||||
| </ul> | </ul> | ||||
| </>))} | </>))} | ||||
| </MainArea>) | </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' | import type { FC } from 'react' | ||||
| export default (() => { | |||||
| const MaterialNewPage: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const tagQuery = query.get ('tag') ?? '' | const tagQuery = query.get ('tag') ?? '' | ||||
| @@ -121,4 +121,6 @@ export default (() => { | |||||
| </Button> | </Button> | ||||
| </Form> | </Form> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialNewPage | |||||
| @@ -10,7 +10,7 @@ import { SITE_TITLE } from '@/config' | |||||
| import type { FC, FormEvent } from 'react' | import type { FC, FormEvent } from 'react' | ||||
| export default (() => { | |||||
| const MaterialSearchPage: FC = () => { | |||||
| const [tagName, setTagName] = useState ('') | const [tagName, setTagName] = useState ('') | ||||
| const [parentTagName, setParentTagName] = useState ('') | const [parentTagName, setParentTagName] = useState ('') | ||||
| @@ -46,4 +46,6 @@ export default (() => { | |||||
| </form> | </form> | ||||
| </div> | </div> | ||||
| </MainArea>) | </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 { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { isApiError } from '@/lib/api' | |||||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | import { fetchPost, toggleViewedFlg } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys } from '@/lib/queryKeys' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| @@ -26,7 +27,7 @@ import type { NiconicoViewerHandle, Post, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default (({ user }: Props) => { | |||||
| const PostDetailPage: FC<Props> = ({ user }) => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const postId = String (id ?? '') | const postId = String (id ?? '') | ||||
| const postKey = postsKeys.show (postId) | const postKey = postsKeys.show (postId) | ||||
| @@ -43,17 +44,15 @@ export default (({ user }: Props) => { | |||||
| const [status, setStatus] = useState (200) | const [status, setStatus] = useState (200) | ||||
| const changeViewedFlg = useMutation ({ | const changeViewedFlg = useMutation ({ | ||||
| mutationFn: async () => { | |||||
| const cur = qc.getQueryData<any> (postKey) | |||||
| const next = !(cur?.viewed) | |||||
| mutationFn: async (next: boolean) => { | |||||
| await toggleViewedFlg (postId, next) | await toggleViewedFlg (postId, next) | ||||
| return next | return next | ||||
| }, | }, | ||||
| onMutate: async () => { | |||||
| onMutate: async (next: boolean) => { | |||||
| await qc.cancelQueries ({ queryKey: postKey }) | await qc.cancelQueries ({ queryKey: postKey }) | ||||
| const prev = qc.getQueryData<any> (postKey) | |||||
| const prev = qc.getQueryData<Post> (postKey) | |||||
| qc.setQueryData (postKey, | qc.setQueryData (postKey, | ||||
| (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | |||||
| (cur: Post | undefined) => cur ? { ...cur, viewed: next } : cur) | |||||
| return { prev } | return { prev } | ||||
| }, | }, | ||||
| onError: (...[, , ctx]) => { | onError: (...[, , ctx]) => { | ||||
| @@ -69,7 +68,7 @@ export default (({ user }: Props) => { | |||||
| if (!(errorFlg)) | if (!(errorFlg)) | ||||
| return | return | ||||
| const code = (error as any)?.response.status ?? (error as any)?.status | |||||
| const code = isApiError (error) ? error.response?.status : undefined | |||||
| if (code) | if (code) | ||||
| setStatus (code) | setStatus (code) | ||||
| }, [errorFlg, error]) | }, [errorFlg, error]) | ||||
| @@ -154,7 +153,7 @@ export default (({ user }: Props) => { | |||||
| ref={embedRef} | ref={embedRef} | ||||
| post={post} | post={post} | ||||
| onLoadComplete={() => embedRef.current?.play ()}/> | onLoadComplete={() => embedRef.current?.play ()}/> | ||||
| <Button onClick={() => changeViewedFlg.mutate ()} | |||||
| <Button onClick={() => changeViewedFlg.mutate (!(post.viewed))} | |||||
| disabled={changeViewedFlg.isPending} | disabled={changeViewedFlg.isPending} | ||||
| className={cn ('text-white', viewedClass)}> | className={cn ('text-white', viewedClass)}> | ||||
| {post.viewed ? '閲覧済' : '未閲覧'} | {post.viewed ? '閲覧済' : '未閲覧'} | ||||
| @@ -169,9 +168,9 @@ export default (({ user }: Props) => { | |||||
| <Tab name="編輯"> | <Tab name="編輯"> | ||||
| <PostEditForm | <PostEditForm | ||||
| post={post} | post={post} | ||||
| onSave={newPost => { | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (postsKeys.show (postId), | qc.setQueryData (postsKeys.show (postId), | ||||
| (prev: any) => newPost ?? prev) | |||||
| (prev: Post | undefined) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | qc.invalidateQueries ({ queryKey: postsKeys.root }) | ||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | qc.invalidateQueries ({ queryKey: tagsKeys.root }) | ||||
| }}/> | }}/> | ||||
| @@ -185,4 +184,6 @@ export default (({ user }: Props) => { | |||||
| {post && <TagDetailSidebar post={post} sp/>} | {post && <TagDetailSidebar post={post} sp/>} | ||||
| </div> | </div> | ||||
| </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 dialogue = useDialogue () | ||||
| const location = useLocation () | const location = useLocation () | ||||
| @@ -48,11 +48,11 @@ export default (() => { | |||||
| // 投稿列の結合で使用 | // 投稿列の結合で使用 | ||||
| let rowsCnt: number | 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 ({ | const { data, isLoading: loading } = useQuery ({ | ||||
| queryKey: postsKeys.changes ({ ...(id && { post: id }), | queryKey: postsKeys.changes ({ ...(id && { post: id }), | ||||
| @@ -290,4 +290,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>)} | </>)} | ||||
| </MainArea>) | </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 { 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 { Helmet } from 'react-helmet-async' | ||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| @@ -20,7 +20,7 @@ import type { FC } from 'react' | |||||
| import type { WikiPage } from '@/types' | import type { WikiPage } from '@/types' | ||||
| export default (() => { | |||||
| const PostListPage: FC = () => { | |||||
| const containerRef = useRef<HTMLDivElement | null> (null) | const containerRef = useRef<HTMLDivElement | null> (null) | ||||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | ||||
| @@ -30,7 +30,7 @@ export default (() => { | |||||
| const tagsQuery = query.get ('tags') ?? '' | const tagsQuery = query.get ('tags') ?? '' | ||||
| const anyFlg = query.get ('match') === 'any' | const anyFlg = query.get ('match') === 'any' | ||||
| const match = anyFlg ? 'any' : 'all' | 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 tagsKey = tags.join (' ') | ||||
| const page = Number (query.get ('page') ?? 1) | const page = Number (query.get ('page') ?? 1) | ||||
| const limit = Number (query.get ('limit') ?? 20) | const limit = Number (query.get ('limit') ?? 20) | ||||
| @@ -66,7 +66,7 @@ export default (() => { | |||||
| ; | ; | ||||
| } | } | ||||
| }) () | }) () | ||||
| }, [location.search]) | |||||
| }, [location.search, tags]) | |||||
| return ( | return ( | ||||
| <div | <div | ||||
| @@ -76,7 +76,7 @@ export default (() => { | |||||
| <title> | <title> | ||||
| {tags.length | {tags.length | ||||
| ? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }` | ? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }` | ||||
| : `${ SITE_TITLE } 〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||||
| : `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||||
| </title> | </title> | ||||
| </Helmet> | </Helmet> | ||||
| @@ -112,4 +112,6 @@ export default (() => { | |||||
| </TabGroup> | </TabGroup> | ||||
| </MainArea> | </MainArea> | ||||
| </div>) | </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 { Helmet } from 'react-helmet-async' | ||||
| import { useNavigate } from 'react-router-dom' | import { useNavigate } from 'react-router-dom' | ||||
| @@ -21,9 +21,8 @@ import type { User } from '@/types' | |||||
| type Props = { user: User | null } | 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 () | const navigate = useNavigate () | ||||
| @@ -41,6 +40,7 @@ export default (({ user }: Props) => { | |||||
| const [url, setURL] = useState ('') | const [url, setURL] = useState ('') | ||||
| const previousURLRef = useRef ('') | const previousURLRef = useRef ('') | ||||
| const thumbnailPreviewRef = useRef ('') | |||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const formData = new FormData | 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 = () => { | const handleURLBlur = () => { | ||||
| if (!(url) || url === previousURLRef.current) | if (!(url) || url === previousURLRef.current) | ||||
| return | return | ||||
| @@ -88,20 +78,20 @@ export default (({ user }: Props) => { | |||||
| previousURLRef.current = url | previousURLRef.current = url | ||||
| } | } | ||||
| const fetchTitle = async () => { | |||||
| const fetchTitle = useCallback (async () => { | |||||
| setTitle ('') | setTitle ('') | ||||
| setTitleLoading (true) | setTitleLoading (true) | ||||
| const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | ||||
| setTitle (data.title || '') | setTitle (data.title || '') | ||||
| setTitleLoading (false) | setTitleLoading (false) | ||||
| } | |||||
| }, [url]) | |||||
| const fetchThumbnail = async () => { | |||||
| const fetchThumbnail = useCallback (async () => { | |||||
| setThumbnailPreview ('') | setThumbnailPreview ('') | ||||
| setThumbnailFile (null) | setThumbnailFile (null) | ||||
| setThumbnailLoading (true) | setThumbnailLoading (true) | ||||
| if (thumbnailPreview) | |||||
| URL.revokeObjectURL (thumbnailPreview) | |||||
| if (thumbnailPreviewRef.current) | |||||
| URL.revokeObjectURL (thumbnailPreviewRef.current) | |||||
| const data = await apiGet<Blob> ('/preview/thumbnail', | const data = await apiGet<Blob> ('/preview/thumbnail', | ||||
| { params: { url }, responseType: 'blob' }) | { params: { url }, responseType: 'blob' }) | ||||
| const imageURL = URL.createObjectURL (data) | const imageURL = URL.createObjectURL (data) | ||||
| @@ -110,7 +100,24 @@ export default (({ user }: Props) => { | |||||
| 'thumbnail.png', | 'thumbnail.png', | ||||
| { type: data.type || 'image/png' })) | { type: data.type || 'image/png' })) | ||||
| setThumbnailLoading (false) | 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 ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -207,4 +214,6 @@ export default (({ user }: Props) => { | |||||
| </Button> | </Button> | ||||
| </Form> | </Form> | ||||
| </MainArea>) | </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 location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -96,7 +96,8 @@ export default (() => { | |||||
| setUpdatedTo (qUpdatedTo) | setUpdatedTo (qUpdatedTo) | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | |||||
| }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, | |||||
| qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) | |||||
| const search = async () => { | const search = async () => { | ||||
| const qs = new URLSearchParams () | const qs = new URLSearchParams () | ||||
| @@ -336,4 +337,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </div>) : '結果ないよ(笑)')} | </div>) : '結果ないよ(笑)')} | ||||
| </MainArea>) | </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 { Helmet } from 'react-helmet-async' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -14,7 +16,7 @@ import type { NicoTag, Tag, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default ({ user }: Props) => { | |||||
| const NicoTagListPage: FC<Props> = ({ user }) => { | |||||
| const [cursor, setCursor] = useState ('') | const [cursor, setCursor] = useState ('') | ||||
| const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | ||||
| const [loading, setLoading] = useState (false) | const [loading, setLoading] = useState (false) | ||||
| @@ -25,12 +27,8 @@ export default ({ user }: Props) => { | |||||
| const memberFlg = ['admin', 'member'].some (r => user?.role === r) | 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]) | setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | ||||
| setCursor (data.nextCursor) | setCursor (data.nextCursor) | ||||
| @@ -40,9 +38,26 @@ export default ({ user }: Props) => { | |||||
| const newRawTags = Object.fromEntries ( | const newRawTags = Object.fromEntries ( | ||||
| data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) | data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) | ||||
| setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) | 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) | 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) => { | const handleEdit = async (id: number) => { | ||||
| if (editing[id]) | if (editing[id]) | ||||
| @@ -67,7 +82,7 @@ export default ({ user }: Props) => { | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const observer = new IntersectionObserver (entries => { | const observer = new IntersectionObserver (entries => { | ||||
| if (entries[0].isIntersecting && !(loading) && cursor) | if (entries[0].isIntersecting && !(loading) && cursor) | ||||
| loadMore (true) | |||||
| loadMore () | |||||
| }, { threshold: 1 }) | }, { threshold: 1 }) | ||||
| const target = loaderRef.current | const target = loaderRef.current | ||||
| @@ -78,12 +93,12 @@ export default ({ user }: Props) => { | |||||
| if (target) | if (target) | ||||
| observer.unobserve (target) | observer.unobserve (target) | ||||
| } | } | ||||
| }, [loaderRef, loading]) | |||||
| }, [cursor, loadMore, loading]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setNicoTags ([]) | setNicoTags ([]) | ||||
| loadMore (false) | |||||
| }, []) | |||||
| loadInitial () | |||||
| }, [loadInitial]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -147,3 +162,5 @@ export default ({ user }: Props) => { | |||||
| </div> | </div> | ||||
| </MainArea>) | </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' | import type { Category, Tag } from '@/types' | ||||
| export default (() => { | |||||
| const TagDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const tagId = String (id ?? '') | const tagId = String (id ?? '') | ||||
| const tagKey = tagsKeys.show (tagId) | const tagKey = tagsKeys.show (tagId) | ||||
| @@ -155,4 +155,6 @@ export default (() => { | |||||
| </form> | </form> | ||||
| </div>)} | </div>)} | ||||
| </MainArea>) | </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 location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| @@ -209,4 +209,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>)} | </>)} | ||||
| </MainArea>) | </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 location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -87,7 +87,8 @@ export default (() => { | |||||
| setUpdatedTo (qUpdatedTo) | setUpdatedTo (qUpdatedTo) | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | |||||
| }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, | |||||
| qPostCountLTE, qUpdatedFrom, qUpdatedTo]) | |||||
| const handleSearch = (e: FormEvent) => { | const handleSearch = (e: FormEvent) => { | ||||
| e.preventDefault () | e.preventDefault () | ||||
| @@ -296,4 +297,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </div>) : '結果ないよ(笑)')} | </div>) : '結果ないよ(笑)')} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TagListPage | |||||
| @@ -9,7 +9,7 @@ import TagDetailSidebar from '@/components/TagDetailSidebar' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { SITE_TITLE } from '@/config' | 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 { fetchPost } from '@/lib/posts' | ||||
| import { dateString } from '@/lib/utils' | import { dateString } from '@/lib/utils' | ||||
| @@ -34,11 +34,12 @@ const INITIAL_THEATRE_INFO = | |||||
| watchingUsers: [] as { id: number; name: string }[] } as const | watchingUsers: [] as { id: number; name: string }[] } as const | ||||
| export default (() => { | |||||
| const TheatreDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const commentsRef = useRef<HTMLDivElement> (null) | const commentsRef = useRef<HTMLDivElement> (null) | ||||
| const embedRef = useRef<NiconicoViewerHandle> (null) | const embedRef = useRef<NiconicoViewerHandle> (null) | ||||
| const loadingRef = useRef (false) | |||||
| const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | ||||
| const videoLengthRef = useRef (0) | const videoLengthRef = useRef (0) | ||||
| const lastCommentNoRef = useRef (0) | const lastCommentNoRef = useRef (0) | ||||
| @@ -53,6 +54,10 @@ export default (() => { | |||||
| const [post, setPost] = useState<Post | null> (null) | const [post, setPost] = useState<Post | null> (null) | ||||
| const [videoLength, setVideoLength] = useState (0) | const [videoLength, setVideoLength] = useState (0) | ||||
| useEffect (() => { | |||||
| loadingRef.current = loading | |||||
| }, [loading]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| theatreInfoRef.current = theatreInfo | theatreInfoRef.current = theatreInfo | ||||
| }, [theatreInfo]) | }, [theatreInfo]) | ||||
| @@ -87,7 +92,7 @@ export default (() => { | |||||
| } | } | ||||
| catch (error) | catch (error) | ||||
| { | { | ||||
| setStatus ((error as any)?.response.status ?? 200) | |||||
| setStatus (isApiError (error) ? error.response?.status ?? 200 : 200) | |||||
| } | } | ||||
| }) () | }) () | ||||
| @@ -160,7 +165,7 @@ export default (() => { | |||||
| }, [id]) | }, [id]) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) | |||||
| if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) | |||||
| return | return | ||||
| let cancelled = false | let cancelled = false | ||||
| @@ -338,4 +343,6 @@ export default (() => { | |||||
| {post && <TagDetailSidebar post={post} sp/>} | {post && <TagDetailSidebar post={post} sp/>} | ||||
| </div> | </div> | ||||
| </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 { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -18,7 +20,7 @@ type Props = { user: User | null | |||||
| setUser: React.Dispatch<React.SetStateAction<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 [name, setName] = useState ('') | ||||
| const [userCodeVsbl, setUserCodeVsbl] = useState (false) | const [userCodeVsbl, setUserCodeVsbl] = useState (false) | ||||
| const [inheritVsbl, setInheritVsbl] = useState (false) | const [inheritVsbl, setInheritVsbl] = useState (false) | ||||
| @@ -110,3 +112,5 @@ export default ({ user, setUser }: Props) => { | |||||
| setUser={setUser}/> | setUser={setUser}/> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default SettingPage | |||||