Add mentions feature to objects in notes (#16373)

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 <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
acee3
2026-02-13 08:12:37 -05:00
committed by GitHub
parent b7c1d47273
commit e2b9bc935f
19 changed files with 489 additions and 28 deletions
@@ -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 (
<Chip
label={t`Unknown object`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
if (!isDefined(record)) {
return (
<Chip
label={t`Deleted record`}
variant={ChipVariant.Transparent}
disabled
/>
);
}
return (
<StyledRecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record}
forceDisableClick={false}
/>
);
};
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 (
<MentionInlineContentRenderer
recordId={recordId}
objectMetadataId={objectMetadataId}
/>
);
},
},
);
@@ -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,
},
});
@@ -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({