8414 add records selection context inside the command menu (#8610)
Closes #8414 https://github.com/user-attachments/assets/a6aeb50a-b57d-43db-a839-4627c49b4155
This commit is contained in:
@ -5,13 +5,21 @@ import { Note } from '@/activities/types/Note';
|
||||
import { Task } from '@/activities/types/Task';
|
||||
import { CommandGroup } from '@/command-menu/components/CommandGroup';
|
||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
|
||||
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
|
||||
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { Command, CommandType } from '@/command-menu/types/Command';
|
||||
import {
|
||||
Command,
|
||||
CommandScope,
|
||||
CommandType,
|
||||
} from '@/command-menu/types/Command';
|
||||
import { Company } from '@/companies/types/Company';
|
||||
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId';
|
||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
|
||||
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
@ -27,6 +35,7 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
@ -40,16 +49,12 @@ import {
|
||||
IconComponent,
|
||||
IconNotes,
|
||||
IconSparkles,
|
||||
IconX,
|
||||
LightIconButton,
|
||||
isDefined,
|
||||
} from 'twenty-ui';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { getLogoUrlFromDomainName } from '~/utils';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
const SEARCH_BAR_HEIGHT = 56;
|
||||
const SEARCH_BAR_PADDING = 3;
|
||||
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
|
||||
|
||||
type CommandGroupConfig = {
|
||||
@ -80,48 +85,6 @@ const StyledCommandMenu = styled.div`
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: none;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: 0;
|
||||
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
height: ${SEARCH_BAR_HEIGHT}px;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
position: relative;
|
||||
|
||||
padding: 0 ${({ theme }) => theme.spacing(SEARCH_BAR_PADDING)};
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
margin: 0;
|
||||
outline: none;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
width: ${({ theme }) => `calc(100% - ${theme.spacing(8)})`};
|
||||
|
||||
&::placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCloseButtonContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledList = styled.div`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
overscroll-behavior: contain;
|
||||
@ -132,10 +95,12 @@ const StyledList = styled.div`
|
||||
const StyledInnerList = styled.div<{ isMobile: boolean }>`
|
||||
max-height: ${({ isMobile }) =>
|
||||
isMobile
|
||||
? `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${
|
||||
SEARCH_BAR_PADDING * 2
|
||||
? `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
|
||||
COMMAND_MENU_SEARCH_BAR_PADDING * 2
|
||||
}px - ${MOBILE_NAVIGATION_BAR_HEIGHT}px)`
|
||||
: `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${SEARCH_BAR_PADDING * 2}px)`};
|
||||
: `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
|
||||
COMMAND_MENU_SEARCH_BAR_PADDING * 2
|
||||
}px)`};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
padding-top: ${({ theme }) => theme.spacing(1)};
|
||||
@ -165,9 +130,14 @@ export const CommandMenu = () => {
|
||||
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
|
||||
const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
|
||||
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCommandMenuSearch(event.target.value);
|
||||
};
|
||||
|
||||
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
|
||||
contextStoreTargetedRecordsRuleComponentState,
|
||||
);
|
||||
|
||||
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
|
||||
contextStoreNumberOfSelectedRecordsComponentState,
|
||||
);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@ -190,6 +160,25 @@ export const CommandMenu = () => {
|
||||
[closeCommandMenu],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Backspace, Key.Delete],
|
||||
() => {
|
||||
if (!isNonEmptyString(commandMenuSearch)) {
|
||||
setContextStoreTargetedRecordsRule({
|
||||
mode: 'selection',
|
||||
selectedRecordIds: [],
|
||||
});
|
||||
|
||||
setContextStoreNumberOfSelectedRecords(0);
|
||||
}
|
||||
},
|
||||
AppHotkeyScope.CommandMenuOpen,
|
||||
[closeCommandMenu],
|
||||
{
|
||||
preventDefault: false,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
matchesSearchFilterObjectRecordsQueryResult,
|
||||
matchesSearchFilterObjectRecordsLoading: loading,
|
||||
@ -378,20 +367,45 @@ export const CommandMenu = () => {
|
||||
: true) && cmd.type === CommandType.Create,
|
||||
);
|
||||
|
||||
const matchingStandardActionCommands = commandMenuCommands.filter(
|
||||
const matchingStandardActionRecordSelectionCommands =
|
||||
commandMenuCommands.filter(
|
||||
(cmd) =>
|
||||
(deferredCommandMenuSearch.length > 0
|
||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||
checkInLabels(cmd, deferredCommandMenuSearch)
|
||||
: true) &&
|
||||
cmd.type === CommandType.StandardAction &&
|
||||
cmd.scope === CommandScope.RecordSelection,
|
||||
);
|
||||
|
||||
const matchingStandardActionGlobalCommands = commandMenuCommands.filter(
|
||||
(cmd) =>
|
||||
(deferredCommandMenuSearch.length > 0
|
||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||
checkInLabels(cmd, deferredCommandMenuSearch)
|
||||
: true) && cmd.type === CommandType.StandardAction,
|
||||
: true) &&
|
||||
cmd.type === CommandType.StandardAction &&
|
||||
cmd.scope === CommandScope.Global,
|
||||
);
|
||||
|
||||
const matchingWorkflowRunCommands = commandMenuCommands.filter(
|
||||
const matchingWorkflowRunRecordSelectionCommands = commandMenuCommands.filter(
|
||||
(cmd) =>
|
||||
(deferredCommandMenuSearch.length > 0
|
||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||
checkInLabels(cmd, deferredCommandMenuSearch)
|
||||
: true) && cmd.type === CommandType.WorkflowRun,
|
||||
: true) &&
|
||||
cmd.type === CommandType.WorkflowRun &&
|
||||
cmd.scope === CommandScope.RecordSelection,
|
||||
);
|
||||
|
||||
const matchingWorkflowRunGlobalCommands = commandMenuCommands.filter(
|
||||
(cmd) =>
|
||||
(deferredCommandMenuSearch.length > 0
|
||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||
checkInLabels(cmd, deferredCommandMenuSearch)
|
||||
: true) &&
|
||||
cmd.type === CommandType.WorkflowRun &&
|
||||
cmd.scope === CommandScope.Global,
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
@ -419,8 +433,10 @@ export const CommandMenu = () => {
|
||||
|
||||
const selectableItemIds = copilotCommands
|
||||
.map((cmd) => cmd.id)
|
||||
.concat(matchingStandardActionCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingWorkflowRunCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingStandardActionRecordSelectionCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingWorkflowRunRecordSelectionCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingStandardActionGlobalCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingWorkflowRunGlobalCommands.map((cmd) => cmd.id))
|
||||
.concat(matchingCreateCommand.map((cmd) => cmd.id))
|
||||
.concat(matchingNavigateCommand.map((cmd) => cmd.id))
|
||||
.concat(people?.map((person) => person.id))
|
||||
@ -437,8 +453,10 @@ export const CommandMenu = () => {
|
||||
);
|
||||
|
||||
const isNoResults =
|
||||
!matchingStandardActionCommands.length &&
|
||||
!matchingWorkflowRunCommands.length &&
|
||||
!matchingStandardActionRecordSelectionCommands.length &&
|
||||
!matchingWorkflowRunRecordSelectionCommands.length &&
|
||||
!matchingStandardActionGlobalCommands.length &&
|
||||
!matchingWorkflowRunGlobalCommands.length &&
|
||||
!matchingCreateCommand.length &&
|
||||
!matchingNavigateCommand.length &&
|
||||
!people?.length &&
|
||||
@ -450,10 +468,6 @@ export const CommandMenu = () => {
|
||||
|
||||
const isLoading = loading || isNotesLoading || isTasksLoading;
|
||||
|
||||
const mainContextStoreComponentInstanceId = useRecoilValue(
|
||||
mainContextStoreComponentInstanceIdState,
|
||||
);
|
||||
|
||||
const commandGroups: CommandGroupConfig[] = [
|
||||
{
|
||||
heading: 'Navigate',
|
||||
@ -575,24 +589,10 @@ export const CommandMenu = () => {
|
||||
<>
|
||||
{isCommandMenuOpened && (
|
||||
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
|
||||
<StyledInputContainer>
|
||||
<StyledInput
|
||||
autoFocus
|
||||
value={commandMenuSearch}
|
||||
placeholder="Search"
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<StyledCloseButtonContainer>
|
||||
<LightIconButton
|
||||
accent={'tertiary'}
|
||||
size={'medium'}
|
||||
Icon={IconX}
|
||||
onClick={closeCommandMenu}
|
||||
/>
|
||||
</StyledCloseButtonContainer>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
<CommandMenuTopBar
|
||||
commandMenuSearch={commandMenuSearch}
|
||||
setCommandMenuSearch={setCommandMenuSearch}
|
||||
/>
|
||||
<StyledList>
|
||||
<ScrollWrapper contextProviderName="commandMenu">
|
||||
<StyledInnerList isMobile={isMobile}>
|
||||
@ -632,45 +632,83 @@ export const CommandMenu = () => {
|
||||
</SelectableItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{mainContextStoreComponentInstanceId && (
|
||||
<>
|
||||
<CommandGroup heading="Standard Actions">
|
||||
{matchingStandardActionCommands?.map(
|
||||
(standardActionCommand) => (
|
||||
<SelectableItem
|
||||
itemId={standardActionCommand.id}
|
||||
key={standardActionCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={standardActionCommand.id}
|
||||
label={standardActionCommand.label}
|
||||
Icon={standardActionCommand.Icon}
|
||||
onClick={standardActionCommand.onCommandClick}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Workflows">
|
||||
{matchingWorkflowRunCommands?.map(
|
||||
(workflowRunCommand) => (
|
||||
<SelectableItem
|
||||
itemId={workflowRunCommand.id}
|
||||
key={workflowRunCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={workflowRunCommand.id}
|
||||
label={workflowRunCommand.label}
|
||||
Icon={workflowRunCommand.Icon}
|
||||
onClick={workflowRunCommand.onCommandClick}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
</>
|
||||
<CommandGroup heading="Record Selection">
|
||||
{matchingStandardActionRecordSelectionCommands?.map(
|
||||
(standardActionrecordSelectionCommand) => (
|
||||
<SelectableItem
|
||||
itemId={standardActionrecordSelectionCommand.id}
|
||||
key={standardActionrecordSelectionCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={standardActionrecordSelectionCommand.id}
|
||||
label={standardActionrecordSelectionCommand.label}
|
||||
Icon={standardActionrecordSelectionCommand.Icon}
|
||||
onClick={
|
||||
standardActionrecordSelectionCommand.onCommandClick
|
||||
}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
{matchingWorkflowRunRecordSelectionCommands?.map(
|
||||
(workflowRunRecordSelectionCommand) => (
|
||||
<SelectableItem
|
||||
itemId={workflowRunRecordSelectionCommand.id}
|
||||
key={workflowRunRecordSelectionCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={workflowRunRecordSelectionCommand.id}
|
||||
label={workflowRunRecordSelectionCommand.label}
|
||||
Icon={workflowRunRecordSelectionCommand.Icon}
|
||||
onClick={
|
||||
workflowRunRecordSelectionCommand.onCommandClick
|
||||
}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
{matchingStandardActionGlobalCommands?.length > 0 && (
|
||||
<CommandGroup heading="View">
|
||||
{matchingStandardActionGlobalCommands?.map(
|
||||
(standardActionGlobalCommand) => (
|
||||
<SelectableItem
|
||||
itemId={standardActionGlobalCommand.id}
|
||||
key={standardActionGlobalCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={standardActionGlobalCommand.id}
|
||||
label={standardActionGlobalCommand.label}
|
||||
Icon={standardActionGlobalCommand.Icon}
|
||||
onClick={
|
||||
standardActionGlobalCommand.onCommandClick
|
||||
}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{matchingWorkflowRunGlobalCommands?.length > 0 && (
|
||||
<CommandGroup heading="Workflows">
|
||||
{matchingWorkflowRunGlobalCommands?.map(
|
||||
(workflowRunGlobalCommand) => (
|
||||
<SelectableItem
|
||||
itemId={workflowRunGlobalCommand.id}
|
||||
key={workflowRunGlobalCommand.id}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={workflowRunGlobalCommand.id}
|
||||
label={workflowRunGlobalCommand.label}
|
||||
Icon={workflowRunGlobalCommand.Icon}
|
||||
onClick={workflowRunGlobalCommand.onCommandClick}
|
||||
/>
|
||||
</SelectableItem>
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{commandGroups.map(({ heading, items, renderItem }) =>
|
||||
items?.length ? (
|
||||
<CommandGroup heading={heading} key={heading}>
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
||||
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
|
||||
import { computeCommandMenuCommands } from '@/command-menu/utils/computeCommandMenuCommands';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
export const CommandMenuCommandsEffect = () => {
|
||||
const actionMenuEntries = useRecoilComponentValueV2(
|
||||
actionMenuEntriesComponentSelector,
|
||||
);
|
||||
|
||||
const setCommands = useSetRecoilState(commandMenuCommandsState);
|
||||
|
||||
useEffect(() => {
|
||||
setCommands(computeCommandMenuCommands(actionMenuEntries));
|
||||
}, [actionMenuEntries, setCommands]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,117 @@
|
||||
import { useContextStoreSelectedRecords } from '@/context-store/hooks/useContextStoreSelectedRecords';
|
||||
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
|
||||
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
|
||||
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Avatar } from 'twenty-ui';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledAvatarWrapper = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(0.5)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
&:not(:first-of-type) {
|
||||
margin-left: -${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledAvatarContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const CommandMenuContextRecordChipAvatars = ({
|
||||
objectMetadataItem,
|
||||
record,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
record: ObjectRecord;
|
||||
}) => {
|
||||
const { recordChipData } = useRecordChipData({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
record,
|
||||
});
|
||||
|
||||
const { Icon, IconColor } = useGetStandardObjectIcon(
|
||||
objectMetadataItem.nameSingular,
|
||||
);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledAvatarWrapper>
|
||||
{Icon ? (
|
||||
<Icon color={IconColor} size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<Avatar
|
||||
avatarUrl={recordChipData.avatarUrl}
|
||||
placeholderColorSeed={recordChipData.recordId}
|
||||
placeholder={recordChipData.name}
|
||||
type={recordChipData.avatarType}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</StyledAvatarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandMenuContextRecordChip = () => {
|
||||
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
|
||||
contextStoreCurrentObjectMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||
objectId: contextStoreCurrentObjectMetadataId ?? '',
|
||||
});
|
||||
|
||||
const { records, loading, totalCount } = useContextStoreSelectedRecords({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
if (loading || !totalCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledChip>
|
||||
<StyledAvatarContainer>
|
||||
{records.map((record) => (
|
||||
<CommandMenuContextRecordChipAvatars
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
key={record.id}
|
||||
record={record}
|
||||
/>
|
||||
))}
|
||||
</StyledAvatarContainer>
|
||||
{totalCount === 1
|
||||
? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] })
|
||||
.name
|
||||
: `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`}
|
||||
</StyledChip>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
|
||||
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
|
||||
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconX, LightIconButton, useIsMobile } from 'twenty-ui';
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: none;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: 0;
|
||||
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
height: ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
position: relative;
|
||||
|
||||
padding: 0 ${({ theme }) => theme.spacing(COMMAND_MENU_SEARCH_BAR_PADDING)};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
margin: 0;
|
||||
outline: none;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
|
||||
&::placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCloseButtonContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
type CommandMenuTopBarProps = {
|
||||
commandMenuSearch: string;
|
||||
setCommandMenuSearch: (search: string) => void;
|
||||
};
|
||||
|
||||
export const CommandMenuTopBar = ({
|
||||
commandMenuSearch,
|
||||
setCommandMenuSearch,
|
||||
}: CommandMenuTopBarProps) => {
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCommandMenuSearch(event.target.value);
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { closeCommandMenu } = useCommandMenu();
|
||||
|
||||
return (
|
||||
<StyledInputContainer>
|
||||
<CommandMenuContextRecordChip />
|
||||
<StyledInput
|
||||
autoFocus
|
||||
value={commandMenuSearch}
|
||||
placeholder="Type anything"
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<StyledCloseButtonContainer>
|
||||
<LightIconButton
|
||||
accent={'tertiary'}
|
||||
size={'medium'}
|
||||
Icon={IconX}
|
||||
onClick={closeCommandMenu}
|
||||
/>
|
||||
</StyledCloseButtonContainer>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { Decorator, Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
@ -20,13 +20,32 @@ import {
|
||||
} from '~/testing/mock-data/users';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
|
||||
import { CommandMenu } from '../CommandMenu';
|
||||
|
||||
const companiesMock = getCompaniesMock();
|
||||
|
||||
const openTimeout = 50;
|
||||
|
||||
const ContextStoreDecorator: Decorator = (Story) => {
|
||||
return (
|
||||
<ContextStoreComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'command-menu' }}
|
||||
>
|
||||
<ActionMenuComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'command-menu' }}
|
||||
>
|
||||
<JestContextStoreSetter contextStoreCurrentObjectMetadataNameSingular="company">
|
||||
<Story />
|
||||
</JestContextStoreSetter>
|
||||
</ActionMenuComponentInstanceContext.Provider>
|
||||
</ContextStoreComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof CommandMenu> = {
|
||||
title: 'Modules/CommandMenu/CommandMenu',
|
||||
component: CommandMenu,
|
||||
@ -79,6 +98,7 @@ const meta: Meta<typeof CommandMenu> = {
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
ContextStoreDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
ComponentWithRouterDecorator,
|
||||
@ -109,7 +129,7 @@ export const DefaultWithoutSearch: Story = {
|
||||
export const MatchingPersonCompanyActivityCreateNavigate: Story = {
|
||||
play: async () => {
|
||||
const canvas = within(document.body);
|
||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
||||
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||
await sleep(openTimeout);
|
||||
await userEvent.type(searchInput, 'n');
|
||||
expect(await canvas.findByText('Linkedin')).toBeInTheDocument();
|
||||
@ -122,7 +142,7 @@ export const MatchingPersonCompanyActivityCreateNavigate: Story = {
|
||||
export const OnlyMatchingCreateAndNavigate: Story = {
|
||||
play: async () => {
|
||||
const canvas = within(document.body);
|
||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
||||
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||
await sleep(openTimeout);
|
||||
await userEvent.type(searchInput, 'ta');
|
||||
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
|
||||
@ -133,7 +153,7 @@ export const OnlyMatchingCreateAndNavigate: Story = {
|
||||
export const AtleastMatchingOnePerson: Story = {
|
||||
play: async () => {
|
||||
const canvas = within(document.body);
|
||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
||||
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||
await sleep(openTimeout);
|
||||
await userEvent.type(searchInput, 'alex');
|
||||
expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user