✨(frontend) propose users in the @ dropdown

Typing @ in the editor opens the search of the mentions, which only
proposed docs. Add a Mention a person section with the users of the doc
allowed to comment, above the Link a doc section, as a single keyboard
navigable list. Picking a user replaces the search by a mention and calls
the mention endpoint with the block as anchor so the user is notified by
email. The slash menu entry Link a doc still proposes docs only.

Signed-off-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
This commit is contained in:
Anthony LC
2026-10-09 14:03:01 +02:00
parent 36acc60bba
commit 146c60aa7e
18 changed files with 1047 additions and 18 deletions
+1
View File
@@ -15,6 +15,7 @@ and this project adheres to
- 👷(ci) cancel the superseded runs of a pull request #2757
- ✨(backend) add mention endpoint with cooldown-limited email
notification #2447
- ✨(frontend) mention users in the editor from the "@" dropdown
- 🚩(setting) add feature flag on Duplicate with Children #2721
- 💄(frontend) redesign 404 error standalone page #2696
- 💄(frontend) redesign 403 access denied page #2720
@@ -3,9 +3,18 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import {
createDoc,
getOtherBrowserName,
goToGridDoc,
verifyDocName,
} from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import {
addNewMember,
connectOtherUserToDoc,
updateShareLink,
} from './utils-share';
import {
createRootSubPage,
getTreeRow,
@@ -532,6 +541,49 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, docChild2);
});
test('it checks user mention feature', async ({ page, browserName }) => {
await createDoc(page, 'doc-user-mention', browserName, 1);
// Only the users of the doc allowed to comment can be mentioned
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', otherBrowserName);
await page.getByRole('button', { name: 'close' }).click();
// The first block of a new doc has no real id yet, the mention is
// recorded on an anchor block, so we mention from a second one
const editor = await writeInEditor({ page, text: 'Hello' });
await page.keyboard.press('Enter');
await page.keyboard.press('@');
const input = page.getByRole('combobox', {
name: 'Search for a person or a document',
});
const searchContainer = page.locator('.quick-search-container');
await input.fill(otherBrowserName);
const userOption = searchContainer
.getByRole('option')
.filter({ hasText: new RegExp(otherBrowserName, 'i') })
.first();
await expect(searchContainer.getByText('Mention a person')).toBeVisible();
await expect(userOption).toBeVisible();
const responseMention = page.waitForResponse(
(response) =>
response.url().includes('/mention/') && response.status() === 201,
);
await userOption.click();
await responseMention;
await expect(searchContainer).toBeHidden();
const userMention = editor.locator('.--docs--user-mention-inline-content');
await expect(userMention).toBeVisible();
await expect(userMention).toContainText(new RegExp(otherBrowserName, 'i'));
});
test('it checks multiple big doc scroll to the top', async ({
page,
browserName,
@@ -1,6 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
@@ -64,4 +64,17 @@ describe('useMentionUser', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(fetchMock.callHistory.calls(url)).toHaveLength(1);
});
it('calls the given error callback when the mention fails', async () => {
fetchMock.post(url, { status: 403, body: {} });
const onError = vi.fn();
const { result } = renderHook(() => useMentionUser({ onError }), {
wrapper: AppWrapper,
});
result.current.mutate(params);
await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
});
});
@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -43,12 +43,19 @@ export const mentionUser = async ({
return response.json() as Promise<MentionUserResponse>;
};
type UseMentionUserOptions = UseMutationOptions<
MentionUserResponse,
APIError,
MentionUser
>;
/**
* Records the mention of a user in the doc body, the backend then notifies
* the user by email.
*/
export function useMentionUser() {
export function useMentionUser(options?: UseMentionUserOptions) {
return useMutation<MentionUserResponse, APIError, MentionUser>({
mutationFn: mentionUser,
...options,
});
}
@@ -2,18 +2,21 @@ import { StyleSchema } from '@blocknote/core';
import { ReactCustomInlineContentRenderProps } from '@blocknote/react';
import { Popover } from '@mantine/core';
import type { KeyboardEvent } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, QuickSearch } from '@/components';
import { Box, Card, QuickSearch, Text } from '@/components';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { DocSearch } from '@/docs/doc-search/api/useSearchDocs';
import { useDocSearchFilterStore } from '@/docs/doc-search/stores/useDocSearchFilterStore';
import { useResponsiveStore } from '@/stores';
import { InterlinkingSearchGroup } from './Interlinking';
import { MentionSearchInlineContentType } from './MentionSearchInlineContent';
import { UserMentionSearchGroup, useSearchUserMention } from './UserMention';
import { MentionedInlineContent } from './types';
import { moveCursorAfterMention } from './utils';
const inputStyle = css`
background-color: transparent;
@@ -42,8 +45,10 @@ export const Search = ({
updateInlineContent,
editor,
inlineContent,
getPos,
}: ReactMentionSearch) => {
const trigger = inlineContent.props.trigger;
const isMentionTrigger = trigger === '@';
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
@@ -53,6 +58,12 @@ export const Search = ({
const dropdownId = useId();
const [popoverOpened, setPopoverOpened] = useState(false);
const { setFilter } = useDocSearchFilterStore();
const [docsCount, setDocsCount] = useState(0);
const [docsLoading, setDocsLoading] = useState(true);
const onDocsResults = useCallback(
(results: DocSearch[]) => setDocsCount(results.length),
[],
);
/**
* When the search is opened, we set the search
@@ -102,17 +113,42 @@ export const Search = ({
}
};
/**
* Pick a mentioned user from the search results.
* @param mentioned
* @returns
*/
const pick = (mentioned: MentionedInlineContent) => {
if (!isEditable) {
return;
}
const pos = getPos();
(updateInlineContent as unknown as ReplaceByMentioned)(mentioned);
contentRef(null);
editor.focus();
if (pos !== undefined) {
moveCursorAfterMention(editor as DocsBlockNoteEditor, pos);
}
};
/**
* Users can be mentioned only when the search is opened with "@",
* not with the slash menu.
*/
const { users, selectUser } = useSearchUserMention({
editor: editor as DocsBlockNoteEditor,
getPos,
search,
enabled: isMentionTrigger,
onPick: pick,
});
const nothingFound = !docsLoading && docsCount === 0 && users.length === 0;
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
@@ -172,13 +208,21 @@ export const Search = ({
as="input"
name="doc-search-input"
role="combobox"
aria-label={t('Search for a document')}
aria-label={
isMentionTrigger
? t('Search for a person or a document')
: t('Search for a document')
}
aria-expanded={popoverOpened}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={dropdownId}
$padding={{ left: '3px' }}
placeholder={t('mention a sub-doc...')}
placeholder={
isMentionTrigger
? t('mention something')
: t('mention a sub-doc')
}
$css={inputStyle}
ref={inputRef}
$display="inline-flex"
@@ -216,7 +260,14 @@ export const Search = ({
}
`}
>
<QuickSearch showInput={false} isSelectByDefault>
{/* The highlighted item is picked when the list mounts. The key
remounts it when the users appear or disappear, so the first
item is always highlighted and never one that went away. */}
<QuickSearch
key={users.length > 0 ? 'with-users' : 'without-users'}
showInput={false}
isSelectByDefault
>
<Card
$css={css`
box-shadow: 0 0 6px 0 rgba(0, 0, 145, 0.1);
@@ -267,7 +318,26 @@ export const Search = ({
$margin="sm"
$padding="none"
>
<InterlinkingSearchGroup search={search} onPick={pick} />
<UserMentionSearchGroup users={users} onSelect={selectUser} />
<InterlinkingSearchGroup
search={search}
onPick={pick}
withSeparator={users.length > 0 && docsCount > 0}
onResults={onDocsResults}
onLoadingChange={setDocsLoading}
/>
{nothingFound && (
<Text
className="--docs--quick-search-group-empty"
$size="sm"
role="status"
$padding="sm"
>
{isMentionTrigger
? t('No results found..')
: t('No documents found...')}
</Text>
)}
</Card>
</QuickSearch>
</Box>
@@ -0,0 +1,78 @@
import { useTranslation } from 'react-i18next';
import ArrowIcon from '@/assets/icons/ui-kit/keyboard_return.svg';
import { Box, Text } from '@/components';
import {
QuickSearchGroup,
QuickSearchItemContent,
} from '@/components/quick-search';
import { UserAvatar } from '@/features/auth';
import { MentionableUser, getMentionableUserName } from './useMentionableUsers';
type UserMentionSearchGroupProps = {
users: MentionableUser[];
onSelect: (user: MentionableUser) => void;
};
/**
* Section of the search dropdown proposing the users to mention. It renders
* nothing when there is no user to propose.
*/
export const UserMentionSearchGroup = ({
users,
onSelect,
}: UserMentionSearchGroupProps) => {
const { t } = useTranslation();
if (users.length === 0) {
return null;
}
return (
<QuickSearchGroup
group={{
groupName: t('Mention a person'),
groupKey: 'users',
elements: users,
}}
onSelect={onSelect}
renderElement={(user) => {
const name = getMentionableUserName(user);
return (
<QuickSearchItemContent
left={
<Box
className="--docs--mention-user-item"
$direction="row"
$gap="0.8rem"
$align="center"
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
$width="100%"
>
<UserAvatar fullName={name} />
<Text
$size="sm"
$color="var(--c--contextuals--content--semantic--neutral--primary)"
spellCheck="false"
$weight="500"
>
{name}
</Text>
</Box>
}
right={
<ArrowIcon
aria-hidden="true"
width="24px"
height="24px"
color="var(--c--contextuals--content--semantic--neutral--tertiary)"
/>
}
/>
);
}}
/>
);
};
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Command } from 'cmdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { UserMentionSearchGroup } from '../UserMentionSearchGroup';
const USERS = [
{ id: '1', full_name: 'Dupont Thomas', short_name: 'Thomas' },
{ id: '2', full_name: 'Dupont Julie', short_name: 'Julie' },
];
const renderGroup = (users = USERS, onSelect = vi.fn()) => {
render(
<Command>
<Command.List>
<UserMentionSearchGroup users={users} onSelect={onSelect} />
</Command.List>
</Command>,
{ wrapper: AppWrapper },
);
return { onSelect };
};
describe('UserMentionSearchGroup', () => {
beforeEach(() => {
// cmdk relies on browser APIs jsdom doesn't implement.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {
// noop
}
unobserve() {
// noop
}
disconnect() {
// noop
}
},
);
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('lists the users under a heading', () => {
renderGroup();
expect(
screen.getByRole('heading', { name: 'Mention a person' }),
).toBeInTheDocument();
expect(screen.getByText('Dupont Thomas')).toBeInTheDocument();
expect(screen.getByText('Dupont Julie')).toBeInTheDocument();
});
it('shows the initials of the users in an avatar', () => {
renderGroup();
expect(screen.getAllByText('DT')).toHaveLength(1);
expect(screen.getAllByText('DJ')).toHaveLength(1);
});
it('renders nothing when there is no user', () => {
renderGroup([]);
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('gives the picked user to the callback', () => {
const { onSelect } = renderGroup();
fireEvent.click(screen.getByText('Dupont Julie'));
expect(onSelect).toHaveBeenCalledWith(USERS[1]);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { getBlockIdAtPos } from '../getBlockIdAtPos';
type FakeNode = {
type: { isInGroup: (group: string) => boolean };
attrs: { id?: string };
};
const createNode = (isBlock: boolean, id?: string): FakeNode => ({
type: { isInGroup: (group) => isBlock && group === 'bnBlock' },
attrs: { id },
});
const createEditor = (nodes: FakeNode[]) =>
({
transact: (callback: (tr: unknown) => unknown) =>
callback({
doc: {
resolve: () => ({
depth: nodes.length - 1,
node: (depth: number) => nodes[depth],
}),
},
}),
}) as unknown as DocsBlockNoteEditor;
describe('getBlockIdAtPos', () => {
it('returns the id of the closest block around the position', () => {
const editor = createEditor([
createNode(false),
createNode(true, 'outer-block'),
createNode(true, 'inner-block'),
createNode(false),
]);
expect(getBlockIdAtPos(editor, 5)).toBe('inner-block');
});
it('returns undefined when the position is not in a block', () => {
const editor = createEditor([createNode(false), createNode(false)]);
expect(getBlockIdAtPos(editor, 0)).toBeUndefined();
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { Access, Role } from '@/docs/doc-management';
import {
getMentionableUserName,
getMentionableUsers,
} from '../useMentionableUsers';
const createAccess = (
id: string,
fullName: string,
maxRole: string = Role.EDITOR,
user: Partial<Access['user']> | null = {},
) =>
({
id: `access-${id}`,
role: maxRole,
max_role: maxRole,
user:
user === null
? null
: { id, full_name: fullName, short_name: fullName, ...user },
}) as unknown as Access;
const accesses = [
createAccess('1', 'Dupont Thomas', Role.OWNER),
createAccess('2', 'Dupont Julie'),
createAccess('3', 'Dupond Diallo', 'commenter'),
createAccess('4', 'Sarah Reader', Role.READER),
createAccess('5', '', Role.EDITOR, null),
];
describe('getMentionableUsers', () => {
it('returns an empty list while the accesses are not loaded', () => {
expect(getMentionableUsers(undefined, '')).toEqual([]);
});
it('keeps the users allowed to comment, without readers nor teams', () => {
expect(getMentionableUsers(accesses, '').map(({ id }) => id)).toEqual([
'1',
'2',
'3',
]);
});
it('lists a user once when it has several accesses on the ancestors', () => {
const duplicated = [...accesses, createAccess('2', 'Dupont Julie')];
expect(getMentionableUsers(duplicated, '').map(({ id }) => id)).toEqual([
'1',
'2',
'3',
]);
});
it('filters the users on the name, ignoring the case', () => {
expect(
getMentionableUsers(accesses, ' dupont ').map(({ id }) => id),
).toEqual(['1', '2']);
expect(getMentionableUsers(accesses, 'DIALLO').map(({ id }) => id)).toEqual(
['3'],
);
expect(getMentionableUsers(accesses, 'nobody')).toEqual([]);
});
it('filters the users on the email when it is known', () => {
const withEmail = [
createAccess('6', 'Jane', Role.EDITOR, { email: 'jane@example.com' }),
];
expect(getMentionableUsers(withEmail, 'example.com')).toHaveLength(1);
});
});
describe('getMentionableUserName', () => {
it('falls back on the short name then the email', () => {
expect(
getMentionableUserName({
id: '1',
full_name: 'Full',
short_name: 'Short',
}),
).toBe('Full');
expect(
getMentionableUserName({ id: '1', full_name: '', short_name: 'Short' }),
).toBe('Short');
expect(
getMentionableUserName({ id: '1', full_name: '', email: 'a@b.fr' }),
).toBe('a@b.fr');
});
});
@@ -0,0 +1,67 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { useMentionableUsers } from '../useMentionableUsers';
const ACCESSES_URL = 'http://test.jest/api/v1.0/documents/doc-id/accesses/';
describe('useMentionableUsers', () => {
beforeEach(() => {
fetchMock.hardReset();
fetchMock.mockGlobal();
fetchMock.get(ACCESSES_URL, [
{
id: 'access-1',
role: 'editor',
max_role: 'editor',
user: { id: '1', full_name: 'Dupont Thomas', short_name: 'Thomas' },
},
{
id: 'access-2',
role: 'reader',
max_role: 'reader',
user: { id: '2', full_name: 'Sarah Reader', short_name: 'Sarah' },
},
]);
});
it('loads the accesses of the doc and returns the users to mention', async () => {
const { result } = renderHook(
() => useMentionableUsers({ docId: 'doc-id', search: 'dup' }),
{ wrapper: AppWrapper },
);
expect(result.current.isLoading).toBe(true);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.users.map(({ id }) => id)).toEqual(['1']);
});
it('does not load anything when disabled', async () => {
const { result } = renderHook(
() =>
useMentionableUsers({ docId: 'doc-id', search: '', enabled: false }),
{ wrapper: AppWrapper },
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(result.current.isLoading).toBe(false);
expect(result.current.users).toEqual([]);
expect(fetchMock.callHistory.calls(ACCESSES_URL)).toHaveLength(0);
});
it('does not load anything without a doc', async () => {
const { result } = renderHook(() => useMentionableUsers({ search: '' }), {
wrapper: AppWrapper,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(result.current.users).toEqual([]);
expect(fetchMock.callHistory.calls()).toHaveLength(0);
});
});
@@ -0,0 +1,23 @@
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
/**
* Id of the block containing the given position of the document.
* It is the anchor of a mention, the email links to it.
*/
export const getBlockIdAtPos = (
editor: DocsBlockNoteEditor,
pos: number,
): string | undefined =>
editor.transact((tr) => {
const $pos = tr.doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth);
if (node.type.isInGroup('bnBlock')) {
return node.attrs.id as string;
}
}
return undefined;
});
@@ -1 +1,5 @@
export * from './UserMentionInlineContent';
export * from './UserMentionSearchGroup';
export * from './getBlockIdAtPos';
export * from './useMentionableUsers';
export * from './useSearchUserMention';
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { Access, Role } from '@/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES, useDocAccesses } from '@/docs/doc-share/api';
import { User } from '@/features/auth';
export type MentionableUser = Pick<User, 'id'> &
Partial<Pick<User, 'email' | 'short_name'>> & { full_name: string };
export const getMentionableUserName = (user: MentionableUser) =>
user.full_name || user.short_name || user.email || '';
/**
* The users of the doc who can be mentioned: the ones with a user account
* (not the teams) and allowed to comment, the backend rejects the readers.
* The same user can have several accesses on the ancestors of the doc.
*/
export const getMentionableUsers = (
accesses: Access[] | undefined,
search: string,
): MentionableUser[] => {
const query = search.trim().toLowerCase();
const users = new Map<string, MentionableUser>();
accesses?.forEach(({ user, role, max_role }) => {
if (!user || (max_role ?? role) === Role.READER || users.has(user.id)) {
return;
}
const matches = [user.full_name, user.short_name, user.email].some(
(value) => value?.toLowerCase().includes(query),
);
if (matches) {
users.set(user.id, user);
}
});
return Array.from(users.values());
};
export const useMentionableUsers = ({
docId,
search,
enabled = true,
}: {
docId?: string;
search: string;
enabled?: boolean;
}) => {
const params = { docId: docId ?? '' };
const { data: accesses, isLoading } = useDocAccesses(params, {
enabled: enabled && !!docId,
queryKey: [KEY_LIST_DOC_ACCESSES, params],
});
const users = useMemo(
() => (enabled ? getMentionableUsers(accesses, search) : []),
[accesses, enabled, search],
);
return { users, isLoading };
};
@@ -0,0 +1,83 @@
import { VariantType, useToastProvider } from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { validate as uuidValidate } from 'uuid';
import { useMentionUser } from '@/docs/doc-editor/api';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { useDocStore } from '@/docs/doc-management';
import { MentionedInlineContent } from '../types';
import { getBlockIdAtPos } from './getBlockIdAtPos';
import {
MentionableUser,
getMentionableUserName,
useMentionableUsers,
} from './useMentionableUsers';
interface UseSearchUserMentionParams {
editor: DocsBlockNoteEditor;
getPos: () => number | undefined;
search: string;
enabled: boolean;
onPick: (inlineContent: MentionedInlineContent) => void;
}
/**
* The users proposed by the search of the mentions, and what happens when one
* is picked: the search is replaced by the mention and the backend notifies
* the user by email, with the block as anchor.
*/
export const useSearchUserMention = ({
editor,
getPos,
search,
enabled,
onPick,
}: UseSearchUserMentionParams) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { currentDoc } = useDocStore();
const { mutate: mentionUser } = useMentionUser({
onError: () => {
toast(
t('The person could not be notified of the mention.'),
VariantType.ERROR,
);
},
});
/**
* Users can only be mentioned by users allowed to comment.
*/
const { users } = useMentionableUsers({
docId: currentDoc?.id,
search,
enabled: enabled && !!currentDoc?.abilities?.comment,
});
const selectUser = (user: MentionableUser) => {
if (!editor.isEditable) {
return;
}
const pos = getPos();
const anchorId =
pos === undefined ? undefined : getBlockIdAtPos(editor, pos);
onPick({
type: 'userMentionInline',
props: { userId: user.id, fullName: getMentionableUserName(user) },
});
if (currentDoc && anchorId && uuidValidate(anchorId)) {
mentionUser({
docId: currentDoc.id,
anchorId,
mentionedUserId: user.id,
});
}
};
return { users, selectUser };
};
@@ -1,9 +1,11 @@
import { MantineProvider } from '@mantine/core';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { PropsWithChildren } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { Doc, useDocStore } from '@/docs/doc-management';
import { useDocSearchFilterStore } from '@/docs/doc-search/stores/useDocSearchFilterStore';
import { AppWrapper } from '@/tests/utils';
@@ -75,7 +77,13 @@ beforeEach(() => {
);
});
const renderSearch = async ({ isEditable = true, trigger = '/' } = {}) => {
const BLOCK_ID = 'a3a5bd0d-4b2f-4a7c-9c1e-5a5e8e1d2f10';
const renderSearch = async ({
isEditable = true,
trigger = '/',
blockId = BLOCK_ID,
} = {}) => {
const updateInlineContent = vi.fn();
const contentRef = vi.fn();
const insertInlineContent = vi.fn();
@@ -85,6 +93,20 @@ const renderSearch = async ({ isEditable = true, trigger = '/' } = {}) => {
isEditable,
focus,
insertInlineContent,
// Used to find the block containing the search
transact: (callback: (tr: unknown) => unknown) =>
callback({
doc: {
nodeAt: () => null,
resolve: () => ({
depth: 0,
node: () => ({
type: { isInGroup: () => true },
attrs: { id: blockId },
}),
}),
},
}),
} as unknown as DocsBlockNoteEditor;
render(
@@ -100,7 +122,7 @@ const renderSearch = async ({ isEditable = true, trigger = '/' } = {}) => {
contentRef={contentRef}
node={{} as any}
getPos={() => undefined}
getPos={() => 12}
/>,
{ wrapper: Wrapper },
);
@@ -243,3 +265,229 @@ describe('Search', () => {
});
});
});
describe('Search with the "@" trigger', () => {
const DOC_ID = 'a1b2c3d4-e5f6-4789-a123-1234567890ab';
const ACCESSES_URL = `http://test.jest/api/v1.0/documents/${DOC_ID}/accesses/`;
const MENTION_URL = `http://test.jest/api/v1.0/documents/${DOC_ID}/mention/`;
const createAccess = (id: string, fullName: string, role = 'editor') => ({
id: `access-${id}`,
role,
max_role: role,
user: { id, full_name: fullName, short_name: fullName },
});
beforeEach(() => {
capturedProps.length = 0;
useDocSearchFilterStore.setState({ filter: 'all' });
useDocStore.setState({
currentDoc: {
id: DOC_ID,
abilities: { comment: true },
} as unknown as Doc,
});
fetchMock.hardReset();
fetchMock.mockGlobal();
fetchMock.get(ACCESSES_URL, [
createAccess('user-1', 'Mehdi Daoudi', 'owner'),
createAccess('user-2', 'Mehdi Benali'),
createAccess('user-3', 'Sarah Reader', 'reader'),
]);
fetchMock.post(MENTION_URL, { status: 201, body: { id: 'mention-id' } });
});
afterEach(() => {
// The globals stay stubbed until the component is unmounted, as the
// list may still re-render once the users are loaded.
useDocStore.setState({ currentDoc: undefined });
});
it('proposes the users and the docs in two sections', async () => {
await renderSearch({ trigger: '@' });
expect(
await screen.findByRole('heading', { name: 'Mention a person' }),
).toBeInTheDocument();
expect(screen.getByText('Mehdi Daoudi')).toBeInTheDocument();
expect(screen.getByText('Mehdi Benali')).toBeInTheDocument();
expect(screen.queryByText('Sarah Reader')).not.toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Link a doc' }),
).toBeInTheDocument();
expect(screen.getByText('First result')).toBeInTheDocument();
});
it('proposes only the docs when opened from the slash menu', async () => {
await renderSearch({ trigger: '/' });
await screen.findByText('First result');
expect(
screen.queryByRole('heading', { name: 'Mention a person' }),
).not.toBeInTheDocument();
expect(fetchMock.callHistory.calls(ACCESSES_URL)).toHaveLength(0);
});
it('filters the users with the typed text', async () => {
await renderSearch({ trigger: '@' });
await screen.findByText('Mehdi Daoudi');
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'benali' },
});
await waitFor(() =>
expect(screen.queryByText('Mehdi Daoudi')).not.toBeInTheDocument(),
);
expect(screen.getByText('Mehdi Benali')).toBeInTheDocument();
});
it('keeps an item highlighted when the typed text filters out the users', async () => {
await renderSearch({ trigger: '@' });
await screen.findByText('Mehdi Daoudi');
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'zzz' },
});
await waitFor(() =>
expect(screen.queryByText('Mehdi Daoudi')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.getByText('First result').closest('[cmdk-item]'),
).toHaveAttribute('data-selected', 'true'),
);
});
it('highlights the first user by default', async () => {
await renderSearch({ trigger: '@' });
expect(
(await screen.findByText('Mehdi Daoudi')).closest('[cmdk-item]'),
).toHaveAttribute('data-selected', 'true');
});
it('replaces the search by a mention and notifies the user', async () => {
const { updateInlineContent, contentRef, focus } = await renderSearch({
trigger: '@',
});
fireEvent.click(await screen.findByText('Mehdi Benali'));
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'userMentionInline',
props: { userId: 'user-2', fullName: 'Mehdi Benali' },
});
expect(contentRef).toHaveBeenCalledWith(null);
expect(focus).toHaveBeenCalled();
await waitFor(() =>
expect(fetchMock.callHistory.calls(MENTION_URL)).toHaveLength(1),
);
expect(
JSON.parse(
fetchMock.callHistory.lastCall(MENTION_URL)?.options.body as string,
),
).toEqual({ anchor_id: BLOCK_ID, mentioned_user_id: 'user-2' });
});
it('keeps the mention but does not notify when the block id is not a uuid', async () => {
const { updateInlineContent } = await renderSearch({
trigger: '@',
blockId: 'not-a-uuid',
});
fireEvent.click(await screen.findByText('Mehdi Benali'));
expect(updateInlineContent).toHaveBeenCalled();
expect(fetchMock.callHistory.calls(MENTION_URL)).toHaveLength(0);
});
it('ignores the selection of a user when the editor is not editable', async () => {
const { updateInlineContent } = await renderSearch({
trigger: '@',
isEditable: false,
});
fireEvent.click(await screen.findByText('Mehdi Benali'));
expect(updateInlineContent).not.toHaveBeenCalled();
expect(fetchMock.callHistory.calls(MENTION_URL)).toHaveLength(0);
});
it('still links a doc', async () => {
const { updateInlineContent } = await renderSearch({ trigger: '@' });
fireEvent.click(await screen.findByText('Second result'));
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { docId: 'doc-2' },
});
expect(fetchMock.callHistory.calls(MENTION_URL)).toHaveLength(0);
});
it('navigates through the users and the docs as a single list', async () => {
const { updateInlineContent } = await renderSearch({ trigger: '@' });
await screen.findByText('Mehdi Daoudi');
const input = screen.getByRole('combobox');
const isSelected = (text: string) =>
screen
.getByText(text)
.closest('[cmdk-item]')
?.getAttribute('data-selected');
expect(isSelected('Mehdi Daoudi')).toBe('true');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(isSelected('Mehdi Benali')).toBe('true');
// Moves from the last user to the first doc
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(isSelected('First result')).toBe('true');
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(isSelected('Mehdi Benali')).toBe('true');
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { docId: 'doc-1' },
});
});
it('selects the highlighted user with Enter', async () => {
const { updateInlineContent } = await renderSearch({ trigger: '@' });
await screen.findByText('Mehdi Daoudi');
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'userMentionInline',
props: { userId: 'user-1', fullName: 'Mehdi Daoudi' },
});
});
it('keeps the trigger and the typed text on Escape', async () => {
const { updateInlineContent, insertInlineContent } = await renderSearch({
trigger: '@',
});
await screen.findByText('Mehdi Daoudi');
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'meh' },
});
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'mentionSearchInline',
props: { disabled: true },
});
expect(insertInlineContent).toHaveBeenCalledWith(['@meh']);
});
});
@@ -0,0 +1,69 @@
import { Schema } from '@tiptap/pm/model';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import { describe, expect, it } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { moveCursorAfterMention } from '../utils';
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'inline*' },
text: { group: 'inline' },
mention: { group: 'inline', inline: true, atom: true },
},
});
// <p>ab[mention]cd</p><p>ef</p>
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('ab'),
schema.node('mention'),
schema.text('cd'),
]),
schema.node('paragraph', null, [schema.text('ef')]),
]);
const MENTION_POS = 3;
const createEditor = (anchor: number) => {
let state = EditorState.create({
doc,
selection: TextSelection.create(doc, anchor),
});
const editor = {
transact: (callback: (tr: typeof state.tr) => unknown) => {
const tr = state.tr;
const result = callback(tr);
state = state.apply(tr);
return result;
},
} as unknown as DocsBlockNoteEditor;
return { editor, getSelection: () => state.selection };
};
describe('moveCursorAfterMention', () => {
it.each([
['before the mention', 2],
['in another block', 9],
])('puts the cursor right after the mention, from %s', (_, anchor) => {
const { editor, getSelection } = createEditor(anchor);
moveCursorAfterMention(editor, MENTION_POS);
expect(getSelection().empty).toBe(true);
expect(getSelection().from).toBe(MENTION_POS + 1);
});
it('keeps the selection when there is no node at the position', () => {
const { editor, getSelection } = createEditor(2);
// The end of the doc, there is no node after it
moveCursorAfterMention(editor, doc.content.size);
expect(getSelection().from).toBe(2);
});
});
@@ -1,8 +1,14 @@
/**
* What the mention search can be replaced by once the user picked an item.
* Each kind of mention (Interlinking, ...) adds its own inline content here.
* Each kind of mention (Interlinking, UserMention, ...) adds its own inline
* content here.
*/
export type MentionedInlineContent = {
type: 'interlinkingLinkInline';
props: { docId: string };
};
export type MentionedInlineContent =
| {
type: 'interlinkingLinkInline';
props: { docId: string };
}
| {
type: 'userMentionInline';
props: { userId: string; fullName: string };
};
@@ -0,0 +1,25 @@
import { TextSelection } from '@tiptap/pm/state';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
/**
* The search is replaced by the mention at the same position, which leaves the
* cursor where it was, before the mention or even elsewhere in the doc. Put it
* right after the mention so the user can keep typing.
*/
export const moveCursorAfterMention = (
editor: DocsBlockNoteEditor,
pos: number,
) => {
editor.transact((tr) => {
const mention = tr.doc.nodeAt(pos);
if (!mention) {
return;
}
tr.setSelection(
TextSelection.create(tr.doc, pos + mention.nodeSize),
).scrollIntoView();
});
};