Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512774bd36 | ||
|
|
b7434451aa | ||
|
|
f91b558399 | ||
|
|
1855124c0e | ||
|
|
bc83f4ad63 | ||
|
|
a0204fc6b9 | ||
|
|
80f59f80f8 | ||
|
|
89070fbf3c | ||
|
|
df513d91ca | ||
|
|
70a2728552 | ||
|
|
22979fbde4 | ||
|
|
b7aa32f034 | ||
|
|
f68dd72da3 | ||
|
|
fc01449921 | ||
|
|
98951653ac | ||
|
|
086267aea8 | ||
|
|
b7646fc6d4 | ||
|
|
ad66b21f78 | ||
|
|
5a39f3f1c9 | ||
|
|
b3147088f6 | ||
|
|
6581b9294c | ||
|
|
1ae428ae24 |
+95
@@ -0,0 +1,95 @@
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
import { Chip, ChipVariant } from 'twenty-ui/components';
|
||||
|
||||
const StyledRecordChip = styled(RecordChip)`
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
|
||||
`;
|
||||
|
||||
const MentionInlineContentRenderer = ({
|
||||
recordId,
|
||||
objectMetadataId,
|
||||
}: {
|
||||
recordId: string;
|
||||
objectMetadataId: string;
|
||||
}) => {
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.id === objectMetadataId,
|
||||
);
|
||||
|
||||
const objectNameSingular = objectMetadataItem?.nameSingular ?? '';
|
||||
|
||||
const { record, loading } = useFindOneRecord({
|
||||
objectNameSingular,
|
||||
objectRecordId: recordId,
|
||||
skip: !objectNameSingular || !recordId,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<Chip
|
||||
label={t`Deleted record`}
|
||||
variant={ChipVariant.Transparent}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!objectNameSingular) {
|
||||
return (
|
||||
<Chip
|
||||
label={t`Unknown object`}
|
||||
variant={ChipVariant.Transparent}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRecordChip
|
||||
objectNameSingular={objectNameSingular}
|
||||
record={record}
|
||||
forceDisableClick={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
type: 'mention' as const,
|
||||
propSchema: {
|
||||
recordId: {
|
||||
default: '' as const,
|
||||
},
|
||||
objectMetadataId: {
|
||||
default: '' as const,
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
const { recordId, objectMetadataId } = props.inlineContent.props;
|
||||
|
||||
return (
|
||||
<MentionInlineContentRenderer
|
||||
recordId={recordId}
|
||||
objectMetadataId={objectMetadataId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,10 +1,19 @@
|
||||
import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
} from '@blocknote/core';
|
||||
|
||||
import { FileBlock } from '@/activities/blocks/components/FileBlock';
|
||||
import { MentionInlineContent } from '@/activities/blocks/components/MentionInlineContent';
|
||||
|
||||
export const BLOCK_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
file: FileBlock,
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
mention: MentionInlineContent,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
@@ -223,6 +224,9 @@ export const ActivityRichTextEditor = ({
|
||||
domAttributes: { editor: { class: 'editor' } },
|
||||
schema: BLOCK_SCHEMA,
|
||||
uploadFile: handleEditorBuiltInUploadFile,
|
||||
placeholders: {
|
||||
default: t`Type '/' for commands, '@' for mentions`,
|
||||
},
|
||||
});
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { isSidePanelAnimatingState } from '@/command-menu/states/isSidePanelAnimatingState';
|
||||
import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
|
||||
import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId';
|
||||
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
|
||||
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
|
||||
@@ -79,6 +80,7 @@ export const CommandMenuOpenContainer = ({
|
||||
LINK_CHIP_CLICK_OUTSIDE_ID,
|
||||
RECORD_CHIP_CLICK_OUTSIDE_ID,
|
||||
SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
|
||||
MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
|
||||
WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID,
|
||||
WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID,
|
||||
WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID,
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePush
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
@@ -95,6 +96,9 @@ export const StandaloneRichTextEditorContent = ({
|
||||
schema: BLOCK_SCHEMA,
|
||||
uploadFile: handleEditorBuiltInUploadFile,
|
||||
sideMenuDetection: 'editor',
|
||||
placeholders: {
|
||||
default: t`Type '/' for commands, '@' for mentions`,
|
||||
},
|
||||
});
|
||||
|
||||
const handlePersistBody = useDebouncedCallback((blocknote: string) => {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID =
|
||||
'mention-menu-dropdown-click-outside-id';
|
||||
@@ -0,0 +1 @@
|
||||
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';
|
||||
@@ -7,11 +7,13 @@ import { type ClipboardEvent } from 'react';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
|
||||
import { CustomMentionMenu } from '@/ui/input/editor/components/CustomMentionMenu';
|
||||
import { CustomSideMenu } from '@/ui/input/editor/components/CustomSideMenu';
|
||||
import {
|
||||
CustomSlashMenu,
|
||||
type SuggestionItem,
|
||||
} from '@/ui/input/editor/components/CustomSlashMenu';
|
||||
import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu';
|
||||
|
||||
interface BlockEditorProps {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
@@ -141,6 +143,7 @@ export const BlockEditor = ({
|
||||
}: BlockEditorProps) => {
|
||||
const theme = useTheme();
|
||||
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
|
||||
const getMentionItems = useMentionMenu(editor);
|
||||
|
||||
const handleFocus = () => {
|
||||
onFocus?.();
|
||||
@@ -179,6 +182,11 @@ export const BlockEditor = ({
|
||||
}
|
||||
suggestionMenuComponent={CustomSlashMenu}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={async (query) => getMentionItems(query)}
|
||||
suggestionMenuComponent={CustomMentionMenu}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
</StyledEditor>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { autoUpdate, useFloating } from '@floating-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
|
||||
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
|
||||
import { CustomMentionMenuListItem } from '@/ui/input/editor/components/CustomMentionMenuListItem';
|
||||
import { CustomMentionMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect';
|
||||
import {
|
||||
type CustomMentionMenuProps,
|
||||
type MentionItem,
|
||||
} from '@/ui/input/editor/components/types';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type { MentionItem };
|
||||
|
||||
const MenuPixelWidth = 240;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const CustomMentionMenu = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
onItemClick,
|
||||
}: CustomMentionMenuProps) => {
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const handleContainerClick = (e: ReactMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
if (!isDefined(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(item) =>
|
||||
isDefined(item.recordId) &&
|
||||
isDefined(item.objectNameSingular) &&
|
||||
isDefined(item.objectMetadataId),
|
||||
);
|
||||
|
||||
return (
|
||||
<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
@@ -0,0 +1,69 @@
|
||||
import { type MouseEvent } from 'react';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
|
||||
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
|
||||
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
||||
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Avatar } from 'twenty-ui/display';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
|
||||
type CustomMentionMenuListItemProps = {
|
||||
recordId: string;
|
||||
onClick: () => void;
|
||||
objectNameSingular: string;
|
||||
};
|
||||
|
||||
export const CustomMentionMenuListItem = ({
|
||||
recordId,
|
||||
onClick,
|
||||
objectNameSingular,
|
||||
}: CustomMentionMenuListItemProps) => {
|
||||
const { resetSelectedItem } = useSelectableList(MENTION_MENU_LIST_ID);
|
||||
|
||||
const isSelectedItem = useRecoilComponentFamilyValue(
|
||||
isSelectedItemIdComponentFamilySelector,
|
||||
recordId,
|
||||
);
|
||||
|
||||
const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId));
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
|
||||
|
||||
const handleClick = (event?: MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
resetSelectedItem();
|
||||
onClick();
|
||||
};
|
||||
|
||||
if (!isDefined(searchRecord)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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
@@ -0,0 +1,29 @@
|
||||
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
|
||||
import { type MentionItem } from '@/ui/input/editor/components/types';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CustomMentionMenuSelectedIndexSyncEffectProps = {
|
||||
items: MentionItem[];
|
||||
selectedIndex: number | undefined;
|
||||
};
|
||||
|
||||
export const CustomMentionMenuSelectedIndexSyncEffect = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
}: CustomMentionMenuSelectedIndexSyncEffectProps) => {
|
||||
const { setSelectedItemId } = useSelectableList(MENTION_MENU_LIST_ID);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(selectedIndex) || !isDefined(items)) return;
|
||||
|
||||
const selectedItem = items[selectedIndex];
|
||||
|
||||
if (isDefined(selectedItem) && isDefined(selectedItem.recordId)) {
|
||||
setSelectedItemId(selectedItem.recordId);
|
||||
}
|
||||
}, [items, selectedIndex, setSelectedItemId]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@@ -2,12 +2,12 @@ import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
|
||||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { CustomSlashMenuListItem } from '@/ui/input/editor/components/CustomSlashMenuListItem';
|
||||
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect';
|
||||
import type {
|
||||
CustomSlashMenuProps,
|
||||
SuggestionItem,
|
||||
@@ -16,8 +16,6 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type { SuggestionItem };
|
||||
|
||||
@@ -58,20 +56,12 @@ export const CustomSlashMenu = ({
|
||||
middleware: [flip(), offset(({ placement }) => getOffsetValue(placement))],
|
||||
});
|
||||
|
||||
const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(selectedIndex)) return;
|
||||
|
||||
const selectedItem = items[selectedIndex];
|
||||
|
||||
if (isDefined(selectedItem)) {
|
||||
setSelectedItemId(selectedItem.title);
|
||||
}
|
||||
}, [items, selectedIndex, setSelectedItemId]);
|
||||
|
||||
return (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<CustomSlashMenuSelectedIndexSyncEffect
|
||||
items={items}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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,11 +1,20 @@
|
||||
import type { SuggestionMenuProps } from '@blocknote/react';
|
||||
import type {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuProps,
|
||||
} from '@blocknote/react';
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
|
||||
export type SuggestionItem = {
|
||||
title: string;
|
||||
onItemClick: () => void;
|
||||
export type SuggestionItem = DefaultReactSuggestionItem & {
|
||||
aliases?: string[];
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
export type CustomSlashMenuProps = SuggestionMenuProps<SuggestionItem>;
|
||||
|
||||
export type MentionItem = DefaultReactSuggestionItem & {
|
||||
recordId?: string;
|
||||
objectNameSingular?: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
|
||||
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
|
||||
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
|
||||
import { type MentionItem } from '@/ui/input/editor/components/types';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import {
|
||||
type SearchQuery,
|
||||
type SearchQueryVariables,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
|
||||
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
||||
|
||||
const searchableObjectMetadataItems = useMemo(
|
||||
() =>
|
||||
activeObjectMetadataItems.filter(
|
||||
(item) =>
|
||||
!item.isSystem &&
|
||||
item.isSearchable &&
|
||||
getObjectPermissionsFromMapByObjectMetadataId({
|
||||
objectPermissionsByObjectMetadataId,
|
||||
objectMetadataId: item.id,
|
||||
}).canReadObjectRecords === true,
|
||||
),
|
||||
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
|
||||
);
|
||||
|
||||
const objectsToSearch = useMemo(
|
||||
() => searchableObjectMetadataItems.map(({ nameSingular }) => nameSingular),
|
||||
[searchableObjectMetadataItems],
|
||||
);
|
||||
|
||||
const getMentionItems = useRecoilCallback(
|
||||
({ set }) =>
|
||||
async (query: string): Promise<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
-8
@@ -56,15 +56,15 @@ export const DropdownMenuItemsContainer = ({
|
||||
scrollable?: boolean;
|
||||
}) => {
|
||||
return scrollable === true ? (
|
||||
<StyledScrollableContainer
|
||||
maxHeight={
|
||||
hasMaxHeight ? DROPDOWN_MENU_ITEMS_CONTAINER_MAX_HEIGHT : undefined
|
||||
}
|
||||
>
|
||||
<StyledExternalContainer role="listbox">
|
||||
<StyledExternalContainer role="listbox">
|
||||
<StyledScrollableContainer
|
||||
maxHeight={
|
||||
hasMaxHeight ? DROPDOWN_MENU_ITEMS_CONTAINER_MAX_HEIGHT : undefined
|
||||
}
|
||||
>
|
||||
<StyledInternalContainer>{children}</StyledInternalContainer>
|
||||
</StyledExternalContainer>
|
||||
</StyledScrollableContainer>
|
||||
</StyledScrollableContainer>
|
||||
</StyledExternalContainer>
|
||||
) : (
|
||||
<StyledExternalContainer role="listbox">
|
||||
<StyledInternalContainer>{children}</StyledInternalContainer>
|
||||
|
||||
@@ -6950,6 +6950,8 @@ export const TwentyUiMenuItemSelectTagElement = createRemoteElement<
|
||||
export type TwentyUiMenuItemSuggestionProperties = {
|
||||
withIconContainer?: boolean;
|
||||
text: string;
|
||||
contextualText?: string;
|
||||
contextualTextPosition?: string;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
@@ -6964,6 +6966,8 @@ export const TwentyUiMenuItemSuggestionElement = createRemoteElement<
|
||||
properties: {
|
||||
withIconContainer: { type: Boolean },
|
||||
text: { type: String },
|
||||
contextualText: { type: String },
|
||||
contextualTextPosition: { type: String },
|
||||
selected: { type: Boolean },
|
||||
className: { type: String },
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ const StyledLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
vertical-align: middle;
|
||||
`;
|
||||
|
||||
export const LinkChip = ({
|
||||
|
||||
@@ -10,6 +10,8 @@ export type MenuItemSuggestionProps = {
|
||||
LeftIcon?: IconComponent | null;
|
||||
withIconContainer?: boolean;
|
||||
text: string;
|
||||
contextualText?: string;
|
||||
contextualTextPosition?: 'left' | 'right';
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent<HTMLLIElement>) => void;
|
||||
@@ -55,6 +57,8 @@ export const MenuItemSuggestion = ({
|
||||
LeftIcon,
|
||||
withIconContainer = false,
|
||||
text,
|
||||
contextualText = undefined,
|
||||
contextualTextPosition = 'left',
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
@@ -77,6 +81,8 @@ export const MenuItemSuggestion = ({
|
||||
<MenuItemLeftContent
|
||||
LeftIcon={LeftIcon ?? undefined}
|
||||
text={text}
|
||||
contextualText={contextualText}
|
||||
contextualTextPosition={contextualTextPosition}
|
||||
withIconContainer={withIconContainer}
|
||||
/>
|
||||
</StyledMenuItemLeftContent>
|
||||
|
||||
Reference in New Issue
Block a user