From f83b6c90d773744ec93e2f353005688e8d8b7582 Mon Sep 17 00:00:00 2001 From: ahfoysal Date: Mon, 6 Jul 2026 12:07:28 +0600 Subject: [PATCH] feat(tree): add rename action dialog for tree nodes --- .../features/page/RenamePageDialog.test.tsx | 164 +++++++++++ .../src/features/page/RenamePageDialog.tsx | 273 ++++++++++++++++++ .../src/features/tree/TreeNodeActionsMenu.tsx | 20 ++ ui/leafwiki-ui/src/lib/registries/index.tsx | 14 + .../src/lib/registries/lazy-dialogs.tsx | 5 + 5 files changed, 476 insertions(+) create mode 100644 ui/leafwiki-ui/src/features/page/RenamePageDialog.test.tsx create mode 100644 ui/leafwiki-ui/src/features/page/RenamePageDialog.tsx diff --git a/ui/leafwiki-ui/src/features/page/RenamePageDialog.test.tsx b/ui/leafwiki-ui/src/features/page/RenamePageDialog.test.tsx new file mode 100644 index 000000000..6a19ae151 --- /dev/null +++ b/ui/leafwiki-ui/src/features/page/RenamePageDialog.test.tsx @@ -0,0 +1,164 @@ +import { DIALOG_RENAME_PAGE } from '@/lib/registries' +import { useConfigStore } from '@/stores/config' +import { useDialogsStore } from '@/stores/dialogs' +import { useTreeStore } from '@/stores/tree' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RenamePageDialog } from './RenamePageDialog' + +vi.mock('@/lib/api/pages', () => ({ + NODE_KIND_PAGE: 'page', + applyPageRefactor: vi.fn(), + getPageByPath: vi.fn(), + previewPageRefactor: vi.fn(), + updatePage: vi.fn(), +})) + +vi.mock('@/features/page/pageRefactorDialogState', () => ({ + confirmPageRefactor: vi.fn(), +})) + +vi.mock('@/features/page/pageMutationRefresh', () => ({ + refreshAfterPageRefactor: vi.fn(), +})) + +import { + applyPageRefactor, + getPageByPath, + previewPageRefactor, + updatePage, +} from '@/lib/api/pages' +import { confirmPageRefactor } from '@/features/page/pageRefactorDialogState' +import { refreshAfterPageRefactor } from '@/features/page/pageMutationRefresh' + +const mockApplyPageRefactor = applyPageRefactor as ReturnType +const mockGetPageByPath = getPageByPath as ReturnType +const mockPreviewPageRefactor = previewPageRefactor as ReturnType +const mockUpdatePage = updatePage as ReturnType +const mockConfirmPageRefactor = confirmPageRefactor as ReturnType +const mockRefreshAfterPageRefactor = refreshAfterPageRefactor as ReturnType< + typeof vi.fn +> + +const samplePage = { + id: 'page-1', + kind: 'page' as const, + title: 'Original title', + slug: 'original-title', + path: '/original-title', + version: 'v1', +} + +describe('RenamePageDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + useConfigStore.setState({ + enableLinkRefactor: false, + }) + useTreeStore.setState({ + reloadTree: vi.fn().mockResolvedValue(undefined), + } as never) + useDialogsStore.setState({ + dialogType: DIALOG_RENAME_PAGE, + dialogProps: null, + }) + mockGetPageByPath.mockResolvedValue({ + content: 'hello', + tags: [], + properties: {}, + }) + mockUpdatePage.mockResolvedValue({ ...samplePage }) + mockApplyPageRefactor.mockResolvedValue({ ...samplePage }) + mockPreviewPageRefactor.mockResolvedValue({ + kind: 'rename', + pageId: 'page-1', + oldPath: '/original-title', + newPath: '/renamed-title', + affectedPages: [], + counts: { + affectedPages: 0, + matchedLinks: 0, + }, + warnings: [], + }) + mockConfirmPageRefactor.mockResolvedValue(false) + mockRefreshAfterPageRefactor.mockResolvedValue(undefined) + }) + + it('updates the page title and slug directly when link refactor is disabled', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.clear(screen.getByTestId('rename-page-title-input')) + await user.type(screen.getByTestId('rename-page-title-input'), 'Renamed title') + await user.clear(screen.getByTestId('rename-page-slug-input')) + await user.type(screen.getByTestId('rename-page-slug-input'), 'renamed-title') + + await user.click( + screen.getByTestId('rename-page-dialog-button-confirm'), + ) + + expect(mockGetPageByPath).toHaveBeenCalledWith('original-title') + expect(mockUpdatePage).toHaveBeenCalledWith( + 'page-1', + 'v1', + 'Renamed title', + 'renamed-title', + 'hello', + [], + {}, + ) + expect(mockApplyPageRefactor).not.toHaveBeenCalled() + expect(mockRefreshAfterPageRefactor).toHaveBeenCalled() + }) + + it('uses page refactor flow when link refactor is enabled', async () => { + useConfigStore.setState({ + enableLinkRefactor: true, + }) + mockConfirmPageRefactor.mockResolvedValue(true) + + const user = userEvent.setup() + + render( + + + , + ) + + await user.clear(screen.getByTestId('rename-page-title-input')) + await user.type(screen.getByTestId('rename-page-title-input'), 'Renamed title') + await user.clear(screen.getByTestId('rename-page-slug-input')) + await user.type(screen.getByTestId('rename-page-slug-input'), 'renamed-title') + + await user.click( + screen.getByTestId('rename-page-dialog-button-confirm'), + ) + + expect(mockPreviewPageRefactor).toHaveBeenCalledWith('page-1', { + kind: 'rename', + title: 'Renamed title', + slug: 'renamed-title', + }) + expect(mockApplyPageRefactor).toHaveBeenCalledWith('page-1', { + kind: 'rename', + version: 'v1', + title: 'Renamed title', + slug: 'renamed-title', + content: 'hello', + rewriteLinks: true, + }) + expect(mockUpdatePage).not.toHaveBeenCalled() + }) +}) diff --git a/ui/leafwiki-ui/src/features/page/RenamePageDialog.tsx b/ui/leafwiki-ui/src/features/page/RenamePageDialog.tsx new file mode 100644 index 000000000..8c4f107cd --- /dev/null +++ b/ui/leafwiki-ui/src/features/page/RenamePageDialog.tsx @@ -0,0 +1,273 @@ +import BaseDialog, { BaseDialogConfirmButton } from '@/components/BaseDialog' +import { FormInput } from '@/components/FormInput' +import { Button } from '@/components/ui/button' +import { handleFieldErrors } from '@/lib/handleFieldErrors' +import { + applyPageRefactor, + getPageByPath, + NODE_KIND_PAGE, + PageNode, + PageRefactorPreview, + previewPageRefactor, + updatePage, +} from '@/lib/api/pages' +import { DIALOG_RENAME_PAGE } from '@/lib/registries' +import { useConfigStore } from '@/stores/config' +import { useTreeStore } from '@/stores/tree' +import { CalendarDays } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { SlugInputWithSuggestion } from './SlugInputWithSuggestion' +import { confirmPageRefactor } from './pageRefactorDialogState' +import { refreshAfterPageRefactor } from './pageMutationRefresh' + +type RenamePageDialogProps = { + page: Pick< + PageNode, + 'id' | 'title' | 'slug' | 'path' | 'version' | 'parentId' | 'kind' + > +} + +const DIALOG_INPUT_ALLOWED_HOTKEYS = 'Enter' + +function getSyntheticRenamePreview({ + page, + newSlug, + parentPath, +}: { + page: RenamePageDialogProps['page'] + newSlug: string + parentPath: string +}): PageRefactorPreview { + const normalizedParentPath = + parentPath && parentPath !== '/' ? parentPath : '' + const normalizedNewPath = normalizedParentPath + ? `${normalizedParentPath}/${newSlug}` + : `/${newSlug}` + + return { + kind: 'rename', + pageId: page.id, + oldPath: page.path, + newPath: normalizedNewPath, + affectedPages: [], + counts: { + affectedPages: 0, + matchedLinks: 0, + }, + warnings: [], + } +} + +export function RenamePageDialog({ page }: RenamePageDialogProps) { + const [title, setTitle] = useState(page.title) + const [slug, setSlug] = useState(page.slug) + const [loading, setLoading] = useState(false) + const [slugLoading, setSlugLoading] = useState(false) + const [slugTouched, setSlugTouched] = useState(false) + const [fieldErrors, setFieldErrors] = useState>({}) + + const reloadTree = useTreeStore((state) => state.reloadTree) + const parentPath = useTreeStore((state) => + page.parentId ? state.getPathById(page.parentId) ?? '' : '', + ) + const enableLinkRefactor = useConfigStore((state) => state.enableLinkRefactor) + const navigate = useNavigate() + const location = useLocation() + const itemLabel = page.kind === NODE_KIND_PAGE ? 'Page' : 'Section' + const itemLabelLower = page.kind === NODE_KIND_PAGE ? 'page' : 'section' + + const titleChanged = title !== page.title + const slugChanged = slug !== page.slug + const isRenameDisabled = + !title || + !slug || + loading || + (!slugTouched && slugLoading) || + (!titleChanged && !slugChanged) + + const handleTitleChange = useCallback((value: string) => { + setTitle(value) + setFieldErrors((prev) => ({ ...prev, title: '' })) + }, []) + + const handleSlugChange = useCallback((value: string) => { + setSlug(value) + setFieldErrors((prev) => ({ ...prev, slug: '' })) + }, []) + + const resetForm = useCallback(() => { + setTitle(page.title) + setSlug(page.slug) + setSlugLoading(false) + setSlugTouched(false) + setFieldErrors({}) + setLoading(false) + }, [page.title, page.slug]) + + const buildRenamePreview = useCallback(async () => { + if (!enableLinkRefactor) { + return getSyntheticRenamePreview({ + page, + newSlug: slug, + parentPath, + }) + } + + return await previewPageRefactor(page.id, { + kind: 'rename', + title, + slug, + }) + }, [enableLinkRefactor, page, parentPath, slug, title]) + + const handleRename = useCallback(async (): Promise => { + if (!title || !slug || isRenameDisabled) return false + if (slugLoading && !slugTouched) { + toast.warning('Please wait until the slug is fully generated.') + return false + } + + setLoading(true) + setFieldErrors({}) + try { + const preview = await buildRenamePreview() + const rewriteLinks = await (enableLinkRefactor + ? confirmPageRefactor(preview, { allowSkipRewrite: true }) + : Promise.resolve(false)) + + if (enableLinkRefactor && rewriteLinks === null) return false + + const fullPage = await getPageByPath(page.path) + + if (enableLinkRefactor) { + await applyPageRefactor(page.id, { + kind: 'rename', + version: page.version, + title, + slug, + content: fullPage.content, + rewriteLinks: rewriteLinks as boolean, + }) + } else { + await updatePage( + page.id, + page.version, + title, + slug, + fullPage.content, + fullPage.tags ?? [], + fullPage.properties ?? {}, + ) + } + + await refreshAfterPageRefactor({ + preview, + currentPath: location.pathname, + navigate, + }) + await reloadTree() + toast.success(`${itemLabel} renamed`) + return true + } catch (err: unknown) { + console.warn(err) + handleFieldErrors(err, setFieldErrors, `Error renaming ${itemLabelLower}`) + return false + } finally { + setLoading(false) + } + }, [ + title, + slug, + isRenameDisabled, + slugLoading, + slugTouched, + buildRenamePreview, + enableLinkRefactor, + page.id, + page.path, + page.version, + location.pathname, + navigate, + reloadTree, + itemLabel, + itemLabelLower, + ]) + + const buttons = useMemo( + () => [ + { + label: loading ? 'Renaming...' : `Rename ${itemLabel}`, + actionType: 'confirm', + autoFocus: true, + loading, + disabled: isRenameDisabled, + variant: 'default', + }, + ], + [itemLabel, loading, isRenameDisabled], + ) + + return ( + await handleRename()} + testidPrefix="rename-page-dialog" + cancelButton={{ + label: 'Cancel', + variant: 'outline', + autoFocus: false, + disabled: loading, + }} + buttons={buttons} + > +
+
+ handleTitleChange(value)} + testid="rename-page-title-input" + placeholder={`${itemLabel} title`} + error={fieldErrors.title} + allowedHotkeys={DIALOG_INPUT_ALLOWED_HOTKEYS} + /> + +
+ +
+ + Path: {parentPath !== '' && `${parentPath}/`} + {slug && `${slug}`} + +
+ ) +} diff --git a/ui/leafwiki-ui/src/features/tree/TreeNodeActionsMenu.tsx b/ui/leafwiki-ui/src/features/tree/TreeNodeActionsMenu.tsx index 0bc5ab9eb..03868564f 100644 --- a/ui/leafwiki-ui/src/features/tree/TreeNodeActionsMenu.tsx +++ b/ui/leafwiki-ui/src/features/tree/TreeNodeActionsMenu.tsx @@ -18,6 +18,7 @@ import { DIALOG_COPY_PAGE, DIALOG_DELETE_PAGE_CONFIRMATION, DIALOG_MOVE_PAGE, + DIALOG_RENAME_PAGE, DIALOG_SORT_PAGES, } from '@/lib/registries' import { stripBasePath } from '@/lib/routePath' @@ -163,6 +164,25 @@ export default function TreeNodeActionsMenu({ Edit{' '} {nodeKind === NODE_KIND_PAGE ? 'Page' : 'Section'} + + openDialog(DIALOG_RENAME_PAGE, { + page: { + id: nodeId, + kind: nodeKind, + title: node.title, + slug: node.slug, + path: node.path, + version: nodeVersion, + parentId: node.parentId, + }, + }) + } + > + Rename{' '} + {nodeKind === NODE_KIND_PAGE ? 'Page' : 'Section'} + {nodeKind === NODE_KIND_PAGE && ( { + return ( + )} + /> + ) + }, +}) + dialogRegistry.register({ type: DIALOG_CREATE_PAGE_BY_PATH, render: (props) => { diff --git a/ui/leafwiki-ui/src/lib/registries/lazy-dialogs.tsx b/ui/leafwiki-ui/src/lib/registries/lazy-dialogs.tsx index 8da8063bf..090ab68c8 100644 --- a/ui/leafwiki-ui/src/lib/registries/lazy-dialogs.tsx +++ b/ui/leafwiki-ui/src/lib/registries/lazy-dialogs.tsx @@ -65,6 +65,11 @@ export const MovePageDialog = lazy(() => default: m.MovePageDialog, })), ) +export const RenamePageDialog = lazy(() => + import('@/features/page/RenamePageDialog').then((m) => ({ + default: m.RenamePageDialog, + })), +) export const PermalinkDialog = lazy( () => import('@/features/page/PermalinkDialog'), )