Add @mention support in AI Chat input (#17943)
## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)
## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before
Made with [Cursor](https://cursor.com)
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type ChangeEvent, useRef } from 'react';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
import { type AttachmentFileCategory } from '@/activities/files/types/AttachmentFileCategory';
|
||||
import { getFileType } from '@/activities/files/utils/getFileType';
|
||||
import { FileIcon } from '@/file/components/FileIcon';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const StyledFileLine = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledLink = styled.a`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledUploadFileContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const FileBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'file',
|
||||
propSchema: {
|
||||
url: {
|
||||
default: '' as string,
|
||||
},
|
||||
name: {
|
||||
default: '' as string,
|
||||
},
|
||||
fileCategory: {
|
||||
default: 'OTHER' as AttachmentFileCategory,
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadAttachment = async (file: File) => {
|
||||
if (isUndefinedOrNull(file)) {
|
||||
return '';
|
||||
}
|
||||
const fileUrl = await editor.uploadFile?.(file);
|
||||
|
||||
if (!isNonEmptyString(fileUrl)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
editor.updateBlock(block.id, {
|
||||
props: {
|
||||
...block.props,
|
||||
url: fileUrl,
|
||||
fileCategory: getFileType(file.name),
|
||||
name: file.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleUploadFileClick = () => {
|
||||
inputFileRef?.current?.click?.();
|
||||
};
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (isDefined(e.target.files))
|
||||
handleUploadAttachment?.(e.target.files[0]);
|
||||
};
|
||||
|
||||
if (isNonEmptyString(block.props.url)) {
|
||||
return (
|
||||
<StyledFileLine>
|
||||
<FileIcon
|
||||
fileCategory={block.props.fileCategory as AttachmentFileCategory}
|
||||
/>
|
||||
<StyledLink href={block.props.url} target="__blank">
|
||||
{block.props.name}
|
||||
</StyledLink>
|
||||
</StyledFileLine>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledUploadFileContainer>
|
||||
<StyledFileInput
|
||||
ref={inputFileRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUploadFileClick}
|
||||
title={t`Upload File`}
|
||||
></Button>
|
||||
</StyledUploadFileContainer>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
|
||||
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 StyledInlineMentionRecordChip = styled(MentionRecordChip)`
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
|
||||
`;
|
||||
|
||||
// Backward-compatible renderer for legacy notes that only stored objectMetadataId + recordId
|
||||
const LegacyMentionRenderer = ({
|
||||
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,
|
||||
},
|
||||
objectNameSingular: {
|
||||
default: '' as const,
|
||||
},
|
||||
label: {
|
||||
default: '' as const,
|
||||
},
|
||||
imageUrl: {
|
||||
default: '' as const,
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
const {
|
||||
recordId,
|
||||
objectMetadataId,
|
||||
objectNameSingular,
|
||||
label,
|
||||
imageUrl,
|
||||
} = props.inlineContent.props;
|
||||
|
||||
// New notes store objectNameSingular + label + imageUrl directly
|
||||
if (isNonEmptyString(objectNameSingular) && isNonEmptyString(label)) {
|
||||
return (
|
||||
<StyledInlineMentionRecordChip
|
||||
recordId={recordId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
label={label}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy notes only have objectMetadataId + recordId: fetch data
|
||||
return (
|
||||
<LegacyMentionRenderer
|
||||
recordId={recordId}
|
||||
objectMetadataId={objectMetadataId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
} from '@blocknote/core';
|
||||
|
||||
import { FileBlock } from '@/blocknote-editor/blocks/FileBlock';
|
||||
import { MentionInlineContent } from '@/blocknote-editor/blocks/MentionInlineContent';
|
||||
|
||||
export const BLOCK_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
file: FileBlock,
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
mention: MentionInlineContent,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { filterSuggestionItems } from '@blocknote/core';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import { SuggestionMenuController } from '@blocknote/react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type ClipboardEvent } from 'react';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
|
||||
import { CustomMentionMenu } from '@/blocknote-editor/components/CustomMentionMenu';
|
||||
import { CustomSideMenu } from '@/blocknote-editor/components/CustomSideMenu';
|
||||
import {
|
||||
CustomSlashMenu,
|
||||
type SuggestionItem,
|
||||
} from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
import { useMentionMenu } from '@/mention/hooks/useMentionMenu';
|
||||
|
||||
interface BlockEditorProps {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
onChange?: () => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line twenty/no-hardcoded-colors
|
||||
const StyledEditor = styled.div`
|
||||
width: 100%;
|
||||
|
||||
& .editor {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
min-height: 400px;
|
||||
}
|
||||
& .editor [class^='_inlineContent']:before {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-style: normal !important;
|
||||
}
|
||||
& .editor .bn-inline-content:has(> .ProseMirror-trailingBreak):before {
|
||||
font-style: normal;
|
||||
}
|
||||
& .mantine-ActionIcon-icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: transparent;
|
||||
}
|
||||
& .bn-container .bn-drag-handle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
& .bn-block-content[data-content-type='checkListItem'] > div > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
& .bn-drag-handle-menu {
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
backdrop-filter: ${({ theme }) => theme.blur.medium};
|
||||
box-shadow:
|
||||
0px 2px 4px rgba(0, 0, 0, 0.04),
|
||||
2px 4px 16px rgba(0, 0, 0, 0.12);
|
||||
min-width: 160px;
|
||||
min-height: 96px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
& .bn-editor {
|
||||
padding-inline: 0px;
|
||||
}
|
||||
|
||||
& .bn-inline-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& .bn-container .bn-suggestion-menu-item:hover {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
& .bn-suggestion-menu {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
backdrop-filter: ${({ theme }) => theme.blur.medium};
|
||||
}
|
||||
|
||||
& .mantine-Menu-item {
|
||||
background-color: transparent;
|
||||
min-width: 152px;
|
||||
min-height: 32px;
|
||||
|
||||
font-style: normal;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
& .mantine-ActionIcon-root:hover {
|
||||
box-shadow:
|
||||
0px 0px 4px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px rgba(0, 0, 0, 0.04);
|
||||
background: ${({ theme }) => theme.background.transparent.primary};
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
& .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
& .bn-mantine .bn-side-menu > [draggable='true'] {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
& .bn-color-picker-dropdown {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
& .bn-inline-content a {
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
font-family: monospace;
|
||||
color: ${({ theme }) => theme.font.color.danger};
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${({ theme }) => theme.font.color.extraLight};
|
||||
font-size: 0.9rem;
|
||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
export const BlockEditor = ({
|
||||
editor,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onPaste,
|
||||
readonly,
|
||||
}: BlockEditorProps) => {
|
||||
const theme = useTheme();
|
||||
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
|
||||
const getMentionItems = useMentionMenu(editor);
|
||||
|
||||
const handleFocus = () => {
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
onPaste?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledEditor>
|
||||
<BlockNoteView
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleChange}
|
||||
editor={editor}
|
||||
theme={blockNoteTheme}
|
||||
slashMenu={false}
|
||||
sideMenu={false}
|
||||
editable={!readonly}
|
||||
>
|
||||
<CustomSideMenu editor={editor} />
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={async (query) =>
|
||||
filterSuggestionItems<SuggestionItem>(getSlashMenu(editor), query)
|
||||
}
|
||||
suggestionMenuComponent={CustomSlashMenu}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={async (query) => getMentionItems(query)}
|
||||
suggestionMenuComponent={CustomMentionMenu}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
</StyledEditor>
|
||||
);
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { isSlashMenuOpenComponentState } from '@/blocknote-editor/states/isSlashMenuOpenComponentState';
|
||||
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
|
||||
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
export type BlockEditorDropdownFocusEffectProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
};
|
||||
|
||||
export const BlockEditorDropdownFocusEffect = ({
|
||||
editor,
|
||||
}: BlockEditorDropdownFocusEffectProps) => {
|
||||
const isSlashMenuOpenState = useRecoilComponentCallbackState(
|
||||
isSlashMenuOpenComponentState,
|
||||
);
|
||||
|
||||
const { setActiveDropdownFocusIdAndMemorizePrevious } =
|
||||
useSetActiveDropdownFocusIdAndMemorizePrevious();
|
||||
|
||||
const { goBackToPreviousDropdownFocusId } =
|
||||
useGoBackToPreviousDropdownFocusId();
|
||||
|
||||
const updateCallBack = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
(event: any) => {
|
||||
// TODO: This triggers before the onClick event of the slash menu item, so the click outside of the editor dropdown is triggered and everything closes.
|
||||
// This is due to useRecoilCallback being executed before the onClick event of the slash menu item.
|
||||
const eventWantsToOpen = event.show === true;
|
||||
|
||||
const isAlreadyOpen = snapshot
|
||||
.getLoadable(isSlashMenuOpenState)
|
||||
.getValue();
|
||||
|
||||
const shouldOpen = eventWantsToOpen && !isAlreadyOpen;
|
||||
|
||||
if (shouldOpen) {
|
||||
setActiveDropdownFocusIdAndMemorizePrevious('custom-slash-menu');
|
||||
set(isSlashMenuOpenState, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventWantsToClose = event.show === false;
|
||||
|
||||
const isAlreadyClosed = !isAlreadyOpen;
|
||||
|
||||
const shouldClose = eventWantsToClose && !isAlreadyClosed;
|
||||
|
||||
if (shouldClose) {
|
||||
goBackToPreviousDropdownFocusId();
|
||||
set(isSlashMenuOpenState, false);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
isSlashMenuOpenState,
|
||||
setActiveDropdownFocusIdAndMemorizePrevious,
|
||||
goBackToPreviousDropdownFocusId,
|
||||
],
|
||||
);
|
||||
|
||||
editor.suggestionMenus.on('update /', updateCallBack);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
|
||||
import { useComponentsContext } from '@blocknote/react';
|
||||
|
||||
type CustomAddBlockItemProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
children: React.ReactNode; // Adding the children prop
|
||||
};
|
||||
|
||||
type ContentItem = {
|
||||
type: string;
|
||||
text: string;
|
||||
styles: any;
|
||||
};
|
||||
export const CustomAddBlockItem = ({
|
||||
editor,
|
||||
children,
|
||||
}: CustomAddBlockItemProps) => {
|
||||
const Components = useComponentsContext();
|
||||
|
||||
if (!Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const blockIdentifier = editor.getTextCursorPosition().block;
|
||||
const currentBlockContent = blockIdentifier?.content as
|
||||
| Array<ContentItem>
|
||||
| undefined;
|
||||
|
||||
const [firstElement] = currentBlockContent || [];
|
||||
|
||||
if (firstElement === undefined) {
|
||||
editor.openSuggestionMenu('/');
|
||||
} else {
|
||||
editor.openSuggestionMenu('/');
|
||||
editor.sideMenu.unfreezeMenu();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Components.Generic.Menu.Item onClick={handleClick}>
|
||||
{children}
|
||||
</Components.Generic.Menu.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
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 { MentionMenuListItem } from '@/mention/components/MentionMenuListItem';
|
||||
import {
|
||||
type CustomMentionMenuProps,
|
||||
type MentionItem,
|
||||
} from '@/blocknote-editor/types/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 { 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 (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<OverlayContainer
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
data-click-outside-id={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<DropdownContent widthInPixels={MenuPixelWidth}>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{filteredItems.map((item, index) => (
|
||||
<MentionMenuListItem
|
||||
key={item.recordId!}
|
||||
recordId={item.recordId!}
|
||||
objectNameSingular={item.objectNameSingular!}
|
||||
label={item.label ?? item.title}
|
||||
imageUrl={item.imageUrl ?? ''}
|
||||
objectLabelSingular={item.objectLabelSingular ?? ''}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { CustomAddBlockItem } from '@/blocknote-editor/components/CustomAddBlockItem';
|
||||
import { CustomSideMenuOptions } from '@/blocknote-editor/components/CustomSideMenuOptions';
|
||||
import {
|
||||
BlockColorsItem,
|
||||
DragHandleButton,
|
||||
DragHandleMenu,
|
||||
RemoveBlockItem,
|
||||
SideMenu,
|
||||
SideMenuController,
|
||||
} from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
|
||||
|
||||
type CustomSideMenuProps = {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
};
|
||||
|
||||
const StyledDivToCreateGap = styled.div`
|
||||
width: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const CustomSideMenu = ({ editor }: CustomSideMenuProps) => {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<SideMenuController
|
||||
sideMenu={(props) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<SideMenu {...props}>
|
||||
<DragHandleButton
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
dragHandleMenu={(props) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<DragHandleMenu {...props}>
|
||||
<CustomAddBlockItem editor={editor}>
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconPlus}
|
||||
text={t`Add Block`}
|
||||
Variant="normal"
|
||||
/>
|
||||
</CustomAddBlockItem>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<BlockColorsItem {...props}>
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconColorSwatch}
|
||||
text={t`Change Color`}
|
||||
Variant="normal"
|
||||
/>
|
||||
</BlockColorsItem>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<RemoveBlockItem {...props}>
|
||||
{' '}
|
||||
<CustomSideMenuOptions
|
||||
LeftIcon={IconTrash}
|
||||
text={t`Delete`}
|
||||
Variant="danger"
|
||||
/>
|
||||
</RemoveBlockItem>
|
||||
</DragHandleMenu>
|
||||
)}
|
||||
/>
|
||||
<StyledDivToCreateGap />
|
||||
</SideMenu>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
|
||||
const StyledContainer = styled.div<{ Variant: Variants }>`
|
||||
color: ${({ theme, Variant }) =>
|
||||
Variant === 'danger' ? theme.color.red : 'inherit'};
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTextContainer = styled.div``;
|
||||
|
||||
type CustomSideMenuOptionsProps = {
|
||||
LeftIcon: IconComponent; // Any valid React node (e.g., a component)
|
||||
Variant: Variants;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Variants = 'normal' | 'danger';
|
||||
|
||||
export const CustomSideMenuOptions = ({
|
||||
LeftIcon,
|
||||
Variant,
|
||||
text,
|
||||
}: CustomSideMenuOptionsProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledContainer Variant={Variant}>
|
||||
<LeftIcon
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
></LeftIcon>
|
||||
<StyledTextContainer>{text}</StyledTextContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
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 { 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 '@/blocknote-editor/components/CustomSlashMenuListItem';
|
||||
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect';
|
||||
import type {
|
||||
CustomSlashMenuProps,
|
||||
SuggestionItem,
|
||||
} from '@/blocknote-editor/types/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';
|
||||
|
||||
export type { SuggestionItem };
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const CustomSlashMenu = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
}: CustomSlashMenuProps) => {
|
||||
const editor = useBlockNoteEditor();
|
||||
|
||||
const currentBlock = editor?.getTextCursorPosition()?.block;
|
||||
const blockType = currentBlock?.type;
|
||||
const headingLevel =
|
||||
blockType === 'heading' ? (currentBlock?.props?.level as number) : null;
|
||||
|
||||
const getOffsetValue = (placement: string) => {
|
||||
if (!placement.startsWith('top')) return 0;
|
||||
|
||||
switch (headingLevel) {
|
||||
case 1:
|
||||
return 65;
|
||||
case 2:
|
||||
return 50;
|
||||
case 3:
|
||||
return 45;
|
||||
default:
|
||||
return 40;
|
||||
}
|
||||
};
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [flip(), offset(({ placement }) => getOffsetValue(placement))],
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<CustomSlashMenuSelectedIndexSyncEffect
|
||||
items={items}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<OverlayContainer
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<SelectableList
|
||||
focusId={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
selectableListInstanceId={SLASH_MENU_LIST_ID}
|
||||
selectableItemIdArray={items.map((item) => item.title)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<CustomSlashMenuListItem key={item.title} item={item} />
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/types';
|
||||
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 { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
|
||||
export type CustomSlashMenuListItemProps = {
|
||||
item: SuggestionItem;
|
||||
};
|
||||
|
||||
export const CustomSlashMenuListItem = ({
|
||||
item,
|
||||
}: CustomSlashMenuListItemProps) => {
|
||||
const { resetSelectedItem } = useSelectableList(SLASH_MENU_LIST_ID);
|
||||
|
||||
const isSelectedItem = useRecoilComponentFamilyValue(
|
||||
isSelectedItemIdComponentFamilySelector,
|
||||
item.title,
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
resetSelectedItem();
|
||||
item.onItemClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableListItem itemId={item.title} onEnter={handleClick}>
|
||||
<MenuItemSuggestion
|
||||
selected={isSelectedItem}
|
||||
onClick={handleClick}
|
||||
LeftIcon={item.Icon}
|
||||
text={item.title}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/types';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CustomSlashMenuSelectedIndexSyncEffectProps = {
|
||||
items: SuggestionItem[];
|
||||
selectedIndex: number | undefined;
|
||||
};
|
||||
|
||||
export const CustomSlashMenuSelectedIndexSyncEffect = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
}: CustomSlashMenuSelectedIndexSyncEffectProps) => {
|
||||
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 <></>;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG = {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
enableGlobalHotkeysWithModifiers: true,
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const BlockEditorComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type Attachment } from '@/activities/files/types/Attachment';
|
||||
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
|
||||
import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate';
|
||||
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
|
||||
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
|
||||
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
|
||||
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useAttachmentSync = (attachments: Attachment[]) => {
|
||||
const isFilesFieldMigrated = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
|
||||
);
|
||||
const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Attachment,
|
||||
});
|
||||
|
||||
const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Attachment,
|
||||
});
|
||||
|
||||
const { fetchAllRecords: findSoftDeletedAttachments } =
|
||||
useLazyFetchAllRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Attachment,
|
||||
filter: {
|
||||
deletedAt: {
|
||||
is: 'NOT_NULL',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
|
||||
const syncAttachments = async (
|
||||
newBody: string,
|
||||
previousBody?: string | null,
|
||||
) => {
|
||||
if (!newBody) return;
|
||||
|
||||
const previousBodyOrEmptyArray = previousBody?.trim() ? previousBody : '[]';
|
||||
|
||||
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
|
||||
newBody,
|
||||
attachments,
|
||||
previousBodyOrEmptyArray,
|
||||
isFilesFieldMigrated,
|
||||
);
|
||||
|
||||
if (attachmentIdsToDelete.length > 0) {
|
||||
await deleteAttachments({
|
||||
recordIdsToDelete: attachmentIdsToDelete,
|
||||
});
|
||||
}
|
||||
|
||||
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
|
||||
newBody,
|
||||
attachments,
|
||||
isFilesFieldMigrated,
|
||||
);
|
||||
|
||||
if (attachmentPathsToRestore.length > 0) {
|
||||
const softDeletedAttachments =
|
||||
(await findSoftDeletedAttachments()) as Attachment[];
|
||||
|
||||
const attachmentIdsToRestore = filterAttachmentsToRestore({
|
||||
attachmentPathsToRestore,
|
||||
softDeletedAttachments: softDeletedAttachments ?? [],
|
||||
isFilesFieldMigrated,
|
||||
});
|
||||
|
||||
await restoreAttachments({
|
||||
idsToRestore: attachmentIdsToRestore,
|
||||
});
|
||||
}
|
||||
|
||||
const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate(
|
||||
newBody,
|
||||
attachments,
|
||||
isFilesFieldMigrated,
|
||||
);
|
||||
|
||||
for (const attachmentToUpdate of attachmentsToUpdate) {
|
||||
if (!attachmentToUpdate.id || !attachmentToUpdate.name) continue;
|
||||
await updateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Attachment,
|
||||
idToUpdate: attachmentToUpdate.id,
|
||||
updateOneRecordInput: { name: attachmentToUpdate.name },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { syncAttachments };
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { BlockEditorComponentInstanceContext } from '@/blocknote-editor/contexts/BlockEditorCompoponeInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const isSlashMenuOpenComponentState = createComponentState<boolean>({
|
||||
key: 'isSlashMenuOpenComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: BlockEditorComponentInstanceContext,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuProps,
|
||||
} from '@blocknote/react';
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
|
||||
export type SuggestionItem = DefaultReactSuggestionItem & {
|
||||
aliases?: string[];
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
export type CustomSlashMenuProps = SuggestionMenuProps<SuggestionItem>;
|
||||
|
||||
export type MentionItem = DefaultReactSuggestionItem & {
|
||||
recordId?: string;
|
||||
objectNameSingular?: string;
|
||||
objectMetadataId?: string;
|
||||
label?: string;
|
||||
imageUrl?: string;
|
||||
objectLabelSingular?: string;
|
||||
};
|
||||
|
||||
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
|
||||
describe('getFirstNonEmptyLineOfRichText', () => {
|
||||
it('should return an empty string if the input is null', () => {
|
||||
const result = getFirstNonEmptyLineOfRichText(null);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return an empty string if the input is an empty array', () => {
|
||||
const result = getFirstNonEmptyLineOfRichText([]);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return the first non-empty line of text', () => {
|
||||
const input: PartialBlock[] = [
|
||||
{ content: [{ text: '', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: ' ', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: 'Second line', type: 'text', styles: {} }] },
|
||||
];
|
||||
const result = getFirstNonEmptyLineOfRichText(input);
|
||||
expect(result).toBe('First non-empty line');
|
||||
});
|
||||
|
||||
it('should return an empty string if all lines are empty', () => {
|
||||
const input: PartialBlock[] = [
|
||||
{ content: [{ text: '', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: ' ', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: '\n', type: 'text', styles: {} }] },
|
||||
];
|
||||
const result = getFirstNonEmptyLineOfRichText(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle mixed content correctly', () => {
|
||||
const input: PartialBlock[] = [
|
||||
{ content: [{ text: '', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: ' ', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
|
||||
{ content: [{ text: '', type: 'text', styles: {} }] },
|
||||
{
|
||||
content: [{ text: 'Second non-empty line', type: 'text', styles: {} }],
|
||||
},
|
||||
];
|
||||
const result = getFirstNonEmptyLineOfRichText(input);
|
||||
expect(result).toBe('First non-empty line');
|
||||
});
|
||||
|
||||
it('should handle content with multiple text objects correctly', () => {
|
||||
const input: PartialBlock[] = [
|
||||
{
|
||||
content: [
|
||||
{ text: '', type: 'text', styles: {} },
|
||||
{ text: ' ', type: 'text', styles: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{ text: 'First non-empty line', type: 'text', styles: {} },
|
||||
{ text: 'Second line', type: 'text', styles: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = getFirstNonEmptyLineOfRichText(input);
|
||||
expect(result).toBe('First non-empty line');
|
||||
});
|
||||
|
||||
it('should handle content with undefined or null content', () => {
|
||||
const input: PartialBlock[] = [
|
||||
{ content: undefined },
|
||||
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
|
||||
];
|
||||
const result = getFirstNonEmptyLineOfRichText(input);
|
||||
expect(result).toBe('First non-empty line');
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
|
||||
|
||||
describe('parseInitialBlocknote', () => {
|
||||
it('should parse valid JSON array string', () => {
|
||||
const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]);
|
||||
const result = parseInitialBlocknote(input);
|
||||
expect(result).toEqual([{ type: 'paragraph', content: 'test' }]);
|
||||
});
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(parseInitialBlocknote('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for null', () => {
|
||||
expect(parseInitialBlocknote(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for undefined', () => {
|
||||
expect(parseInitialBlocknote(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty object string "{}"', () => {
|
||||
expect(parseInitialBlocknote('{}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid JSON', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
expect(parseInitialBlocknote('invalid json')).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return undefined for empty array', () => {
|
||||
expect(parseInitialBlocknote('[]')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-array JSON', () => {
|
||||
expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use custom log context when parsing fails', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
parseInitialBlocknote('invalid', 'Custom context');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Custom context');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
|
||||
|
||||
describe('prepareBodyWithSignedUrls', () => {
|
||||
it('should return empty string as-is', () => {
|
||||
expect(prepareBodyWithSignedUrls('')).toBe('');
|
||||
});
|
||||
|
||||
it('should parse and re-stringify blocks', () => {
|
||||
const input = JSON.stringify([{ type: 'paragraph', content: 'text' }]);
|
||||
const result = JSON.parse(prepareBodyWithSignedUrls(input));
|
||||
expect(result).toEqual([{ type: 'paragraph', content: 'text' }]);
|
||||
});
|
||||
|
||||
it('should pass through non-image blocks unchanged', () => {
|
||||
const blocks = [
|
||||
{ type: 'paragraph', content: 'text' },
|
||||
{ type: 'heading', content: 'title' },
|
||||
{ type: 'bulletListItem', content: 'item' },
|
||||
];
|
||||
const result = JSON.parse(
|
||||
prepareBodyWithSignedUrls(JSON.stringify(blocks)),
|
||||
);
|
||||
expect(result).toEqual(blocks);
|
||||
});
|
||||
|
||||
it('should skip image blocks without props', () => {
|
||||
const input = JSON.stringify([{ type: 'image' }]);
|
||||
const result = JSON.parse(prepareBodyWithSignedUrls(input));
|
||||
expect(result).toEqual([{ type: 'image' }]);
|
||||
});
|
||||
|
||||
it('should skip image blocks without url in props', () => {
|
||||
const input = JSON.stringify([{ type: 'image', props: { alt: 'test' } }]);
|
||||
const result = JSON.parse(prepareBodyWithSignedUrls(input));
|
||||
expect(result).toEqual([{ type: 'image', props: { alt: 'test' } }]);
|
||||
});
|
||||
|
||||
it('should process image blocks with valid URLs', () => {
|
||||
const input = JSON.stringify([
|
||||
{ type: 'image', props: { url: 'https://example.com/image.png' } },
|
||||
]);
|
||||
const result = JSON.parse(prepareBodyWithSignedUrls(input));
|
||||
expect(result[0].type).toBe('image');
|
||||
expect(result[0].props.url).toContain('example.com');
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
export const getFirstNonEmptyLineOfRichText = (
|
||||
blocks: PartialBlock[] | null,
|
||||
): string => {
|
||||
if (blocks === null) {
|
||||
return '';
|
||||
}
|
||||
for (const block of blocks) {
|
||||
if (!isUndefinedOrNull(block.content)) {
|
||||
const contentArray = block.content as Array<
|
||||
{ text: string } | { link: string }
|
||||
>;
|
||||
if (contentArray.length > 0) {
|
||||
for (const content of contentArray) {
|
||||
if ('link' in content) {
|
||||
return content.link;
|
||||
}
|
||||
if ('text' in content) {
|
||||
const value = content.text.trim();
|
||||
if (value !== '') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
|
||||
|
||||
import { type SuggestionItem } from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconCode,
|
||||
type IconComponent,
|
||||
IconFile,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconHeadphones,
|
||||
IconList,
|
||||
IconListCheck,
|
||||
IconListNumbers,
|
||||
IconMoodSmile,
|
||||
IconPhoto,
|
||||
IconPilcrow,
|
||||
IconTable,
|
||||
IconVideo,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
const Icons: Record<string, IconComponent> = {
|
||||
'Heading 1': IconH1,
|
||||
'Heading 2': IconH2,
|
||||
'Heading 3': IconH3,
|
||||
Quote: IconBlockquote,
|
||||
'Numbered List': IconListNumbers,
|
||||
'Bullet List': IconList,
|
||||
'Check List': IconListCheck,
|
||||
'Code Block': IconCode,
|
||||
Paragraph: IconPilcrow,
|
||||
Table: IconTable,
|
||||
Image: IconPhoto,
|
||||
Video: IconVideo,
|
||||
Audio: IconHeadphones,
|
||||
Emoji: IconMoodSmile,
|
||||
};
|
||||
|
||||
export const getSlashMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
|
||||
const items: SuggestionItem[] = [
|
||||
...getDefaultReactSlashMenuItems(editor).map((x) => ({
|
||||
...x,
|
||||
Icon: Icons[x.title],
|
||||
})),
|
||||
{
|
||||
title: 'File',
|
||||
aliases: ['file', 'folder'],
|
||||
Icon: IconFile,
|
||||
onItemClick: () => {
|
||||
const currentBlock = editor.getTextCursorPosition().block;
|
||||
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: 'file',
|
||||
props: {
|
||||
url: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
currentBlock,
|
||||
'before',
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return items;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { isArray, isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const parseInitialBlocknote = (
|
||||
blocknote?: string | null,
|
||||
logContext?: string,
|
||||
): PartialBlock[] | undefined => {
|
||||
if (isNonEmptyString(blocknote) && blocknote !== '{}') {
|
||||
let parsedBody: PartialBlock[] | undefined = undefined;
|
||||
|
||||
// TODO: Remove this once we have removed the old rich text
|
||||
try {
|
||||
parsedBody = JSON.parse(blocknote);
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(logContext ?? `Failed to parse blocknote body`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(blocknote);
|
||||
}
|
||||
|
||||
if (!isArray(parsedBody) || parsedBody.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsedBody;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
|
||||
// TODO: This function is extracted but its not doing what it is supposed to do. It is not signing the urls. It is just parsing the image urls.
|
||||
// tracking issue - https://github.com/twentyhq/twenty/issues/8351
|
||||
export const prepareBodyWithSignedUrls = (
|
||||
newStringifiedBody: string,
|
||||
): string => {
|
||||
if (!newStringifiedBody) return newStringifiedBody;
|
||||
|
||||
const body: PartialBlock[] = JSON.parse(newStringifiedBody);
|
||||
|
||||
const bodyWithSignedPayload = body.map((block) => {
|
||||
if (block.type !== 'image' || !block.props?.url) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const imageUrl = block.props.url;
|
||||
const parsedImageUrl = new URL(imageUrl);
|
||||
|
||||
return {
|
||||
...block,
|
||||
props: {
|
||||
...block.props,
|
||||
url: parsedImageUrl.toString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return JSON.stringify(bodyWithSignedPayload);
|
||||
};
|
||||
Reference in New Issue
Block a user