mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-29 04:55:11 +02:00
✨(frontend) turn pasted doc links into interlinks
Pasting a bare link to another doc on the same domain now inserts an interlink instead of a plain hyperlink, so it benefits from the same title-sync and navigation behaviour as links added through the `/` or `@` menu. Pasting over a selection, inside a code block, or a link the current user can't resolve will display the doc id instead.
This commit is contained in:
@@ -75,6 +75,7 @@ and this project adheres to
|
||||
`/external_api/{version}/jwks`
|
||||
- 🔧(collaboration) adapt docker stack for development purpose
|
||||
- 🔧(helm) run a valkey for the backend and one for yhub in dev and feature
|
||||
- ✨(frontend) turn pasted doc links into interlinks #2713
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -513,6 +513,23 @@ test.describe('Doc Editor', () => {
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(editor.getByText('@')).toBeVisible();
|
||||
|
||||
// Copy current url
|
||||
const currentUrl = page.url();
|
||||
await page.evaluate(async (url) => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
}, currentUrl);
|
||||
|
||||
// Create new doc
|
||||
await createDoc(page, 'new-doc', browserName, 1);
|
||||
// Paste event the copied URL into the new doc's editor
|
||||
await editor.focus();
|
||||
await page.keyboard.press('Control+V');
|
||||
|
||||
// The paste url becomes an interlink to the copied doc
|
||||
await expect(interlinkChild).toContainText(docChild2);
|
||||
await interlinkChild.click();
|
||||
await verifyDocName(page, docChild2);
|
||||
});
|
||||
|
||||
test('it checks multiple big doc scroll to the top', async ({
|
||||
|
||||
@@ -140,5 +140,4 @@
|
||||
|
||||
.c__toast__content__children {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
+31
-4
@@ -42,7 +42,7 @@ import {
|
||||
useComments,
|
||||
} from '@/docs/doc-comments';
|
||||
import { DocsFindReplaceStyle } from '@/docs/doc-find-replace/styles';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { type Doc } from '@/docs/doc-management/types';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
|
||||
import { useAnalytics } from '@/libs/Analytics';
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
} from '../hook';
|
||||
import { useEditorStore } from '../stores';
|
||||
import { DocsEditorStyle } from '../styles';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
import { type DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor, sanitizeColor } from '../utils';
|
||||
|
||||
import BlockNoteAI from './AI';
|
||||
@@ -70,7 +70,10 @@ const AIMenuController = BlockNoteAI?.AIMenuController;
|
||||
const useAI = BlockNoteAI?.useAI;
|
||||
const localesBNAI = BlockNoteAI?.localesAI || {};
|
||||
import { createSafeCodeBlockSpec } from './custom-blocks/CodeBlock';
|
||||
import { InterlinkingLinkInlineContent } from './custom-inline-content';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
getPastedDocInterlink,
|
||||
} from './custom-inline-content';
|
||||
import XLMultiColumn from './xl-multi-column';
|
||||
|
||||
const localesBNMultiColumn = XLMultiColumn?.locales;
|
||||
@@ -232,7 +235,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
ai: localesBNAI?.[langLocalesBNAI as keyof typeof localesBNAI],
|
||||
}),
|
||||
},
|
||||
pasteHandler: ({ event, defaultPasteHandler }) => {
|
||||
pasteHandler: ({ event, editor: pasteEditor, defaultPasteHandler }) => {
|
||||
// Get clipboard data
|
||||
const blocknoteData = event.clipboardData?.getData('blocknote/html');
|
||||
|
||||
@@ -250,6 +253,30 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
void threadStore.refreshThreads();
|
||||
}
|
||||
|
||||
/**
|
||||
* When pasting a bare link to a doc on this same domain, turn it
|
||||
* into an interlink instead of a plain link, so it benefits from
|
||||
* the title-sync and navigation behaviour of the interlinking system.
|
||||
*/
|
||||
const pastedInterlink = getPastedDocInterlink(
|
||||
event,
|
||||
pasteEditor as DocsBlockNoteEditor,
|
||||
);
|
||||
if (pastedInterlink?.docId) {
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId: pastedInterlink.docId,
|
||||
...(pastedInterlink.blockId && {
|
||||
blockId: pastedInterlink.blockId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return defaultPasteHandler();
|
||||
},
|
||||
extensions: [
|
||||
|
||||
+10
-21
@@ -23,13 +23,13 @@ export type InterlinkingLinkInlineContentType = {
|
||||
docId?: {
|
||||
default: '';
|
||||
};
|
||||
blockId?: {
|
||||
default: '';
|
||||
};
|
||||
trigger?: {
|
||||
default: '/';
|
||||
values: readonly ['/', '@'];
|
||||
};
|
||||
title?: {
|
||||
default: '';
|
||||
};
|
||||
};
|
||||
content: 'none';
|
||||
};
|
||||
@@ -44,6 +44,9 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
|
||||
docId: {
|
||||
default: '',
|
||||
},
|
||||
blockId: {
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
values: [true, false],
|
||||
@@ -52,9 +55,6 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
|
||||
default: '/',
|
||||
values: ['/', '@'],
|
||||
},
|
||||
title: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
@@ -63,19 +63,19 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
|
||||
* Can have 3 render states:
|
||||
* 1. Disabled state: when the inline content is disabled, it renders nothing
|
||||
* 2. Search state: when the inline content has no docId, it renders the search page
|
||||
* 3. Linked state: when the inline content has a docId and title, it renders the linked doc
|
||||
* 3. Linked state: when the inline content has a docId, it renders the linked doc.
|
||||
*
|
||||
* Info: We keep everything in the same inline content to easily preserve
|
||||
* the element position when switching between states
|
||||
*/
|
||||
render: (props) => {
|
||||
const { disabled, docId, title } = props.inlineContent.props;
|
||||
const { disabled, docId, blockId } = props.inlineContent.props;
|
||||
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (docId && title) {
|
||||
if (docId) {
|
||||
/**
|
||||
* Should not happen
|
||||
*/
|
||||
@@ -98,19 +98,8 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
|
||||
return (
|
||||
<LinkSelected
|
||||
docId={docId}
|
||||
title={title}
|
||||
blockId={blockId}
|
||||
isEditable={props.editor.isEditable}
|
||||
onUpdateTitle={(newTitle) =>
|
||||
props.updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId: docId,
|
||||
title: newTitle,
|
||||
trigger: props.inlineContent.props.trigger,
|
||||
disabled: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+41
-33
@@ -1,47 +1,39 @@
|
||||
import { Loader } from '@gouvfr-lasuite/ui-components';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { getEmojiAndTitle, useDoc, useDocStore } from '@/docs/doc-management/';
|
||||
import {
|
||||
KEY_DOC,
|
||||
getEmojiAndTitle,
|
||||
useDoc,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management/';
|
||||
|
||||
interface LinkSelectedProps {
|
||||
docId: string;
|
||||
title: string;
|
||||
blockId?: string;
|
||||
isEditable: boolean;
|
||||
onUpdateTitle: (title: string) => void;
|
||||
}
|
||||
export const LinkSelected = ({
|
||||
docId,
|
||||
title,
|
||||
isEditable,
|
||||
onUpdateTitle,
|
||||
}: LinkSelectedProps) => {
|
||||
const { data: doc } = useDoc({ id: docId });
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isEditable && doc?.title && doc.title !== title) {
|
||||
onUpdateTitle(doc.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
|
||||
* causing an infinite loop of updates.
|
||||
* To prevent this, we only run this effect when doc?.title changes,
|
||||
* not when inlineContent.props.title changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doc?.title, docId, isEditable]);
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
export const LinkSelected = ({ docId, blockId }: LinkSelectedProps) => {
|
||||
const { data: doc, isLoading } = useDoc(
|
||||
{ id: docId },
|
||||
{
|
||||
queryKey: [KEY_DOC, { id: docId }],
|
||||
refetchOnWindowFocus: (query) => query.state.error?.status !== 403,
|
||||
},
|
||||
);
|
||||
const { untitledDocument } = useTrans();
|
||||
const href = `/docs/${docId}/${blockId ? `#${blockId}` : ''}`;
|
||||
const visualHref = `/${docId}/`;
|
||||
const label = doc ? doc.title || untitledDocument : visualHref;
|
||||
const isHrefFallback = !isLoading && !doc;
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(label);
|
||||
const { currentDoc } = useDocStore();
|
||||
const isDeletedDoc = !!currentDoc?.deleted_at;
|
||||
const router = useRouter();
|
||||
const href = `/docs/${docId}/`;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLSpanElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -127,7 +119,7 @@ export const LinkSelected = ({
|
||||
<Text
|
||||
$weight="500"
|
||||
spellCheck="false"
|
||||
$size="16px"
|
||||
$size={isHrefFallback ? 'sm' : 'md'}
|
||||
$display="inline"
|
||||
$position="relative"
|
||||
$css={css`
|
||||
@@ -139,7 +131,23 @@ export const LinkSelected = ({
|
||||
text-underline-offset: 0.2em;
|
||||
`}
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
{isLoading ? (
|
||||
<Box
|
||||
as="span"
|
||||
$display="inline-flex"
|
||||
$css={css`
|
||||
vertical-align: middle;
|
||||
& .c__loader--small {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Loader size="small" />
|
||||
</Box>
|
||||
) : (
|
||||
titleWithoutEmoji
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
-1
@@ -274,7 +274,6 @@ export const SearchPage = ({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId: doc.id,
|
||||
title: doc.title || untitledDocument,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
const mockCreateChildDoc = vi.fn();
|
||||
const mockCurrentDoc = { id: 'current-doc-id' };
|
||||
|
||||
vi.mock('@/docs/doc-management', async () => {
|
||||
const actual = await vi.importActual('@/docs/doc-management');
|
||||
return {
|
||||
...actual,
|
||||
useDocStore: () => ({ currentDoc: mockCurrentDoc }),
|
||||
useCreateChildDocTree: () => mockCreateChildDoc,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getInterlinkinghMenuItems,
|
||||
useGetInterlinkingMenuItems,
|
||||
} from '../InterlinkingLinkInlineContent';
|
||||
|
||||
const t = ((key: string) => key) as unknown as Parameters<
|
||||
typeof getInterlinkinghMenuItems
|
||||
>[1];
|
||||
|
||||
describe('getInterlinkinghMenuItems', () => {
|
||||
it('inserts an interlink inline content when the "link-doc" item is clicked', () => {
|
||||
const insertInlineContent = vi.fn();
|
||||
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
|
||||
const createPage = vi.fn();
|
||||
|
||||
const items = getInterlinkinghMenuItems(editor, t, 'Links', createPage);
|
||||
const linkDocItem = items.find((item) => item.key === 'link-doc');
|
||||
|
||||
linkDocItem?.onItemClick();
|
||||
|
||||
expect(insertInlineContent).toHaveBeenCalledWith([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { trigger: '/' },
|
||||
},
|
||||
]);
|
||||
expect(createPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to createPage when the "new-sub-doc" item is clicked', () => {
|
||||
const insertInlineContent = vi.fn();
|
||||
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
|
||||
const createPage = vi.fn();
|
||||
|
||||
const items = getInterlinkinghMenuItems(editor, t, 'Links', createPage);
|
||||
const newSubDocItem = items.find((item) => item.key === 'new-sub-doc');
|
||||
|
||||
newSubDocItem?.onItemClick();
|
||||
|
||||
expect(createPage).toHaveBeenCalledTimes(1);
|
||||
expect(insertInlineContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tags both items with the given group and searchable aliases', () => {
|
||||
const editor = {
|
||||
insertInlineContent: vi.fn(),
|
||||
} as unknown as DocsBlockNoteEditor;
|
||||
|
||||
const items = getInterlinkinghMenuItems(editor, t, 'Links', vi.fn());
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
items.forEach((item) => {
|
||||
expect(item.group).toBe('Links');
|
||||
expect(item.aliases.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGetInterlinkingMenuItems', () => {
|
||||
it('builds menu items bound to the current doc as parent', () => {
|
||||
const { result } = renderHook(() => useGetInterlinkingMenuItems(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const insertInlineContent = vi.fn();
|
||||
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
|
||||
|
||||
const items = result.current(editor, t);
|
||||
const newSubDocItem = items.find((item) => item.key === 'new-sub-doc');
|
||||
|
||||
newSubDocItem?.onItemClick();
|
||||
|
||||
expect(mockCreateChildDoc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
const DOC_ID = 'a1b2c3d4-e5f6-4789-a123-1234567890ab';
|
||||
|
||||
let mockDocQuery: { data?: { title?: string }; isLoading: boolean } = {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
};
|
||||
let mockCurrentDoc: { deleted_at?: string | null } | undefined;
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/docs/doc-management/', async () => {
|
||||
const actual = await vi.importActual('@/docs/doc-management/');
|
||||
return {
|
||||
...actual,
|
||||
useDoc: () => mockDocQuery,
|
||||
useDocStore: () => ({ currentDoc: mockCurrentDoc }),
|
||||
};
|
||||
});
|
||||
|
||||
import { LinkSelected } from '../LinkSelected';
|
||||
|
||||
describe('LinkSelected', () => {
|
||||
const push = vi.fn();
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
openSpy.mockClear();
|
||||
mockCurrentDoc = undefined;
|
||||
mockDocQuery = { data: undefined, isLoading: true };
|
||||
(useRouter as Mock).mockReturnValue({ push });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loader while the doc is loading', () => {
|
||||
mockDocQuery = { data: undefined, isLoading: true };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(document.querySelector('.c__loader--small')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the emoji and title once loaded', () => {
|
||||
mockDocQuery = { data: { title: '📄 My document' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByText('📄')).toBeInTheDocument();
|
||||
expect(screen.getByText('My document')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the href when the doc has no title', () => {
|
||||
mockDocQuery = { data: { title: '' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByText(`Untitled document`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates on click with no modifier keys', () => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
|
||||
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/`);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }])(
|
||||
'opens a new tab/window when %o click',
|
||||
(modifier) => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('link'), modifier);
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(`/docs/${DOC_ID}/`, '_blank');
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('opens a new tab on middle-mouse (auxclick) but not on other buttons', () => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
|
||||
fireEvent(
|
||||
link,
|
||||
new MouseEvent('auxclick', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent(
|
||||
link,
|
||||
new MouseEvent('auxclick', {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalledWith(`/docs/${DOC_ID}/`, '_blank');
|
||||
});
|
||||
|
||||
it('navigates on Enter but ignores other keys', () => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
|
||||
fireEvent.keyDown(link, { key: ' ' });
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(link, { key: 'Enter' });
|
||||
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/`);
|
||||
});
|
||||
|
||||
it('includes the block id anchor in the href when provided', () => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
|
||||
render(
|
||||
<LinkSelected docId={DOC_ID} blockId="block-1" isEditable={true} />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
|
||||
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/#block-1`);
|
||||
});
|
||||
|
||||
it('disables interaction when the current doc is deleted', () => {
|
||||
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
|
||||
mockCurrentDoc = { deleted_at: '2024-01-01T00:00:00Z' };
|
||||
|
||||
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('tabindex', '-1');
|
||||
expect(link).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(link, { key: 'Enter' });
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent(
|
||||
link,
|
||||
new MouseEvent('auxclick', {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
import { useDocSearchFilterStore } from '@/docs/doc-search/stores/useDocSearchFilterStore';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||
<AppWrapper>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</AppWrapper>
|
||||
);
|
||||
|
||||
const { capturedProps } = vi.hoisted(() => ({
|
||||
capturedProps: [] as unknown[],
|
||||
}));
|
||||
|
||||
const FAKE_DOCS = [
|
||||
{ id: 'doc-1', title: 'First result' },
|
||||
{ id: 'doc-2', title: 'Second result' },
|
||||
];
|
||||
|
||||
vi.mock('@/docs/doc-search', async () => {
|
||||
const { QuickSearchGroup } = await vi.importActual<
|
||||
typeof import('@/components/quick-search')
|
||||
>('@/components/quick-search');
|
||||
|
||||
return {
|
||||
DocSearchContent: (props: any) => {
|
||||
capturedProps.push(props);
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
group={{ groupName: props.groupName, elements: FAKE_DOCS }}
|
||||
onSelect={props.onSelect}
|
||||
renderElement={(doc: (typeof FAKE_DOCS)[number]) => doc.title}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { SearchPage } from '../SearchPage';
|
||||
|
||||
// cmdk and Mantine rely on browser APIs jsdom doesn't implement.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {
|
||||
// noop
|
||||
}
|
||||
unobserve() {
|
||||
// noop
|
||||
}
|
||||
disconnect() {
|
||||
// noop
|
||||
}
|
||||
},
|
||||
);
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const renderSearchPage = async ({ isEditable = true, trigger = '/' } = {}) => {
|
||||
const updateInlineContent = vi.fn();
|
||||
const contentRef = vi.fn();
|
||||
const insertInlineContent = vi.fn();
|
||||
const focus = vi.fn();
|
||||
|
||||
const editor = {
|
||||
isEditable,
|
||||
focus,
|
||||
insertInlineContent,
|
||||
} as unknown as DocsBlockNoteEditor;
|
||||
|
||||
render(
|
||||
<SearchPage
|
||||
editor={editor as any}
|
||||
inlineContent={
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { trigger, disabled: false, docId: '' },
|
||||
} as any
|
||||
}
|
||||
updateInlineContent={updateInlineContent}
|
||||
contentRef={contentRef}
|
||||
|
||||
node={{} as any}
|
||||
getPos={() => undefined}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// SearchPage focuses the input and opens the popover after a 100ms timeout.
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toHaveFocus());
|
||||
|
||||
return { updateInlineContent, contentRef, insertInlineContent, focus };
|
||||
};
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
capturedProps.length = 0;
|
||||
useDocSearchFilterStore.setState({ filter: 'all' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('limits the search to the current doc subtree on mount', async () => {
|
||||
await renderSearchPage();
|
||||
|
||||
expect(useDocSearchFilterStore.getState().filter).toBe('current');
|
||||
});
|
||||
|
||||
it('renders the trigger character and focuses the search input', async () => {
|
||||
await renderSearchPage({ trigger: '@' });
|
||||
|
||||
expect(screen.getByText('@')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('forwards the typed text to the search results', async () => {
|
||||
await renderSearchPage();
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'my query' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = capturedProps[capturedProps.length - 1] as {
|
||||
search: string;
|
||||
};
|
||||
expect(lastCall.search).toBe('my query');
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a result and inserts the interlink', async () => {
|
||||
const { updateInlineContent, contentRef, focus } = await renderSearchPage();
|
||||
|
||||
fireEvent.click(await screen.findByText('First result'));
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { docId: 'doc-1' },
|
||||
});
|
||||
expect(contentRef).toHaveBeenCalledWith(null);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a selection when the editor is not editable', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage({
|
||||
isEditable: false,
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByText('First result'));
|
||||
|
||||
expect(updateInlineContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes and re-inserts the trigger and typed text on Escape', async () => {
|
||||
const { updateInlineContent, insertInlineContent, focus } =
|
||||
await renderSearchPage({ trigger: '/' });
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'abc' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' });
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { disabled: true },
|
||||
});
|
||||
expect(focus).toHaveBeenCalled();
|
||||
expect(insertInlineContent).toHaveBeenCalledWith(['/abc']);
|
||||
});
|
||||
|
||||
it('closes without inserting anything on Backspace when the search is empty', async () => {
|
||||
const { updateInlineContent, insertInlineContent } =
|
||||
await renderSearchPage();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { disabled: true },
|
||||
});
|
||||
expect(insertInlineContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a Backspace with existing text fall through to normal editing', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage();
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'abc' },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
||||
|
||||
expect(updateInlineContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the highlighted result with ArrowDown/ArrowUp and selects it on Enter', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage();
|
||||
|
||||
await screen.findByText('First result');
|
||||
const input = screen.getByRole('combobox');
|
||||
|
||||
// The first result is highlighted by default.
|
||||
expect(
|
||||
screen.getByText('First result').closest('[cmdk-item]'),
|
||||
).toHaveAttribute('data-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
|
||||
expect(
|
||||
screen.getByText('Second result').closest('[cmdk-item]'),
|
||||
).toHaveAttribute('data-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
||||
|
||||
expect(
|
||||
screen.getByText('First result').closest('[cmdk-item]'),
|
||||
).toHaveAttribute('data-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { docId: 'doc-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
|
||||
import { getPastedDocInterlink } from '../pasteInterlink';
|
||||
|
||||
const VALID_DOC_ID = 'a1b2c3d4-e5f6-4789-a123-1234567890ab';
|
||||
|
||||
const makeEditor = (
|
||||
opts: { selectionEmpty?: boolean; inCodeBlock?: boolean } = {},
|
||||
) => {
|
||||
const { selectionEmpty = true, inCodeBlock = false } = opts;
|
||||
|
||||
const tr = {
|
||||
selection: {
|
||||
empty: selectionEmpty,
|
||||
$from: { parent: { type: { spec: { code: inCodeBlock } } } },
|
||||
$to: { parent: { type: { spec: { code: inCodeBlock } } } },
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
transact: (fn: (transaction: typeof tr) => unknown) => fn(tr),
|
||||
} as unknown as DocsBlockNoteEditor;
|
||||
};
|
||||
|
||||
const makeClipboardEvent = (text: string | undefined) =>
|
||||
({
|
||||
clipboardData: {
|
||||
getData: () => text,
|
||||
},
|
||||
}) as unknown as ClipboardEvent;
|
||||
|
||||
describe('getPastedDocInterlink', () => {
|
||||
it('returns null when the editor has a non-empty selection', () => {
|
||||
const editor = makeEditor({ selectionEmpty: false });
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cursor is inside a code block', () => {
|
||||
const editor = makeEditor({ inCodeBlock: true });
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the clipboard has no text', () => {
|
||||
const editor = makeEditor();
|
||||
|
||||
expect(
|
||||
getPastedDocInterlink(makeClipboardEvent(undefined), editor),
|
||||
).toBeNull();
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(''), editor)).toBeNull();
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(' '), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the pasted text contains whitespace', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(
|
||||
getPastedDocInterlink(makeClipboardEvent(`some text ${url}`), editor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the pasted text is not a valid URL', () => {
|
||||
const editor = makeEditor();
|
||||
|
||||
expect(
|
||||
getPastedDocInterlink(makeClipboardEvent('not-a-url'), editor),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a URL on a different origin', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `https://not-the-same-origin.example/docs/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the path is not a doc route', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/settings/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the doc id in the path is not a valid uuid', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/docs/not-a-uuid/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the doc id for a same-origin doc URL with a trailing slash', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
|
||||
docId: VALID_DOC_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the doc id for a same-origin doc URL without a trailing slash', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
|
||||
docId: VALID_DOC_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the doc id when the URL has a query string or hash', () => {
|
||||
const editor = makeEditor();
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/?foo=bar#section`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
|
||||
docId: VALID_DOC_ID,
|
||||
blockId: 'section',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the block id from the URL hash when present', () => {
|
||||
const editor = makeEditor();
|
||||
const blockId = '0b0566d8-56d3-4901-8163-d887c3c3bc0c';
|
||||
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/#${blockId}`;
|
||||
|
||||
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
|
||||
docId: VALID_DOC_ID,
|
||||
blockId,
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
@@ -1 +1,2 @@
|
||||
export * from './InterlinkingLinkInlineContent';
|
||||
export * from './pasteInterlink';
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
import { type DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
|
||||
const DOC_PATH_REGEX = /^\/docs\/([^/]+)\/?$/;
|
||||
|
||||
export type PastedDocInterlink = {
|
||||
docId: string;
|
||||
blockId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* When the pasted content is a bare URL pointing to a doc on this same
|
||||
* origin (e.g. copied from the address bar or "Copy link"), returns its
|
||||
* doc id (and, if present, the anchored block id from the URL hash) so
|
||||
* the paste can be turned into an interlink instead of a plain link.
|
||||
* Returns null for anything else (selections, code blocks, links mixed
|
||||
* with other text, other origins/routes).
|
||||
*/
|
||||
export const getPastedDocInterlink = (
|
||||
event: ClipboardEvent,
|
||||
editor: DocsBlockNoteEditor,
|
||||
): PastedDocInterlink | null => {
|
||||
const hasSelection = editor.transact((tr) => !tr.selection.empty);
|
||||
if (hasSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInCodeBlock = editor.transact(
|
||||
(tr) =>
|
||||
!!tr.selection.$from.parent.type.spec.code &&
|
||||
!!tr.selection.$to.parent.type.spec.code,
|
||||
);
|
||||
// If the cursor is inside a code block, we don't want to convert the pasted URL into an interlink.
|
||||
if (isInCodeBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim();
|
||||
// If the pasted text is empty or contains whitespace, it can't be a bare URL.
|
||||
if (!text || /\s/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the URL is not from the same origin as the current site, it can't be an interlink.
|
||||
if (url.origin !== window.location.origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = DOC_PATH_REGEX.exec(url.pathname);
|
||||
const docId = match?.[1];
|
||||
|
||||
if (!docId || !uuidValidate(docId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockId = url.hash.slice(1) || undefined;
|
||||
|
||||
return blockId ? { docId, blockId } : { docId };
|
||||
};
|
||||
Reference in New Issue
Block a user