From 146c60aa7ef9fa70837f82aa63eb6d3caa69a294 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 8 Oct 2026 15:45:29 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20propose=20users=20in=20th?= =?UTF-8?q?e=20@=20dropdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-editor.spec.ts | 56 +++- .../api/__tests__/useMentionUser.test.tsx | 15 +- .../docs/doc-editor/api/useMentionUser.tsx | 11 +- .../custom-inline-content/Mention/Search.tsx | 82 +++++- .../UserMention/UserMentionSearchGroup.tsx | 78 ++++++ .../__tests__/UserMentionSearchGroup.test.tsx | 81 ++++++ .../__tests__/getBlockIdAtPos.test.ts | 47 ++++ .../__tests__/useMentionableUsers.test.ts | 92 +++++++ .../useMentionableUsersQuery.test.tsx | 67 +++++ .../Mention/UserMention/getBlockIdAtPos.ts | 23 ++ .../Mention/UserMention/index.ts | 4 + .../UserMention/useMentionableUsers.ts | 63 +++++ .../UserMention/useSearchUserMention.ts | 83 ++++++ .../Mention/__tests__/Search.test.tsx | 252 +++++++++++++++++- .../Mention/__tests__/utils.test.ts | 69 +++++ .../custom-inline-content/Mention/types.ts | 16 +- .../custom-inline-content/Mention/utils.ts | 25 ++ 18 files changed, 1047 insertions(+), 18 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/UserMentionSearchGroup.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/__tests__/UserMentionSearchGroup.test.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/__tests__/getBlockIdAtPos.test.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/__tests__/useMentionableUsers.test.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/__tests__/useMentionableUsersQuery.test.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/getBlockIdAtPos.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/useMentionableUsers.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/useSearchUserMention.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/__tests__/utils.test.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeae72747..e8b029a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 4baf345b1..2fe6767ba 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -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, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/__tests__/useMentionUser.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/__tests__/useMentionUser.test.tsx index cd5228e70..7c08a21da 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/__tests__/useMentionUser.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/api/__tests__/useMentionUser.test.tsx @@ -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)); + }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useMentionUser.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useMentionUser.tsx index 6c25fdc51..0aa463124 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/api/useMentionUser.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/api/useMentionUser.tsx @@ -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; }; +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({ mutationFn: mentionUser, + ...options, }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/Search.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/Search.tsx index a6df8f879..340c54c5f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/Search.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/Search.tsx @@ -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(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) => { 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 = ({ } `} > - + {/* 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. */} + 0 ? 'with-users' : 'without-users'} + showInput={false} + isSelectByDefault + > - + + 0 && docsCount > 0} + onResults={onDocsResults} + onLoadingChange={setDocsLoading} + /> + {nothingFound && ( + + {isMentionTrigger + ? t('No results found..') + : t('No documents found...')} + + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/UserMentionSearchGroup.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/UserMentionSearchGroup.tsx new file mode 100644 index 000000000..f00434f50 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Mention/UserMention/UserMentionSearchGroup.tsx @@ -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 ( + { + const name = getMentionableUserName(user); + + return ( + + + + {name} + + + } + right={ +