mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-16 21:25:39 +02:00
✨(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:
@@ -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,
|
||||
|
||||
+14
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+76
-6
@@ -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>
|
||||
|
||||
+78
@@ -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)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+81
@@ -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]);
|
||||
});
|
||||
});
|
||||
+47
@@ -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();
|
||||
});
|
||||
});
|
||||
+92
@@ -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');
|
||||
});
|
||||
});
|
||||
+67
@@ -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);
|
||||
});
|
||||
});
|
||||
+23
@@ -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;
|
||||
});
|
||||
+4
@@ -1 +1,5 @@
|
||||
export * from './UserMentionInlineContent';
|
||||
export * from './UserMentionSearchGroup';
|
||||
export * from './getBlockIdAtPos';
|
||||
export * from './useMentionableUsers';
|
||||
export * from './useSearchUserMention';
|
||||
|
||||
+63
@@ -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 };
|
||||
};
|
||||
+83
@@ -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 };
|
||||
};
|
||||
+250
-2
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
+69
@@ -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);
|
||||
});
|
||||
});
|
||||
+11
-5
@@ -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 };
|
||||
};
|
||||
|
||||
+25
@@ -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();
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user