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:
@@ -1 +0,0 @@
|
||||
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';
|
||||
@@ -1,193 +0,0 @@
|
||||
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 '@/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;
|
||||
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
@@ -1,66 +0,0 @@
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { isSlashMenuOpenComponentState } from '@/ui/input/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 <></>;
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/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>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
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 (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<CustomMentionMenuSelectedIndexSyncEffect
|
||||
items={filteredItems}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
<>
|
||||
{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>
|
||||
<SelectableList
|
||||
focusId={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
|
||||
selectableListInstanceId={MENTION_MENU_LIST_ID}
|
||||
selectableItemIdArray={filteredItems.map(
|
||||
(item) => item.recordId!,
|
||||
)}
|
||||
>
|
||||
{filteredItems.map((item) => (
|
||||
<CustomMentionMenuListItem
|
||||
key={item.recordId!}
|
||||
recordId={item.recordId!}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
objectNameSingular={item.objectNameSingular!}
|
||||
/>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
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 (
|
||||
<SelectableListItem itemId={recordId} onEnter={handleClick}>
|
||||
<MenuItemSuggestion
|
||||
selected={isSelectedItem}
|
||||
onClick={handleClick}
|
||||
text={`${searchRecord.label}`}
|
||||
contextualText={objectMetadataItem.labelSingular}
|
||||
contextualTextPosition="left"
|
||||
LeftIcon={() => (
|
||||
<Avatar
|
||||
placeholder={searchRecord.label}
|
||||
placeholderColorSeed={recordId}
|
||||
avatarUrl={searchRecord.imageUrl}
|
||||
type={getAvatarType(objectNameSingular) ?? 'rounded'}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
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 <></>;
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { CustomAddBlockItem } from '@/ui/input/editor/components/CustomAddBlockItem';
|
||||
import { CustomSideMenuOptions } from '@/ui/input/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
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
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 '@/ui/input/editor/components/CustomSlashMenuListItem';
|
||||
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect';
|
||||
import type {
|
||||
CustomSlashMenuProps,
|
||||
SuggestionItem,
|
||||
} 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';
|
||||
|
||||
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
@@ -1,38 +0,0 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/ui/input/editor/components/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
@@ -1,29 +0,0 @@
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } 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 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 <></>;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
export const BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG = {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
enableGlobalHotkeysWithModifiers: true,
|
||||
};
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const BlockEditorComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
@@ -1,97 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
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<MentionItem[]> => {
|
||||
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;
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { BlockEditorComponentInstanceContext } from '@/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const isSlashMenuOpenComponentState = createComponentState<boolean>({
|
||||
key: 'isSlashMenuOpenComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: BlockEditorComponentInstanceContext,
|
||||
});
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/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
@@ -1,47 +0,0 @@
|
||||
import { parseInitialBlocknote } from '@/ui/input/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
@@ -1,46 +0,0 @@
|
||||
import { prepareBodyWithSignedUrls } from '@/ui/input/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
@@ -1,32 +0,0 @@
|
||||
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 '';
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
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