From e2b9bc935f931bf4f061fafdc7e1722f0675331e Mon Sep 17 00:00:00 2001 From: acee3 <67206925+acee3@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:12:37 -0500 Subject: [PATCH] Add mentions feature to objects in notes (#16373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #15755 ### Context As shown in the issue, users should be able to @ objects and have it be linked to their associated page. ### Changes Made - Updated the BlockNote schema to include the newly created MentionInlineContent that displays an object using a RecordChip - Added the UI for the menu that appears to select the requested object (similar to this example: https://www.blocknotejs.org/examples/ui-components/suggestion-menus-slash-menu-component) - Added a hook to retrieve the data for the UI - Added the @ trigger to the block editor ### Result https://github.com/user-attachments/assets/87dae6a2-ad7a-4642-80b2-8b1751feae24 --------- Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait Co-authored-by: Cursor --- .../components/MentionInlineContent.tsx | 97 +++++++++++++++++ .../activities/blocks/constants/Schema.ts | 11 +- .../components/ActivityRichTextEditor.tsx | 4 + .../components/CommandMenuOpenContainer.tsx | 2 + .../StandaloneRichTextEditorContent.tsx | 4 + .../MentionMenuDropdownClickOutsideId.ts | 2 + .../ui/input/constants/MentionMenuListId.ts | 1 + .../input/editor/components/BlockEditor.tsx | 8 ++ .../editor/components/CustomMentionMenu.tsx | 101 ++++++++++++++++++ .../components/CustomMentionMenuListItem.tsx | 69 ++++++++++++ ...stomMentionMenuSelectedIndexSyncEffect.tsx | 29 +++++ .../editor/components/CustomSlashMenu.tsx | 20 +--- ...CustomSlashMenuSelectedIndexSyncEffect.tsx | 29 +++++ .../ui/input/editor/components/types.ts | 17 ++- .../ui/input/editor/hooks/useMentionMenu.ts | 96 +++++++++++++++++ .../components/DropdownMenuItemsContainer.tsx | 16 +-- .../remote/generated/remote-elements.ts | 4 + .../src/components/chip/LinkChip.tsx | 1 + .../components/MenuItemSuggestion.tsx | 6 ++ 19 files changed, 489 insertions(+), 28 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/constants/MentionMenuDropdownClickOutsideId.ts create mode 100644 packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts diff --git a/packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx b/packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx new file mode 100644 index 00000000000..dff8a6be18f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx @@ -0,0 +1,97 @@ +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { RecordChip } from '@/object-record/components/RecordChip'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { createReactInlineContentSpec } from '@blocknote/react'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { Chip, ChipVariant } from 'twenty-ui/components'; + +const StyledRecordChip = styled(RecordChip)` + height: auto; + margin: 0; + padding: ${({ theme }) => `0 ${theme.spacing(1)}`}; +`; + +const MentionInlineContentRenderer = ({ + recordId, + objectMetadataId, +}: { + recordId: string; + objectMetadataId: string; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === objectMetadataId, + ); + + const objectNameSingular = objectMetadataItem?.nameSingular; + + const { record, loading } = useFindOneRecord({ + objectNameSingular: objectNameSingular ?? '', + objectRecordId: recordId, + skip: !isNonEmptyString(objectNameSingular) || !isNonEmptyString(recordId), + }); + + if (loading) { + return null; + } + + if (!isDefined(objectMetadataItem)) { + return ( + + ); + } + + if (!isDefined(record)) { + return ( + + ); + } + + return ( + + ); +}; + +export const MentionInlineContent = createReactInlineContentSpec( + { + type: 'mention' as const, + propSchema: { + recordId: { + default: '' as const, + }, + objectMetadataId: { + default: '' as const, + }, + }, + content: 'none', + }, + { + render: (props) => { + const { recordId, objectMetadataId } = props.inlineContent.props; + + return ( + + ); + }, + }, +); diff --git a/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts b/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts index 745584f28be..c2eb65b990d 100644 --- a/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts +++ b/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts @@ -1,10 +1,19 @@ -import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'; +import { + BlockNoteSchema, + defaultBlockSpecs, + defaultInlineContentSpecs, +} from '@blocknote/core'; import { FileBlock } from '@/activities/blocks/components/FileBlock'; +import { MentionInlineContent } from '@/activities/blocks/components/MentionInlineContent'; export const BLOCK_SCHEMA = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, file: FileBlock, }, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + mention: MentionInlineContent, + }, }); diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index 7091c248c91..cc2a4530363 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -37,6 +37,7 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { t } from '@lingui/core/macro'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; @@ -223,6 +224,9 @@ export const ActivityRichTextEditor = ({ domAttributes: { editor: { class: 'editor' } }, schema: BLOCK_SCHEMA, uploadFile: handleEditorBuiltInUploadFile, + placeholders: { + default: t`Type '/' for commands, '@' for mentions`, + }, }); useHotkeysOnFocusedElement({ diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx index 8af08175b34..7d6cda2879f 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuOpenContainer.tsx @@ -5,6 +5,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { isSidePanelAnimatingState } from '@/command-menu/states/isSidePanelAnimatingState'; import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId'; +import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId'; import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId'; @@ -79,6 +80,7 @@ export const CommandMenuOpenContainer = ({ LINK_CHIP_CLICK_OUTSIDE_ID, RECORD_CHIP_CLICK_OUTSIDE_ID, SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID, + MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID, WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID, WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID, WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx index 3660755caca..2056fc792c4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx @@ -18,6 +18,7 @@ import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePush import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { t } from '@lingui/core/macro'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; @@ -95,6 +96,9 @@ export const StandaloneRichTextEditorContent = ({ schema: BLOCK_SCHEMA, uploadFile: handleEditorBuiltInUploadFile, sideMenuDetection: 'editor', + placeholders: { + default: t`Type '/' for commands, '@' for mentions`, + }, }); const handlePersistBody = useDebouncedCallback((blocknote: string) => { diff --git a/packages/twenty-front/src/modules/ui/input/constants/MentionMenuDropdownClickOutsideId.ts b/packages/twenty-front/src/modules/ui/input/constants/MentionMenuDropdownClickOutsideId.ts new file mode 100644 index 00000000000..8a94cb4b43a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/constants/MentionMenuDropdownClickOutsideId.ts @@ -0,0 +1,2 @@ +export const MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID = + 'mention-menu-dropdown-click-outside-id'; diff --git a/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts b/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts new file mode 100644 index 00000000000..39b97203292 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts @@ -0,0 +1 @@ +export const MENTION_MENU_LIST_ID = 'mention-menu-list-id'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx index 075df001297..ce25728a7f9 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx +++ b/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx @@ -7,11 +7,13 @@ import { type ClipboardEvent } from 'react'; import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu'; +import { CustomMentionMenu } from '@/ui/input/editor/components/CustomMentionMenu'; import { CustomSideMenu } from '@/ui/input/editor/components/CustomSideMenu'; import { CustomSlashMenu, type SuggestionItem, } from '@/ui/input/editor/components/CustomSlashMenu'; +import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu'; interface BlockEditorProps { editor: typeof BLOCK_SCHEMA.BlockNoteEditor; @@ -141,6 +143,7 @@ export const BlockEditor = ({ }: BlockEditorProps) => { const theme = useTheme(); const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark'; + const getMentionItems = useMentionMenu(editor); const handleFocus = () => { onFocus?.(); @@ -179,6 +182,11 @@ export const BlockEditor = ({ } suggestionMenuComponent={CustomSlashMenu} /> + getMentionItems(query)} + suggestionMenuComponent={CustomMentionMenu} + /> ); diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx new file mode 100644 index 00000000000..9881797e75b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx @@ -0,0 +1,101 @@ +import styled from '@emotion/styled'; +import { autoUpdate, useFloating } from '@floating-ui/react'; +import { motion } from 'framer-motion'; +import { type MouseEvent as ReactMouseEvent } from 'react'; +import { createPortal } from 'react-dom'; + +import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId'; +import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId'; +import { CustomMentionMenuListItem } from '@/ui/input/editor/components/CustomMentionMenuListItem'; +import { CustomMentionMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect'; +import { + type CustomMentionMenuProps, + type MentionItem, +} from '@/ui/input/editor/components/types'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { isDefined } from 'twenty-shared/utils'; + +export type { MentionItem }; + +const MenuPixelWidth = 240; + +const StyledContainer = styled.div` + height: 1px; + width: 1px; +`; + +export const CustomMentionMenu = ({ + items, + selectedIndex, + onItemClick, +}: CustomMentionMenuProps) => { + const { refs, floatingStyles } = useFloating({ + placement: 'bottom-start', + whileElementsMounted: autoUpdate, + }); + + const handleContainerClick = (e: ReactMouseEvent) => { + e.stopPropagation(); + }; + + if (!isDefined(items) || items.length === 0) { + return null; + } + + const filteredItems = items.filter( + (item) => + isDefined(item.recordId) && + isDefined(item.objectNameSingular) && + isDefined(item.objectMetadataId), + ); + + return ( + + + <> + {createPortal( + + + + + item.recordId!, + )} + > + {filteredItems.map((item) => ( + onItemClick?.(item)} + objectNameSingular={item.objectNameSingular!} + /> + ))} + + + + + , + document.body, + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx new file mode 100644 index 00000000000..290689c0c9f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx @@ -0,0 +1,69 @@ +import { type MouseEvent } from 'react'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getAvatarType } from '@/object-metadata/utils/getAvatarType'; +import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState'; +import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { Avatar } from 'twenty-ui/display'; +import { MenuItemSuggestion } from 'twenty-ui/navigation'; + +type CustomMentionMenuListItemProps = { + recordId: string; + onClick: () => void; + objectNameSingular: string; +}; + +export const CustomMentionMenuListItem = ({ + recordId, + onClick, + objectNameSingular, +}: CustomMentionMenuListItemProps) => { + const { resetSelectedItem } = useSelectableList(MENTION_MENU_LIST_ID); + + const isSelectedItem = useRecoilComponentFamilyValue( + isSelectedItemIdComponentFamilySelector, + recordId, + ); + + const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId)); + + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + + const handleClick = (event?: MouseEvent) => { + event?.preventDefault(); + event?.stopPropagation(); + resetSelectedItem(); + onClick(); + }; + + if (!isDefined(searchRecord)) { + return null; + } + + return ( + + ( + + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx new file mode 100644 index 00000000000..077bfd870df --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx @@ -0,0 +1,29 @@ +import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId'; +import { type MentionItem } from '@/ui/input/editor/components/types'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type CustomMentionMenuSelectedIndexSyncEffectProps = { + items: MentionItem[]; + selectedIndex: number | undefined; +}; + +export const CustomMentionMenuSelectedIndexSyncEffect = ({ + items, + selectedIndex, +}: CustomMentionMenuSelectedIndexSyncEffectProps) => { + const { setSelectedItemId } = useSelectableList(MENTION_MENU_LIST_ID); + + useEffect(() => { + if (!isDefined(selectedIndex) || !isDefined(items)) return; + + const selectedItem = items[selectedIndex]; + + if (isDefined(selectedItem) && isDefined(selectedItem.recordId)) { + setSelectedItemId(selectedItem.recordId); + } + }, [items, selectedIndex, setSelectedItemId]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx index 093c08f6b40..299a86ce2fa 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx @@ -2,12 +2,12 @@ import { useBlockNoteEditor } from '@blocknote/react'; import styled from '@emotion/styled'; import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react'; import { motion } from 'framer-motion'; -import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId'; import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId'; import { CustomSlashMenuListItem } from '@/ui/input/editor/components/CustomSlashMenuListItem'; +import { CustomSlashMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect'; import type { CustomSlashMenuProps, SuggestionItem, @@ -16,8 +16,6 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; -import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; -import { isDefined } from 'twenty-shared/utils'; export type { SuggestionItem }; @@ -58,20 +56,12 @@ export const CustomSlashMenu = ({ middleware: [flip(), offset(({ placement }) => getOffsetValue(placement))], }); - const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID); - - useEffect(() => { - if (!isDefined(selectedIndex)) return; - - const selectedItem = items[selectedIndex]; - - if (isDefined(selectedItem)) { - setSelectedItemId(selectedItem.title); - } - }, [items, selectedIndex, setSelectedItemId]); - return ( + <> {createPortal( { + const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID); + + useEffect(() => { + if (!isDefined(selectedIndex)) return; + + const selectedItem = items[selectedIndex]; + + if (isDefined(selectedItem)) { + setSelectedItemId(selectedItem.title); + } + }, [items, selectedIndex, setSelectedItemId]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/types.ts b/packages/twenty-front/src/modules/ui/input/editor/components/types.ts index 26354233cc0..7d10aa8eef9 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/types.ts +++ b/packages/twenty-front/src/modules/ui/input/editor/components/types.ts @@ -1,11 +1,20 @@ -import type { SuggestionMenuProps } from '@blocknote/react'; +import type { + DefaultReactSuggestionItem, + SuggestionMenuProps, +} from '@blocknote/react'; import { type IconComponent } from 'twenty-ui/display'; -export type SuggestionItem = { - title: string; - onItemClick: () => void; +export type SuggestionItem = DefaultReactSuggestionItem & { aliases?: string[]; Icon?: IconComponent; }; export type CustomSlashMenuProps = SuggestionMenuProps; + +export type MentionItem = DefaultReactSuggestionItem & { + recordId?: string; + objectNameSingular?: string; + objectMetadataId?: string; +}; + +export type CustomMentionMenuProps = SuggestionMenuProps; diff --git a/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts b/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts new file mode 100644 index 00000000000..fe7d01c4204 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts @@ -0,0 +1,96 @@ +import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; +import { type MentionItem } from '@/ui/input/editor/components/types'; +import { useMemo } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { + type SearchQuery, + type SearchQueryVariables, +} from '~/generated/graphql'; + +const MENTION_SEARCH_LIMIT = 50; + +export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => { + const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); + const apolloCoreClient = useApolloCoreClient(); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const searchableObjectMetadataItems = useMemo( + () => + activeObjectMetadataItems.filter( + (item) => + !item.isSystem && + item.isSearchable && + getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: item.id, + }).canReadObjectRecords === true, + ), + [activeObjectMetadataItems, objectPermissionsByObjectMetadataId], + ); + + const objectsToSearch = useMemo( + () => searchableObjectMetadataItems.map(({ nameSingular }) => nameSingular), + [searchableObjectMetadataItems], + ); + + const getMentionItems = useRecoilCallback( + ({ set }) => + async (query: string): Promise => { + const { data } = await apolloCoreClient.query< + SearchQuery, + SearchQueryVariables + >({ + query: SEARCH_QUERY, + variables: { + searchInput: query, + limit: MENTION_SEARCH_LIMIT, + includedObjectNameSingulars: objectsToSearch, + }, + }); + + const searchRecords = data?.search.edges.map((edge) => edge.node) || []; + + searchRecords.forEach((searchRecord) => { + set(searchRecordStoreFamilyState(searchRecord.recordId), { + ...searchRecord, + record: undefined, + }); + }); + + return searchRecords.map((searchRecord) => { + const objectMetadataItem = searchableObjectMetadataItems.find( + (item) => item.nameSingular === searchRecord.objectNameSingular, + ); + + return { + title: searchRecord.label, + recordId: searchRecord.recordId, + objectNameSingular: searchRecord.objectNameSingular, + objectMetadataId: objectMetadataItem?.id, + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'mention', + props: { + recordId: searchRecord.recordId, + objectMetadataId: objectMetadataItem?.id ?? '', + }, + }, + ' ', + ]); + }, + }; + }); + }, + [apolloCoreClient, editor, objectsToSearch, searchableObjectMetadataItems], + ); + + return getMentionItems; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx index 7181312f5b6..dc6c12fc10f 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx @@ -56,15 +56,15 @@ export const DropdownMenuItemsContainer = ({ scrollable?: boolean; }) => { return scrollable === true ? ( - - + + {children} - - + + ) : ( {children} diff --git a/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts b/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts index 2361bad102c..0fd91e4cae4 100644 --- a/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts +++ b/packages/twenty-sdk/src/front-component/remote/generated/remote-elements.ts @@ -6950,6 +6950,8 @@ export const TwentyUiMenuItemSelectTagElement = createRemoteElement< export type TwentyUiMenuItemSuggestionProperties = { withIconContainer?: boolean; text: string; + contextualText?: string; + contextualTextPosition?: string; selected?: boolean; className?: string; }; @@ -6964,6 +6966,8 @@ export const TwentyUiMenuItemSuggestionElement = createRemoteElement< properties: { withIconContainer: { type: Boolean }, text: { type: String }, + contextualText: { type: String }, + contextualTextPosition: { type: String }, selected: { type: Boolean }, className: { type: String }, }, diff --git a/packages/twenty-ui/src/components/chip/LinkChip.tsx b/packages/twenty-ui/src/components/chip/LinkChip.tsx index 33771e42fb7..6e0c42ecbbb 100644 --- a/packages/twenty-ui/src/components/chip/LinkChip.tsx +++ b/packages/twenty-ui/src/components/chip/LinkChip.tsx @@ -26,6 +26,7 @@ const StyledLink = styled(Link)` display: inline-flex; text-decoration: none; min-width: 0; + vertical-align: middle; `; export const LinkChip = ({ diff --git a/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemSuggestion.tsx b/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemSuggestion.tsx index 9ef2f8f2a52..82d2d8764bb 100644 --- a/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemSuggestion.tsx +++ b/packages/twenty-ui/src/navigation/menu/menu-item/components/MenuItemSuggestion.tsx @@ -10,6 +10,8 @@ export type MenuItemSuggestionProps = { LeftIcon?: IconComponent | null; withIconContainer?: boolean; text: string; + contextualText?: string; + contextualTextPosition?: 'left' | 'right'; selected?: boolean; className?: string; onClick?: (event: MouseEvent) => void; @@ -55,6 +57,8 @@ export const MenuItemSuggestion = ({ LeftIcon, withIconContainer = false, text, + contextualText = undefined, + contextualTextPosition = 'left', className, selected, onClick, @@ -77,6 +81,8 @@ export const MenuItemSuggestion = ({