diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 54e6892..ce63840 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -1,26 +1,69 @@ class NicoTagsController < ApplicationController def index - limit = (params[:limit] || 20).to_i - cursor = params[:cursor].presence + name = params[:name].presence + linked_tag = params[:linked_tag].presence + link_status = params[:link_status].presence + order = params[:order].to_s.split(':', 2).map(&:strip) + order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at']) + unless order[1].in?(['asc', 'desc']) + order[1] = order[0] == 'name' ? 'asc' : 'desc' + end + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + post_tag_max_sql = + PostTag + .select('tag_id, MAX(created_at) AS max_created_at') + .group('tag_id') + .to_sql q = Tag.nico_tags + .joins(:tag_name) + .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ + 'ON post_tag_max.tag_id = tags.id') .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) - .order(updated_at: :desc) - q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor - - tags = q.limit(limit + 1).to_a - - next_cursor = nil - if tags.size > limit - next_cursor = tags.last.updated_at.iso8601(6) - tags = tags.first(limit) + q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name + if linked_tag + linked_tag_ids = + Tag + .joins(:tag_name) + .where('tag_names.name LIKE ?', "%#{ linked_tag }%") + .pluck(:id) + linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) + q = q.where(id: linked_nico_tag_ids) + end + if link_status.in?(['linked', 'unlinked']) + exists_sql = + 'EXISTS (SELECT 1 FROM nico_tag_relations ' \ + 'WHERE nico_tag_relations.nico_tag_id = tags.id)' + q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") end + count = q.count + sort_sql = + case order[0] + when 'name' + 'tag_names.name' + when 'updated_at' + 'post_tag_max.max_created_at' + else + "tags.#{ order[0] }" + end + tags = q.reselect('tags.*', + Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at')) + .order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) + .limit(limit) + .offset((page - 1) * limit) + .to_a + render json: { tags: tags.map { |tag| - TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt| - TagRepr.base(lt) - }) - }, next_cursor: } + TagRepr.base(tag).merge( + recent_post_tag_created_at: tag.recent_post_tag_created_at, + linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) + }, count: } end def update diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 5ad80d5..b9ac6c6 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -3,12 +3,68 @@ require 'rails_helper' RSpec.describe 'NicoTags', type: :request do describe 'GET /tags/nico' do - it 'returns tags and next_cursor when overflowing limit' do - create_list(:tag, 21, :nico) - get '/tags/nico', params: { limit: 20 } + it 'returns paginated tags and total count' do + create_list(:tag, 3, :nico) + + get '/tags/nico', params: { page: 2, limit: 2 } + expect(response).to have_http_status(:ok) - expect(json['tags'].size).to eq(20) - expect(json['next_cursor']).to be_present + expect(json['tags'].size).to eq(1) + expect(json['count']).to eq(3) + end + + it 'filters by nico tag name, linked tag name, and link status' do + linked = create(:tag, :nico) + linked.tag_name.update!(name: 'nico:search_linked') + unlinked = create(:tag, :nico) + unlinked.tag_name.update!(name: 'nico:search_unlinked') + other = create(:tag, :nico) + other.tag_name.update!(name: 'nico:other') + destination = create(:tag, :general) + destination.tag_name.update!(name: 'destination_search') + NicoTagRelation.create!(nico_tag: linked, tag: destination) + NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general)) + + get '/tags/nico', params: { + name: 'search_', + linked_tag: 'destination_', + link_status: 'linked' + } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(1) + expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id]) + + get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' } + + expect(json['count']).to eq(1) + expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id]) + end + + it 'sorts by name and timestamps' do + older = create(:tag, :nico) + older.tag_name.update!(name: 'nico:a') + older.update_columns(created_at: 2.days.ago) + newer = create(:tag, :nico) + newer.tag_name.update!(name: 'nico:b') + newer.update_columns(created_at: 1.day.ago) + older_post_tag = + PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older) + older_post_tag.update_columns(created_at: 1.hour.ago) + newer_post_tag = + PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer) + newer_post_tag.update_columns(created_at: 2.hours.ago) + + get '/tags/nico', params: { order: 'name:desc' } + expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id]) + + get '/tags/nico', params: { order: 'created_at:asc' } + expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) + + get '/tags/nico', params: { order: 'updated_at:desc' } + expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) + expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at'))) + .to be_within(1.second).of(older_post_tag.created_at) end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2eeaf79..784fbf8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,6 +62,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }> @@ -158,4 +159,4 @@ const App: FC = () => { ) } -export default App \ No newline at end of file +export default App diff --git a/frontend/src/lib/prefetchers.test.ts b/frontend/src/lib/prefetchers.test.ts index b33cb65..8a341a3 100644 --- a/frontend/src/lib/prefetchers.test.ts +++ b/frontend/src/lib/prefetchers.test.ts @@ -10,6 +10,7 @@ const postsApi = vi.hoisted (() => ({ })) const tagsApi = vi.hoisted (() => ({ + fetchNicoTags: vi.fn (), fetchTag: vi.fn (), fetchTagByName: vi.fn (), fetchTagChanges: vi.fn (), @@ -37,6 +38,7 @@ describe ('prefetchForURL', () => { postsApi.fetchPost.mockResolvedValue ({ id: 1 }) postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 }) tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) + tagsApi.fetchNicoTags.mockResolvedValue ({ tags: [], count: 0 }) tagsApi.fetchTag.mockResolvedValue ({ id: 1 }) tagsApi.fetchTagByName.mockResolvedValue (null) tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 }) @@ -85,6 +87,32 @@ describe ('prefetchForURL', () => { ) }) + it ('prefetches nico tag indexes and their alias from query parameters', async () => { + await prefetchForURL ( + qc (), + 'http://localhost/tags/nico?name=source&linked_tag=destination' + + '&link_status=linked&page=3&limit=10', + ) + await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2') + + expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, { + name: 'source', + linkedTag: 'destination', + linkStatus: 'linked', + page: 3, + limit: 10, + order: 'updated_at:desc', + }) + expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, { + name: '', + linkedTag: '', + linkStatus: 'all', + page: 2, + limit: 20, + order: 'updated_at:desc', + }) + }) + it ('prefetches wiki show pages and related tag/post data', async () => { wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({ id: 3, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 5dc9d70..38f51cc 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' +import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' @@ -170,6 +170,24 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { } +const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => { + const keys = { + name: url.searchParams.get ('name') ?? '', + linkedTag: url.searchParams.get ('linked_tag') ?? '', + linkStatus: (url.searchParams.get ('link_status') || 'all') as + 'all' | 'linked' | 'unlinked', + page: Number (url.searchParams.get ('page') || 1), + limit: Number (url.searchParams.get ('limit') || 20), + order: (url.searchParams.get ('order') || 'updated_at:desc') as + 'name:asc' | 'name:desc' | 'created_at:asc' | 'created_at:desc' + | 'updated_at:asc' | 'updated_at:desc' } + + await qc.prefetchQuery ({ + queryKey: tagsKeys.nicoIndex (keys), + queryFn: () => fetchNicoTags (keys) }) +} + + const prefetchTagShow: Prefetcher = async (qc, url) => { const m = mTag (url.pathname) if (!(m)) @@ -206,6 +224,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] && Boolean (mWiki (u.pathname))), run: prefetchWikiPageShow }, { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, + { test: u => ['/tags/nico', '/nico/tags'].includes (u.pathname), + run: prefetchNicoTagsIndex }, { test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname)) && Boolean (mTag (u.pathname))), run: prefetchTagShow }, diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 6ac3f21..b7dff2b 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,4 +1,4 @@ -import type { FetchPostsParams, FetchTagsParams } from '@/types' +import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types' export const postsKeys = { root: ['posts'] as const, @@ -11,6 +11,8 @@ export const postsKeys = { export const tagsKeys = { root: ['tags'] as const, index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + nicoRoot: ['tags', 'nico'] as const, + nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const, show: (name: string) => ['tags', name] as const, changes: (p: { id?: string; page: number; limit: number }) => ['tags', 'changes', p] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 74eba45..9ac8788 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,11 @@ import { apiGet } from '@/lib/api' -import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' +import type { Deerjikist, + FetchNicoTagsParams, + FetchTagsParams, + NicoTag, + Tag, + TagVersion } from '@/types' export const fetchTags = async ( @@ -23,6 +28,19 @@ export const fetchTags = async ( ...(order && { order }) } }) +export const fetchNicoTags = async ( + { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams, +): Promise<{ tags: NicoTag[] + count: number }> => + await apiGet ('/tags/nico', { params: { + page, + limit, + name, + linked_tag: linkedTag, + link_status: linkStatus === 'all' ? '' : linkStatus, + order } }) + + export const fetchTag = async (id: string): Promise => { try { diff --git a/frontend/src/pages/tags/NicoTagListPage.test.tsx b/frontend/src/pages/tags/NicoTagListPage.test.tsx new file mode 100644 index 0000000..c5efcd3 --- /dev/null +++ b/frontend/src/pages/tags/NicoTagListPage.test.tsx @@ -0,0 +1,273 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import NicoTagListPage from '@/pages/tags/NicoTagListPage' +import { dateString } from '@/lib/utils' +import { buildTag, buildUser } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +import type { NicoTag } from '@/types' + +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), + apiPut: vi.fn (), + isApiError: vi.fn (), +})) + +const toastApi = vi.hoisted (() => ({ + toast: vi.fn (), +})) + +const dialogue = vi.hoisted (() => ({ + confirm: vi.fn (), +})) + +const scrollIntoView = vi.fn () + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => dialogue, +})) + +const buildNicoTag = (values: Partial = {}): NicoTag => ({ + ...buildTag (), + ...values, + category: 'nico', + linkedTags: values.linkedTags ?? [], + recentPostTagCreatedAt: values.recentPostTagCreatedAt ?? null, +}) + +const renderPage = (route = '/tags/nico') => + renderWithProviders ( + , + { route }, + ) + +describe ('NicoTagListPage', () => { + beforeEach (() => { + vi.clearAllMocks () + api.isApiError.mockReturnValue (false) + dialogue.confirm.mockResolvedValue (true) + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear () + }) + + it ('loads a filtered page from URL search parameters', async () => { + api.apiGet.mockResolvedValue ({ + tags: [buildNicoTag ({ + id: 1, + name: 'nico:linked', + createdAt: '2024-01-02T03:04:05Z', + recentPostTagCreatedAt: '2025-01-02T03:04:05Z', + updatedAt: '2026-01-02T03:04:05Z', + })], + count: 21, + }) + + renderPage ( + '/tags/nico?name=linked&linked_tag=destination&link_status=linked' + + '&page=2&order=name:asc', + ) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/nico', + { params: { + page: 2, + limit: 20, + name: 'linked', + linked_tag: 'destination', + link_status: 'linked', + order: 'name:asc', + } }, + ) + }) + expect (await screen.findByText ('21 件')).toBeInTheDocument () + expect (screen.getByLabelText ('前のページ')).toBeInTheDocument () + expect (screen.queryByText ('なし')).not.toBeInTheDocument () + expect (screen.getByText (dateString ('2025-01-02T03:04:05Z'))).toBeInTheDocument () + expect (screen.queryByText (dateString ('2026-01-02T03:04:05Z'))).not.toBeInTheDocument () + expect (screen.getByRole ('link', { name: 'ニコニコタグ ▲' })).toHaveAttribute ( + 'href', + expect.stringContaining ('order=name%3Adesc'), + ) + expect (screen.getByRole ('link', { name: '最初に記載された日時' })).toHaveAttribute ( + 'href', + expect.stringContaining ('order=created_at%3Adesc'), + ) + expect (screen.getByRole ('link', { name: '最近記載された日時' })).toHaveAttribute ( + 'href', + expect.stringContaining ('order=updated_at%3Adesc'), + ) + + fireEvent.mouseEnter (screen.getByLabelText ('前のページ')) + await waitFor (() => { + expect (api.apiGet).toHaveBeenLastCalledWith ( + '/tags/nico', + { params: { + page: 1, + limit: 20, + name: 'linked', + linked_tag: 'destination', + link_status: 'linked', + order: 'name:asc', + } }, + ) + }) + }) + + it ('scrolls to the table when moving between pages', async () => { + api.apiGet.mockResolvedValue ({ + tags: [buildNicoTag ({ id: 1, name: 'nico:linked' })], + count: 21, + }) + + renderPage () + + fireEvent.click (await screen.findByLabelText ('次のページ')) + + await waitFor (() => { + expect (scrollIntoView).toHaveBeenCalledWith ({ behavior: 'smooth' }) + }) + }) + + it ('navigates with submitted search conditions', async () => { + api.apiGet.mockResolvedValue ({ tags: [], count: 0 }) + renderPage () + + fireEvent.change (screen.getByLabelText ('ニコニコタグ'), { + target: { value: 'source' }, + }) + fireEvent.change (screen.getByLabelText ('連携タグ'), { + target: { value: 'destination' }, + }) + fireEvent.change (screen.getByLabelText ('連携状態'), { + target: { value: 'unlinked' }, + }) + fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenLastCalledWith ( + '/tags/nico', + { params: expect.objectContaining ({ + page: 1, + name: 'source', + linked_tag: 'destination', + link_status: 'unlinked', + }) }, + ) + }) + }) + + it ('updates links from a tag card', async () => { + api.apiGet + .mockResolvedValueOnce ({ + tags: [buildNicoTag ({ id: 7, name: 'nico:source' })], + count: 1, + }) + .mockResolvedValueOnce ({ + tags: [ + buildNicoTag ({ + id: 7, + name: 'nico:source', + linkedTags: [buildTag ({ id: 8, name: '連携先' })], + }), + ], + count: 1, + }) + api.apiPut.mockResolvedValueOnce ([buildTag ({ id: 8, name: '連携先' })]) + + renderPage () + + fireEvent.click (await screen.findByRole ('button', { name: '編集' })) + fireEvent.change (screen.getByLabelText ('連携する広場タグ'), { + target: { value: '連携先' }, + }) + fireEvent.click (screen.getByRole ('button', { name: '保存' })) + + await waitFor (() => { + expect (api.apiPut).toHaveBeenCalledWith ( + '/tags/nico/7', + expect.any (FormData), + { headers: { 'Content-Type': 'multipart/form-data' } }, + ) + }) + expect (await screen.findByText ('1 件')).toBeInTheDocument () + }) + + it ('asks before discarding changes when editing another tag', async () => { + api.apiGet.mockResolvedValueOnce ({ + tags: [ + buildNicoTag ({ id: 1, name: 'nico:first' }), + buildNicoTag ({ id: 2, name: 'nico:second' }), + ], + count: 2, + }) + + renderPage () + dialogue.confirm.mockResolvedValueOnce (false) + + const editButtons = await screen.findAllByRole ('button', { name: '編集' }) + fireEvent.click (editButtons[0]) + fireEvent.change (screen.getByLabelText ('連携する広場タグ'), { + target: { value: '入力中' }, + }) + fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0]) + + await waitFor (() => { + expect (dialogue.confirm).toHaveBeenCalledWith ({ + title: '編集中の内容を破棄しますか?', + confirmText: '破棄', + variant: 'danger', + }) + }) + expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1) + expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中') + }) + + it ('switches editing rows without confirmation when unchanged', async () => { + api.apiGet.mockResolvedValueOnce ({ + tags: [ + buildNicoTag ({ id: 1, name: 'nico:first' }), + buildNicoTag ({ id: 2, name: 'nico:second' }), + ], + count: 2, + }) + + renderPage () + + const editButtons = await screen.findAllByRole ('button', { name: '編集' }) + fireEvent.click (editButtons[0]) + fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0]) + + expect (dialogue.confirm).not.toHaveBeenCalled () + expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1) + }) + + it ('shows tags field validation errors inside the edited card', async () => { + api.apiGet.mockResolvedValueOnce ({ + tags: [buildNicoTag ({ id: 7, name: 'nico:source' })], + count: 1, + }) + api.isApiError.mockReturnValue (true) + api.apiPut.mockRejectedValueOnce ({ + response: { + status: 422, + data: { + type: 'validation_error', + errors: { tags: ['タグ名を確認してください.'] }, + base_errors: [], + }, + }, + }) + + renderPage () + + fireEvent.click (await screen.findByRole ('button', { name: '編集' })) + fireEvent.click (screen.getByRole ('button', { name: '保存' })) + + expect (await screen.findByText ('タグ名を確認してください.')).toBeInTheDocument () + expect (screen.getByLabelText ('連携する広場タグ')).toHaveAttribute ('aria-invalid', 'true') + }) +}) diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx index 4c10fe0..84d5cdd 100644 --- a/frontend/src/pages/tags/NicoTagListPage.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.tsx @@ -1,120 +1,179 @@ -import type { FC } from 'react' - -import { useCallback, useEffect, useRef, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, LoaderCircle, Pencil, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' +import { useLocation, useNavigate } from 'react-router-dom' import TagLink from '@/components/TagLink' +import SortHeader from '@/components/SortHeader' import FieldError from '@/components/common/FieldError' +import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' import TextArea from '@/components/common/TextArea' +import { useDialogue } from '@/components/dialogues/DialogueProvider' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiGet, apiPut } from '@/lib/api' +import { apiPut } from '@/lib/api' import { extractValidationError } from '@/lib/apiErrors' +import { tagsKeys } from '@/lib/queryKeys' +import { fetchNicoTags } from '@/lib/tags' +import { cn, dateString, inputClass } from '@/lib/utils' import { canEditContent } from '@/lib/users' -import type { NicoTag, Tag, User } from '@/types' +import type { FC, FormEvent } from 'react' +import type { FetchNicoTagsOrder, FetchNicoTagsOrderField, NicoTag, Tag, User } from '@/types' + +type LinkStatus = 'all' | 'linked' | 'unlinked' type Props = { user: User | null } -const NicoTagListPage: FC = ({ user }) => { - const [cursor, setCursor] = useState ('') - const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) - const [errorsByTagId, setErrorsByTagId] = useState> ({ }) - const [loading, setLoading] = useState (false) - const [nicoTags, setNicoTags] = useState ([]) - const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ }) +const setIf = (qs: URLSearchParams, key: string, value: string) => { + const trimmed = value.trim () + if (trimmed) + qs.set (key, trimmed) +} - const loaderRef = useRef (null) + +const NicoTagListPage: FC = ({ user }) => { + const dialogue = useDialogue () + const location = useLocation () + const navigate = useNavigate () + const queryClient = useQueryClient () + const query = useMemo (() => new URLSearchParams (location.search), [location.search]) + + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + const qName = query.get ('name') ?? '' + const qLinkedTag = query.get ('linked_tag') ?? '' + const qLinkStatus = (query.get ('link_status') || 'all') as LinkStatus + const order = (query.get ('order') || 'updated_at:desc') as FetchNicoTagsOrder + + const [editingId, setEditingId] = useState (null) + const [errorsByTagId, setErrorsByTagId] = useState> ({ }) + const [linkStatus, setLinkStatus] = useState ('all') + const [linkedTag, setLinkedTag] = useState ('') + const [name, setName] = useState ('') + const [rawTags, setRawTags] = useState> ({ }) + const [savingId, setSavingId] = useState (null) + + const keys = { + name: qName, linkedTag: qLinkedTag, linkStatus: qLinkStatus, page, limit, order } + const { data, isError, isLoading: loading } = useQuery ({ + queryKey: tagsKeys.nicoIndex (keys), + queryFn: () => fetchNicoTags (keys) }) + const nicoTags = data?.tags ?? [] + const count = data?.count ?? 0 const editable = canEditContent (user) + const totalPages = Math.ceil (count / limit) - const applyLoadedTags = useCallback ((data: { tags: NicoTag[]; nextCursor: string }, - withCursor: boolean) => { - setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) - setCursor (data.nextCursor) + const handleSearch = (e: FormEvent) => { + e.preventDefault () - const newEditing = Object.fromEntries (data.tags.map (t => [t.id, false])) - setEditing (editing => ({ ...editing, ...newEditing })) - - const newRawTags = Object.fromEntries ( - data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) - setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) - }, []) - - const loadInitial = useCallback (async () => { - setLoading (true) - - const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ('/tags/nico') - applyLoadedTags (data, false) - - setLoading (false) - }, [applyLoadedTags]) - - const loadMore = useCallback (async () => { - setLoading (true) - - const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ( - '/tags/nico', { params: { cursor } }) - applyLoadedTags (data, true) - - setLoading (false) - }, [applyLoadedTags, cursor]) - - const handleEdit = async (id: number) => { - if (editing[id]) - { - const formData = new FormData - formData.append ('tags', rawTags[id]) - - try - { - const data = await apiPut (`/tags/nico/${ id }`, formData, - { headers: { 'Content-Type': 'multipart/form-data' } }) - setNicoTags (nicoTags => { - nicoTags.find (t => t.id === id)!.linkedTags = data - return [...nicoTags] - }) - setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') })) - setErrorsByTagId (errors => ({ ...errors, [id]: [] })) - - toast ({ title: '更新しました.' }) - } - catch (e) - { - const validationError = extractValidationError<'tags'> (e) - setErrorsByTagId (errors => ({ ...errors, - [id]: validationError?.fieldErrors.tags ?? [] })) - toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) - return - } - } - - setEditing (editing => ({ ...editing, [id]: !(editing[id]) })) + const qs = new URLSearchParams () + setIf (qs, 'name', name) + setIf (qs, 'linked_tag', linkedTag) + if (linkStatus !== 'all') + qs.set ('link_status', linkStatus) + qs.set ('page', '1') + qs.set ('limit', String (limit)) + qs.set ('order', order) + navigate (`${ location.pathname }?${ qs.toString () }`) } - useEffect(() => { - const observer = new IntersectionObserver (entries => { - if (entries[0].isIntersecting && !(loading) && cursor) - loadMore () - }, { threshold: 1 }) + const defaultDirection = { + name: 'asc', + created_at: 'desc', + updated_at: 'desc', + } as const - const target = loaderRef.current - if (target) - observer.observe (target) + const beginEdit = async (tag: NicoTag) => { + const editingTag = nicoTags.find (tag => tag.id === editingId) + const editingValue = editingTag?.linkedTags.map (tag => tag.name).join (' ') ?? '' + const editingChanged = editingId != null && rawTags[editingId] !== editingValue - return () => { - if (target) - observer.unobserve (target) + if (editingId != null && editingId !== tag.id && editingChanged + && !(await dialogue.confirm ({ + title: '編集中の内容を破棄しますか?', + confirmText: '破棄', + variant: 'danger', + }))) + return + + setEditingId (tag.id) + setRawTags (rawTags => ({ + ...rawTags, + [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), + })) + setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) + } + + const cancelEdit = (tag: NicoTag) => { + setEditingId (null) + setRawTags (rawTags => ({ + ...rawTags, + [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), + })) + setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) + } + + const saveLinks = async (id: number) => { + const formData = new FormData + formData.append ('tags', rawTags[id] ?? '') + setSavingId (id) + + try + { + await apiPut (`/tags/nico/${ id }`, formData, + { headers: { 'Content-Type': 'multipart/form-data' } }) + setErrorsByTagId (errors => ({ ...errors, [id]: [] })) + setEditingId (null) + await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot }) + toast ({ description: '連携を更新しました.' }) } - }, [cursor, loadMore, loading]) + catch (e) + { + const validationError = extractValidationError<'tags'> (e) + setErrorsByTagId (errors => ({ + ...errors, + [id]: validationError?.fieldErrors.tags + ?? validationError?.baseErrors + ?? ['更新できませんでした.'], + })) + toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) + } + finally + { + setSavingId (null) + } + } useEffect (() => { - setNicoTags ([]) - loadInitial () - }, [loadInitial]) + setName (qName) + setLinkedTag (qLinkedTag) + setLinkStatus (qLinkStatus) + setEditingId (null) + + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search, qLinkedTag, qLinkStatus, qName]) + + useEffect (() => { + if (!(data)) + return + + setRawTags (Object.fromEntries (data.tags.map (tag => [ + tag.id, + tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), + ]))) + }, [data]) + + useEffect (() => { + if (isError) + toast ({ title: '読込失敗', description: 'ニコニコ連携を読み込めませんでした.' }) + }, [isError]) return ( @@ -124,66 +183,201 @@ const NicoTagListPage: FC = ({ user }) => {
ニコニコ連携 +

+ ニコニコタグを広場のタグへ結び付けます. +

+ +
+ + {({ invalid }) => ( + setName (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + setLinkedTag (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + )} + + +
+ +
+
-
- {nicoTags.length > 0 && ( - - - - - - {editable && } - - - - {nicoTags.map ((tag, i) => ( - - -
ニコニコタグ連携タグ
- - - {editing[tag.id] - ? ( - <> -