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:
@ -1,4 +1,8 @@
|
|||||||
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
|
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
|
||||||
@ -28,9 +32,9 @@ export const WorkflowRunActionEffect = () => {
|
|||||||
activeWorkflowVersion,
|
activeWorkflowVersion,
|
||||||
] of activeWorkflowVersions.entries()) {
|
] of activeWorkflowVersions.entries()) {
|
||||||
addActionMenuEntry({
|
addActionMenuEntry({
|
||||||
type: 'workflow-run',
|
type: ActionMenuEntryType.WorkflowRun,
|
||||||
key: `workflow-run-${activeWorkflowVersion.id}`,
|
key: `workflow-run-${activeWorkflowVersion.id}`,
|
||||||
scope: 'global',
|
scope: ActionMenuEntryScope.Global,
|
||||||
label: capitalize(activeWorkflowVersion.workflow.name),
|
label: capitalize(activeWorkflowVersion.workflow.name),
|
||||||
position: index,
|
position: index,
|
||||||
Icon: IconSettingsAutomation,
|
Icon: IconSettingsAutomation,
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
||||||
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
@ -105,8 +109,8 @@ export const DeleteRecordsActionEffect = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
addActionMenuEntry({
|
addActionMenuEntry({
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position,
|
position,
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
|||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { IconDatabaseExport } from 'twenty-ui';
|
import { IconDatabaseExport } from 'twenty-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import {
|
import {
|
||||||
displayedExportProgress,
|
displayedExportProgress,
|
||||||
useExportRecords,
|
useExportRecords,
|
||||||
@ -31,8 +35,11 @@ export const ExportRecordsActionEffect = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addActionMenuEntry({
|
addActionMenuEntry({
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope:
|
||||||
|
contextStoreNumberOfSelectedRecords > 0
|
||||||
|
? ActionMenuEntryScope.RecordSelection
|
||||||
|
: ActionMenuEntryScope.Global,
|
||||||
key: 'export',
|
key: 'export',
|
||||||
position,
|
position,
|
||||||
label: displayedExportProgress(progress),
|
label: displayedExportProgress(progress),
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
|
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
|
||||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||||
@ -50,8 +54,8 @@ export const ManageFavoritesActionEffect = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
addActionMenuEntry({
|
addActionMenuEntry({
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'manage-favorites',
|
key: 'manage-favorites',
|
||||||
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
|
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
|
||||||
position,
|
position,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
|
|||||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
|
import { isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
const noSelectionRecordActionEffects = [ExportRecordsActionEffect];
|
const noSelectionRecordActionEffects = [ExportRecordsActionEffect];
|
||||||
|
|
||||||
@ -21,25 +22,33 @@ const multipleRecordActionEffects = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const RecordActionMenuEntriesSetter = () => {
|
export const RecordActionMenuEntriesSetter = () => {
|
||||||
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
|
|
||||||
contextStoreNumberOfSelectedRecordsComponentState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
|
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
|
||||||
contextStoreCurrentObjectMetadataIdComponentState,
|
contextStoreCurrentObjectMetadataIdComponentState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isDefined(contextStoreCurrentObjectMetadataId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionEffects objectMetadataItemId={contextStoreCurrentObjectMetadataId} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionEffects = ({
|
||||||
|
objectMetadataItemId,
|
||||||
|
}: {
|
||||||
|
objectMetadataItemId: string;
|
||||||
|
}) => {
|
||||||
const { objectMetadataItem } = useObjectMetadataItemById({
|
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||||
objectId: contextStoreCurrentObjectMetadataId ?? '',
|
objectId: objectMetadataItemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
|
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
|
||||||
|
contextStoreNumberOfSelectedRecordsComponentState,
|
||||||
|
);
|
||||||
|
|
||||||
if (!objectMetadataItem) {
|
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
|
||||||
throw new Error(
|
|
||||||
`Object metadata item not found for id ${contextStoreCurrentObjectMetadataId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions =
|
const actions =
|
||||||
contextStoreNumberOfSelectedRecords === 0
|
contextStoreNumberOfSelectedRecords === 0
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
@ -55,9 +59,9 @@ export const WorkflowRunRecordActionEffect = ({
|
|||||||
activeWorkflowVersion,
|
activeWorkflowVersion,
|
||||||
] of activeWorkflowVersions.entries()) {
|
] of activeWorkflowVersions.entries()) {
|
||||||
addActionMenuEntry({
|
addActionMenuEntry({
|
||||||
type: 'workflow-run',
|
type: ActionMenuEntryType.WorkflowRun,
|
||||||
key: `workflow-run-${activeWorkflowVersion.id}`,
|
key: `workflow-run-${activeWorkflowVersion.id}`,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
label: capitalize(activeWorkflowVersion.workflow.name),
|
label: capitalize(activeWorkflowVersion.workflow.name),
|
||||||
position: index,
|
position: index,
|
||||||
Icon: IconSettingsAutomation,
|
Icon: IconSettingsAutomation,
|
||||||
|
|||||||
@ -43,7 +43,12 @@ export const RecordIndexActionMenuEffect = () => {
|
|||||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contextStoreNumberOfSelectedRecords > 0 && !isDropdownOpen) {
|
if (
|
||||||
|
contextStoreNumberOfSelectedRecords > 0 &&
|
||||||
|
!isDropdownOpen &&
|
||||||
|
!isRightDrawerOpen &&
|
||||||
|
!isCommandMenuOpened
|
||||||
|
) {
|
||||||
// We only handle opening the ActionMenuBar here, not the Dropdown.
|
// We only handle opening the ActionMenuBar here, not the Dropdown.
|
||||||
// The Dropdown is already managed by sync handlers for events like
|
// The Dropdown is already managed by sync handlers for events like
|
||||||
// right-click to open and click outside to close.
|
// right-click to open and click outside to close.
|
||||||
@ -57,6 +62,8 @@ export const RecordIndexActionMenuEffect = () => {
|
|||||||
openActionBar,
|
openActionBar,
|
||||||
closeActionBar,
|
closeActionBar,
|
||||||
isDropdownOpen,
|
isDropdownOpen,
|
||||||
|
isRightDrawerOpen,
|
||||||
|
isCommandMenuOpened,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
||||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
|
import { ActionMenuEntryScope } from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope';
|
import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope';
|
||||||
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
|
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||||
@ -67,7 +68,8 @@ export const RightDrawerActionMenuDropdown = () => {
|
|||||||
<DropdownMenuItemsContainer>
|
<DropdownMenuItemsContainer>
|
||||||
{actionMenuEntries
|
{actionMenuEntries
|
||||||
.filter(
|
.filter(
|
||||||
(actionMenuEntry) => actionMenuEntry.scope === 'record-selection',
|
(actionMenuEntry) =>
|
||||||
|
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
|
||||||
)
|
)
|
||||||
.map((actionMenuEntry, index) => (
|
.map((actionMenuEntry, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { RecoilRoot } from 'recoil';
|
|||||||
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
|
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
|
||||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
import {
|
||||||
|
ActionMenuEntry,
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId';
|
import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId';
|
||||||
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
@ -48,8 +52,8 @@ const meta: Meta<typeof RecordIndexActionMenuBar> = {
|
|||||||
|
|
||||||
map.set('delete', {
|
map.set('delete', {
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position: 0,
|
position: 0,
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry';
|
import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry';
|
||||||
|
import {
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { expect, jest } from '@storybook/jest';
|
import { expect, jest } from '@storybook/jest';
|
||||||
import { Meta, StoryObj } from '@storybook/react';
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
import { userEvent, within } from '@storybook/testing-library';
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
@ -21,8 +25,8 @@ const markAsDoneMock = jest.fn();
|
|||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
entry: {
|
entry: {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position: 0,
|
position: 0,
|
||||||
@ -35,8 +39,8 @@ export const Default: Story = {
|
|||||||
export const WithDangerAccent: Story = {
|
export const WithDangerAccent: Story = {
|
||||||
args: {
|
args: {
|
||||||
entry: {
|
entry: {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position: 0,
|
position: 0,
|
||||||
@ -50,8 +54,8 @@ export const WithDangerAccent: Story = {
|
|||||||
export const WithInteraction: Story = {
|
export const WithInteraction: Story = {
|
||||||
args: {
|
args: {
|
||||||
entry: {
|
entry: {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'markAsDone',
|
key: 'markAsDone',
|
||||||
label: 'Mark as done',
|
label: 'Mark as done',
|
||||||
position: 0,
|
position: 0,
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIn
|
|||||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
|
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
|
||||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
import {
|
||||||
|
ActionMenuEntry,
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
|
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
|
||||||
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
|
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
|
||||||
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
|
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
|
||||||
@ -41,8 +45,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
map.set('delete', {
|
map.set('delete', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position: 0,
|
position: 0,
|
||||||
@ -51,8 +55,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.set('markAsDone', {
|
map.set('markAsDone', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'markAsDone',
|
key: 'markAsDone',
|
||||||
label: 'Mark as done',
|
label: 'Mark as done',
|
||||||
position: 1,
|
position: 1,
|
||||||
@ -61,8 +65,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.set('addToFavorites', {
|
map.set('addToFavorites', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'addToFavorites',
|
key: 'addToFavorites',
|
||||||
label: 'Add to favorites',
|
label: 'Add to favorites',
|
||||||
position: 2,
|
position: 2,
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { RecoilRoot } from 'recoil';
|
|||||||
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
|
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
|
||||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
import {
|
||||||
|
ActionMenuEntry,
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { userEvent, waitFor, within } from '@storybook/test';
|
import { userEvent, waitFor, within } from '@storybook/test';
|
||||||
@ -54,8 +58,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
map.set('addToFavorites', {
|
map.set('addToFavorites', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'addToFavorites',
|
key: 'addToFavorites',
|
||||||
label: 'Add to favorites',
|
label: 'Add to favorites',
|
||||||
position: 0,
|
position: 0,
|
||||||
@ -64,8 +68,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.set('export', {
|
map.set('export', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'export',
|
key: 'export',
|
||||||
label: 'Export',
|
label: 'Export',
|
||||||
position: 1,
|
position: 1,
|
||||||
@ -74,8 +78,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.set('delete', {
|
map.set('delete', {
|
||||||
type: 'standard',
|
type: ActionMenuEntryType.Standard,
|
||||||
scope: 'record-selection',
|
scope: ActionMenuEntryScope.RecordSelection,
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
position: 2,
|
position: 2,
|
||||||
|
|||||||
@ -1,9 +1,19 @@
|
|||||||
import { MouseEvent, ReactNode } from 'react';
|
import { MouseEvent, ReactNode } from 'react';
|
||||||
import { IconComponent, MenuItemAccent } from 'twenty-ui';
|
import { IconComponent, MenuItemAccent } from 'twenty-ui';
|
||||||
|
|
||||||
|
export enum ActionMenuEntryType {
|
||||||
|
Standard = 'Standard',
|
||||||
|
WorkflowRun = 'WorkflowRun',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActionMenuEntryScope {
|
||||||
|
Global = 'Global',
|
||||||
|
RecordSelection = 'RecordSelection',
|
||||||
|
}
|
||||||
|
|
||||||
export type ActionMenuEntry = {
|
export type ActionMenuEntry = {
|
||||||
type: 'standard' | 'workflow-run';
|
type: ActionMenuEntryType;
|
||||||
scope: 'global' | 'record-selection';
|
scope: ActionMenuEntryScope;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
position: number;
|
position: number;
|
||||||
|
|||||||
@ -5,13 +5,21 @@ import { Note } from '@/activities/types/Note';
|
|||||||
import { Task } from '@/activities/types/Task';
|
import { Task } from '@/activities/types/Task';
|
||||||
import { CommandGroup } from '@/command-menu/components/CommandGroup';
|
import { CommandGroup } from '@/command-menu/components/CommandGroup';
|
||||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
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 { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
|
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
|
||||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
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 { 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 { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
|
||||||
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
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 { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { isNonEmptyString } from '@sniptt/guards';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
@ -40,16 +49,12 @@ import {
|
|||||||
IconComponent,
|
IconComponent,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconX,
|
|
||||||
LightIconButton,
|
|
||||||
isDefined,
|
isDefined,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from 'use-debounce';
|
||||||
import { getLogoUrlFromDomainName } from '~/utils';
|
import { getLogoUrlFromDomainName } from '~/utils';
|
||||||
import { capitalize } from '~/utils/string/capitalize';
|
import { capitalize } from '~/utils/string/capitalize';
|
||||||
|
|
||||||
const SEARCH_BAR_HEIGHT = 56;
|
|
||||||
const SEARCH_BAR_PADDING = 3;
|
|
||||||
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
|
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
|
||||||
|
|
||||||
type CommandGroupConfig = {
|
type CommandGroupConfig = {
|
||||||
@ -80,48 +85,6 @@ const StyledCommandMenu = styled.div`
|
|||||||
z-index: 1000;
|
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`
|
const StyledList = styled.div`
|
||||||
background: ${({ theme }) => theme.background.secondary};
|
background: ${({ theme }) => theme.background.secondary};
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@ -132,10 +95,12 @@ const StyledList = styled.div`
|
|||||||
const StyledInnerList = styled.div<{ isMobile: boolean }>`
|
const StyledInnerList = styled.div<{ isMobile: boolean }>`
|
||||||
max-height: ${({ isMobile }) =>
|
max-height: ${({ isMobile }) =>
|
||||||
isMobile
|
isMobile
|
||||||
? `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${
|
? `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
|
||||||
SEARCH_BAR_PADDING * 2
|
COMMAND_MENU_SEARCH_BAR_PADDING * 2
|
||||||
}px - ${MOBILE_NAVIGATION_BAR_HEIGHT}px)`
|
}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-left: ${({ theme }) => theme.spacing(2)};
|
||||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||||
padding-top: ${({ theme }) => theme.spacing(1)};
|
padding-top: ${({ theme }) => theme.spacing(1)};
|
||||||
@ -165,9 +130,14 @@ export const CommandMenu = () => {
|
|||||||
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
|
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
|
||||||
const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
|
const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
|
||||||
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
|
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
|
||||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setCommandMenuSearch(event.target.value);
|
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
|
||||||
};
|
contextStoreTargetedRecordsRuleComponentState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
|
||||||
|
contextStoreNumberOfSelectedRecordsComponentState,
|
||||||
|
);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@ -190,6 +160,25 @@ export const CommandMenu = () => {
|
|||||||
[closeCommandMenu],
|
[closeCommandMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useScopedHotkeys(
|
||||||
|
[Key.Backspace, Key.Delete],
|
||||||
|
() => {
|
||||||
|
if (!isNonEmptyString(commandMenuSearch)) {
|
||||||
|
setContextStoreTargetedRecordsRule({
|
||||||
|
mode: 'selection',
|
||||||
|
selectedRecordIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
setContextStoreNumberOfSelectedRecords(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AppHotkeyScope.CommandMenuOpen,
|
||||||
|
[closeCommandMenu],
|
||||||
|
{
|
||||||
|
preventDefault: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
matchesSearchFilterObjectRecordsQueryResult,
|
matchesSearchFilterObjectRecordsQueryResult,
|
||||||
matchesSearchFilterObjectRecordsLoading: loading,
|
matchesSearchFilterObjectRecordsLoading: loading,
|
||||||
@ -378,20 +367,45 @@ export const CommandMenu = () => {
|
|||||||
: true) && cmd.type === CommandType.Create,
|
: 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) =>
|
(cmd) =>
|
||||||
(deferredCommandMenuSearch.length > 0
|
(deferredCommandMenuSearch.length > 0
|
||||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||||
checkInLabels(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) =>
|
(cmd) =>
|
||||||
(deferredCommandMenuSearch.length > 0
|
(deferredCommandMenuSearch.length > 0
|
||||||
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
|
||||||
checkInLabels(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({
|
useListenClickOutside({
|
||||||
@ -419,8 +433,10 @@ export const CommandMenu = () => {
|
|||||||
|
|
||||||
const selectableItemIds = copilotCommands
|
const selectableItemIds = copilotCommands
|
||||||
.map((cmd) => cmd.id)
|
.map((cmd) => cmd.id)
|
||||||
.concat(matchingStandardActionCommands.map((cmd) => cmd.id))
|
.concat(matchingStandardActionRecordSelectionCommands.map((cmd) => cmd.id))
|
||||||
.concat(matchingWorkflowRunCommands.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(matchingCreateCommand.map((cmd) => cmd.id))
|
||||||
.concat(matchingNavigateCommand.map((cmd) => cmd.id))
|
.concat(matchingNavigateCommand.map((cmd) => cmd.id))
|
||||||
.concat(people?.map((person) => person.id))
|
.concat(people?.map((person) => person.id))
|
||||||
@ -437,8 +453,10 @@ export const CommandMenu = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isNoResults =
|
const isNoResults =
|
||||||
!matchingStandardActionCommands.length &&
|
!matchingStandardActionRecordSelectionCommands.length &&
|
||||||
!matchingWorkflowRunCommands.length &&
|
!matchingWorkflowRunRecordSelectionCommands.length &&
|
||||||
|
!matchingStandardActionGlobalCommands.length &&
|
||||||
|
!matchingWorkflowRunGlobalCommands.length &&
|
||||||
!matchingCreateCommand.length &&
|
!matchingCreateCommand.length &&
|
||||||
!matchingNavigateCommand.length &&
|
!matchingNavigateCommand.length &&
|
||||||
!people?.length &&
|
!people?.length &&
|
||||||
@ -450,10 +468,6 @@ export const CommandMenu = () => {
|
|||||||
|
|
||||||
const isLoading = loading || isNotesLoading || isTasksLoading;
|
const isLoading = loading || isNotesLoading || isTasksLoading;
|
||||||
|
|
||||||
const mainContextStoreComponentInstanceId = useRecoilValue(
|
|
||||||
mainContextStoreComponentInstanceIdState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const commandGroups: CommandGroupConfig[] = [
|
const commandGroups: CommandGroupConfig[] = [
|
||||||
{
|
{
|
||||||
heading: 'Navigate',
|
heading: 'Navigate',
|
||||||
@ -575,24 +589,10 @@ export const CommandMenu = () => {
|
|||||||
<>
|
<>
|
||||||
{isCommandMenuOpened && (
|
{isCommandMenuOpened && (
|
||||||
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
|
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
|
||||||
<StyledInputContainer>
|
<CommandMenuTopBar
|
||||||
<StyledInput
|
commandMenuSearch={commandMenuSearch}
|
||||||
autoFocus
|
setCommandMenuSearch={setCommandMenuSearch}
|
||||||
value={commandMenuSearch}
|
/>
|
||||||
placeholder="Search"
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
/>
|
|
||||||
{!isMobile && (
|
|
||||||
<StyledCloseButtonContainer>
|
|
||||||
<LightIconButton
|
|
||||||
accent={'tertiary'}
|
|
||||||
size={'medium'}
|
|
||||||
Icon={IconX}
|
|
||||||
onClick={closeCommandMenu}
|
|
||||||
/>
|
|
||||||
</StyledCloseButtonContainer>
|
|
||||||
)}
|
|
||||||
</StyledInputContainer>
|
|
||||||
<StyledList>
|
<StyledList>
|
||||||
<ScrollWrapper contextProviderName="commandMenu">
|
<ScrollWrapper contextProviderName="commandMenu">
|
||||||
<StyledInnerList isMobile={isMobile}>
|
<StyledInnerList isMobile={isMobile}>
|
||||||
@ -632,45 +632,83 @@ export const CommandMenu = () => {
|
|||||||
</SelectableItem>
|
</SelectableItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
{mainContextStoreComponentInstanceId && (
|
<CommandGroup heading="Record Selection">
|
||||||
<>
|
{matchingStandardActionRecordSelectionCommands?.map(
|
||||||
<CommandGroup heading="Standard Actions">
|
(standardActionrecordSelectionCommand) => (
|
||||||
{matchingStandardActionCommands?.map(
|
<SelectableItem
|
||||||
(standardActionCommand) => (
|
itemId={standardActionrecordSelectionCommand.id}
|
||||||
<SelectableItem
|
key={standardActionrecordSelectionCommand.id}
|
||||||
itemId={standardActionCommand.id}
|
>
|
||||||
key={standardActionCommand.id}
|
<CommandMenuItem
|
||||||
>
|
id={standardActionrecordSelectionCommand.id}
|
||||||
<CommandMenuItem
|
label={standardActionrecordSelectionCommand.label}
|
||||||
id={standardActionCommand.id}
|
Icon={standardActionrecordSelectionCommand.Icon}
|
||||||
label={standardActionCommand.label}
|
onClick={
|
||||||
Icon={standardActionCommand.Icon}
|
standardActionrecordSelectionCommand.onCommandClick
|
||||||
onClick={standardActionCommand.onCommandClick}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectableItem>
|
</SelectableItem>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</CommandGroup>
|
{matchingWorkflowRunRecordSelectionCommands?.map(
|
||||||
|
(workflowRunRecordSelectionCommand) => (
|
||||||
<CommandGroup heading="Workflows">
|
<SelectableItem
|
||||||
{matchingWorkflowRunCommands?.map(
|
itemId={workflowRunRecordSelectionCommand.id}
|
||||||
(workflowRunCommand) => (
|
key={workflowRunRecordSelectionCommand.id}
|
||||||
<SelectableItem
|
>
|
||||||
itemId={workflowRunCommand.id}
|
<CommandMenuItem
|
||||||
key={workflowRunCommand.id}
|
id={workflowRunRecordSelectionCommand.id}
|
||||||
>
|
label={workflowRunRecordSelectionCommand.label}
|
||||||
<CommandMenuItem
|
Icon={workflowRunRecordSelectionCommand.Icon}
|
||||||
id={workflowRunCommand.id}
|
onClick={
|
||||||
label={workflowRunCommand.label}
|
workflowRunRecordSelectionCommand.onCommandClick
|
||||||
Icon={workflowRunCommand.Icon}
|
}
|
||||||
onClick={workflowRunCommand.onCommandClick}
|
/>
|
||||||
/>
|
</SelectableItem>
|
||||||
</SelectableItem>
|
),
|
||||||
),
|
)}
|
||||||
)}
|
</CommandGroup>
|
||||||
</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 }) =>
|
{commandGroups.map(({ heading, items, renderItem }) =>
|
||||||
items?.length ? (
|
items?.length ? (
|
||||||
<CommandGroup heading={heading} key={heading}>
|
<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 { 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 { expect, userEvent, within } from '@storybook/test';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||||
@ -20,13 +20,32 @@ import {
|
|||||||
} from '~/testing/mock-data/users';
|
} from '~/testing/mock-data/users';
|
||||||
import { sleep } from '~/utils/sleep';
|
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 { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||||
|
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
|
||||||
import { CommandMenu } from '../CommandMenu';
|
import { CommandMenu } from '../CommandMenu';
|
||||||
|
|
||||||
const companiesMock = getCompaniesMock();
|
const companiesMock = getCompaniesMock();
|
||||||
|
|
||||||
const openTimeout = 50;
|
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> = {
|
const meta: Meta<typeof CommandMenu> = {
|
||||||
title: 'Modules/CommandMenu/CommandMenu',
|
title: 'Modules/CommandMenu/CommandMenu',
|
||||||
component: CommandMenu,
|
component: CommandMenu,
|
||||||
@ -79,6 +98,7 @@ const meta: Meta<typeof CommandMenu> = {
|
|||||||
|
|
||||||
return <Story />;
|
return <Story />;
|
||||||
},
|
},
|
||||||
|
ContextStoreDecorator,
|
||||||
ObjectMetadataItemsDecorator,
|
ObjectMetadataItemsDecorator,
|
||||||
SnackBarDecorator,
|
SnackBarDecorator,
|
||||||
ComponentWithRouterDecorator,
|
ComponentWithRouterDecorator,
|
||||||
@ -109,7 +129,7 @@ export const DefaultWithoutSearch: Story = {
|
|||||||
export const MatchingPersonCompanyActivityCreateNavigate: Story = {
|
export const MatchingPersonCompanyActivityCreateNavigate: Story = {
|
||||||
play: async () => {
|
play: async () => {
|
||||||
const canvas = within(document.body);
|
const canvas = within(document.body);
|
||||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||||
await sleep(openTimeout);
|
await sleep(openTimeout);
|
||||||
await userEvent.type(searchInput, 'n');
|
await userEvent.type(searchInput, 'n');
|
||||||
expect(await canvas.findByText('Linkedin')).toBeInTheDocument();
|
expect(await canvas.findByText('Linkedin')).toBeInTheDocument();
|
||||||
@ -122,7 +142,7 @@ export const MatchingPersonCompanyActivityCreateNavigate: Story = {
|
|||||||
export const OnlyMatchingCreateAndNavigate: Story = {
|
export const OnlyMatchingCreateAndNavigate: Story = {
|
||||||
play: async () => {
|
play: async () => {
|
||||||
const canvas = within(document.body);
|
const canvas = within(document.body);
|
||||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||||
await sleep(openTimeout);
|
await sleep(openTimeout);
|
||||||
await userEvent.type(searchInput, 'ta');
|
await userEvent.type(searchInput, 'ta');
|
||||||
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
|
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
|
||||||
@ -133,7 +153,7 @@ export const OnlyMatchingCreateAndNavigate: Story = {
|
|||||||
export const AtleastMatchingOnePerson: Story = {
|
export const AtleastMatchingOnePerson: Story = {
|
||||||
play: async () => {
|
play: async () => {
|
||||||
const canvas = within(document.body);
|
const canvas = within(document.body);
|
||||||
const searchInput = await canvas.findByPlaceholderText('Search');
|
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||||
await sleep(openTimeout);
|
await sleep(openTimeout);
|
||||||
await userEvent.type(searchInput, 'alex');
|
await userEvent.type(searchInput, 'alex');
|
||||||
expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument();
|
expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument();
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 56;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const COMMAND_MENU_SEARCH_BAR_PADDING = 3;
|
||||||
@ -9,8 +9,12 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
|
|||||||
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
|
||||||
import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands';
|
import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands';
|
||||||
|
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
|
||||||
|
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||||
|
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||||
|
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId';
|
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId';
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { ALL_ICONS } from '@ui/display/icon/providers/internal/AllIcons';
|
import { ALL_ICONS } from '@ui/display/icon/providers/internal/AllIcons';
|
||||||
@ -34,42 +38,83 @@ export const useCommandMenu = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openCommandMenu = useRecoilCallback(
|
const openCommandMenu = useRecoilCallback(
|
||||||
({ snapshot }) =>
|
({ snapshot, set }) =>
|
||||||
() => {
|
() => {
|
||||||
if (isDefined(mainContextStoreComponentInstanceId)) {
|
if (isDefined(mainContextStoreComponentInstanceId)) {
|
||||||
const actionMenuEntries = snapshot.getLoadable(
|
const contextStoreCurrentObjectMetadataId = snapshot
|
||||||
actionMenuEntriesComponentSelector.selectorFamily({
|
.getLoadable(
|
||||||
instanceId: mainContextStoreComponentInstanceId,
|
contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
|
||||||
|
instanceId: mainContextStoreComponentInstanceId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue();
|
||||||
|
|
||||||
|
set(
|
||||||
|
contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
|
||||||
|
instanceId: 'command-menu',
|
||||||
}),
|
}),
|
||||||
|
contextStoreCurrentObjectMetadataId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const commands = Object.values(COMMAND_MENU_COMMANDS);
|
const contextStoreTargetedRecordsRule = snapshot
|
||||||
|
.getLoadable(
|
||||||
const actionCommands = actionMenuEntries
|
contextStoreTargetedRecordsRuleComponentState.atomFamily({
|
||||||
.getValue()
|
instanceId: mainContextStoreComponentInstanceId,
|
||||||
?.filter((actionMenuEntry) => actionMenuEntry.type === 'standard')
|
}),
|
||||||
?.map((actionMenuEntry) => ({
|
|
||||||
id: actionMenuEntry.key,
|
|
||||||
label: actionMenuEntry.label,
|
|
||||||
Icon: actionMenuEntry.Icon,
|
|
||||||
onCommandClick: actionMenuEntry.onClick,
|
|
||||||
type: CommandType.StandardAction,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const workflowRunCommands = actionMenuEntries
|
|
||||||
.getValue()
|
|
||||||
?.filter(
|
|
||||||
(actionMenuEntry) => actionMenuEntry.type === 'workflow-run',
|
|
||||||
)
|
)
|
||||||
?.map((actionMenuEntry) => ({
|
.getValue();
|
||||||
id: actionMenuEntry.key,
|
|
||||||
label: actionMenuEntry.label,
|
|
||||||
Icon: actionMenuEntry.Icon,
|
|
||||||
onCommandClick: actionMenuEntry.onClick,
|
|
||||||
type: CommandType.WorkflowRun,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setCommands([...commands, ...actionCommands, ...workflowRunCommands]);
|
set(
|
||||||
|
contextStoreTargetedRecordsRuleComponentState.atomFamily({
|
||||||
|
instanceId: 'command-menu',
|
||||||
|
}),
|
||||||
|
contextStoreTargetedRecordsRule,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextStoreNumberOfSelectedRecords = snapshot
|
||||||
|
.getLoadable(
|
||||||
|
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
|
||||||
|
instanceId: mainContextStoreComponentInstanceId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue();
|
||||||
|
|
||||||
|
set(
|
||||||
|
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
|
||||||
|
instanceId: 'command-menu',
|
||||||
|
}),
|
||||||
|
contextStoreNumberOfSelectedRecords,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextStoreFilters = snapshot
|
||||||
|
.getLoadable(
|
||||||
|
contextStoreFiltersComponentState.atomFamily({
|
||||||
|
instanceId: mainContextStoreComponentInstanceId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue();
|
||||||
|
|
||||||
|
set(
|
||||||
|
contextStoreFiltersComponentState.atomFamily({
|
||||||
|
instanceId: 'command-menu',
|
||||||
|
}),
|
||||||
|
contextStoreFilters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextStoreCurrentViewId = snapshot
|
||||||
|
.getLoadable(
|
||||||
|
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||||
|
instanceId: mainContextStoreComponentInstanceId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue();
|
||||||
|
|
||||||
|
set(
|
||||||
|
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||||
|
instanceId: 'command-menu',
|
||||||
|
}),
|
||||||
|
contextStoreCurrentViewId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCommandMenuOpened(true);
|
setIsCommandMenuOpened(true);
|
||||||
@ -77,7 +122,6 @@ export const useCommandMenu = () => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
mainContextStoreComponentInstanceId,
|
mainContextStoreComponentInstanceId,
|
||||||
setCommands,
|
|
||||||
setHotkeyScopeAndMemorizePreviousScope,
|
setHotkeyScopeAndMemorizePreviousScope,
|
||||||
setIsCommandMenuOpened,
|
setIsCommandMenuOpened,
|
||||||
],
|
],
|
||||||
@ -92,17 +136,11 @@ export const useCommandMenu = () => {
|
|||||||
|
|
||||||
if (isCommandMenuOpened) {
|
if (isCommandMenuOpened) {
|
||||||
setIsCommandMenuOpened(false);
|
setIsCommandMenuOpened(false);
|
||||||
setCommands([]);
|
|
||||||
resetSelectedItem();
|
resetSelectedItem();
|
||||||
goBackToPreviousHotkeyScope();
|
goBackToPreviousHotkeyScope();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[goBackToPreviousHotkeyScope, resetSelectedItem, setIsCommandMenuOpened],
|
||||||
goBackToPreviousHotkeyScope,
|
|
||||||
resetSelectedItem,
|
|
||||||
setCommands,
|
|
||||||
setIsCommandMenuOpened,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleCommandMenu = useRecoilCallback(
|
const toggleCommandMenu = useRecoilCallback(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { IconComponent } from 'twenty-ui';
|
import { IconComponent } from 'twenty-ui';
|
||||||
|
|
||||||
export enum CommandType {
|
export enum CommandType {
|
||||||
Navigate = 'Navigate',
|
Navigate = 'Navigate',
|
||||||
Create = 'Create',
|
Create = 'Create',
|
||||||
@ -7,15 +6,17 @@ export enum CommandType {
|
|||||||
WorkflowRun = 'WorkflowRun',
|
WorkflowRun = 'WorkflowRun',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CommandScope {
|
||||||
|
Global = 'Global',
|
||||||
|
RecordSelection = 'RecordSelection',
|
||||||
|
}
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
id: string;
|
id: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?:
|
type?: CommandType;
|
||||||
| CommandType.Navigate
|
scope?: CommandScope;
|
||||||
| CommandType.Create
|
|
||||||
| CommandType.StandardAction
|
|
||||||
| CommandType.WorkflowRun;
|
|
||||||
Icon?: IconComponent;
|
Icon?: IconComponent;
|
||||||
firstHotKey?: string;
|
firstHotKey?: string;
|
||||||
secondHotKey?: string;
|
secondHotKey?: string;
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
ActionMenuEntry,
|
||||||
|
ActionMenuEntryScope,
|
||||||
|
ActionMenuEntryType,
|
||||||
|
} from '@/action-menu/types/ActionMenuEntry';
|
||||||
|
import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandScope,
|
||||||
|
CommandType,
|
||||||
|
} from '@/command-menu/types/Command';
|
||||||
|
|
||||||
|
export const computeCommandMenuCommands = (
|
||||||
|
actionMenuEntries: ActionMenuEntry[],
|
||||||
|
): Command[] => {
|
||||||
|
const commands = Object.values(COMMAND_MENU_COMMANDS);
|
||||||
|
|
||||||
|
const actionCommands: Command[] = actionMenuEntries
|
||||||
|
?.filter(
|
||||||
|
(actionMenuEntry) =>
|
||||||
|
actionMenuEntry.type === ActionMenuEntryType.Standard,
|
||||||
|
)
|
||||||
|
?.map((actionMenuEntry) => ({
|
||||||
|
id: actionMenuEntry.key,
|
||||||
|
label: actionMenuEntry.label,
|
||||||
|
Icon: actionMenuEntry.Icon,
|
||||||
|
onCommandClick: actionMenuEntry.onClick,
|
||||||
|
type: CommandType.StandardAction,
|
||||||
|
scope:
|
||||||
|
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection
|
||||||
|
? CommandScope.RecordSelection
|
||||||
|
: CommandScope.Global,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workflowRunCommands: Command[] = actionMenuEntries
|
||||||
|
?.filter(
|
||||||
|
(actionMenuEntry) =>
|
||||||
|
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun,
|
||||||
|
)
|
||||||
|
?.map((actionMenuEntry) => ({
|
||||||
|
id: actionMenuEntry.key,
|
||||||
|
label: actionMenuEntry.label,
|
||||||
|
Icon: actionMenuEntry.Icon,
|
||||||
|
onCommandClick: actionMenuEntry.onClick,
|
||||||
|
type: CommandType.WorkflowRun,
|
||||||
|
scope:
|
||||||
|
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection
|
||||||
|
? CommandScope.RecordSelection
|
||||||
|
: CommandScope.Global,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...commands, ...actionCommands, ...workflowRunCommands];
|
||||||
|
};
|
||||||
@ -11,10 +11,10 @@ export const MainContextStoreComponentInstanceIdSetterEffect = () => {
|
|||||||
const context = useContext(ContextStoreComponentInstanceContext);
|
const context = useContext(ContextStoreComponentInstanceContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMainContextStoreComponentInstanceId(context?.instanceId ?? null);
|
setMainContextStoreComponentInstanceId(context?.instanceId ?? 'app');
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setMainContextStoreComponentInstanceId(null);
|
setMainContextStoreComponentInstanceId('app');
|
||||||
};
|
};
|
||||||
}, [context, setMainContextStoreComponentInstanceId]);
|
}, [context, setMainContextStoreComponentInstanceId]);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
|
||||||
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
|
|
||||||
|
export const useContextStoreCurrentObjectMetadataIdOrThrow = (
|
||||||
|
instanceId?: string,
|
||||||
|
) => {
|
||||||
|
const contextStoreCurrentObjectMetadataIdComponent =
|
||||||
|
useRecoilComponentValueV2(
|
||||||
|
contextStoreCurrentObjectMetadataIdComponentState,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contextStoreCurrentObjectMetadataIdComponent) {
|
||||||
|
throw new Error('contextStoreCurrentObjectMetadataIdComponent is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return contextStoreCurrentObjectMetadataIdComponent;
|
||||||
|
};
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { useContextStoreCurrentObjectMetadataIdOrThrow } from '@/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow';
|
||||||
|
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||||
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
|
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||||
|
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||||
|
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||||
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
|
|
||||||
|
export const useContextStoreSelectedRecords = ({
|
||||||
|
instanceId,
|
||||||
|
limit = 3,
|
||||||
|
}: {
|
||||||
|
instanceId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}) => {
|
||||||
|
const objectMetadataId =
|
||||||
|
useContextStoreCurrentObjectMetadataIdOrThrow(instanceId);
|
||||||
|
|
||||||
|
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||||
|
objectId: objectMetadataId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
|
||||||
|
contextStoreTargetedRecordsRuleComponentState,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextStoreFilters = useRecoilComponentValueV2(
|
||||||
|
contextStoreFiltersComponentState,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryFilter = computeContextStoreFilters(
|
||||||
|
contextStoreTargetedRecordsRule,
|
||||||
|
contextStoreFilters,
|
||||||
|
objectMetadataItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { records, loading, totalCount } = useFindManyRecords({
|
||||||
|
objectNameSingular: objectMetadataItem.nameSingular,
|
||||||
|
filter: queryFilter,
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
position: 'AscNullsFirst',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skip:
|
||||||
|
contextStoreTargetedRecordsRule.mode === 'selection' &&
|
||||||
|
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { createState } from 'twenty-ui';
|
import { createState } from 'twenty-ui';
|
||||||
|
|
||||||
export const mainContextStoreComponentInstanceIdState = createState<
|
export const mainContextStoreComponentInstanceIdState = createState<string>({
|
||||||
string | null
|
|
||||||
>({
|
|
||||||
key: 'mainContextStoreComponentInstanceIdState',
|
key: 'mainContextStoreComponentInstanceIdState',
|
||||||
defaultValue: null,
|
defaultValue: 'app',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
|
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
|
||||||
|
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
|
||||||
|
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
|
||||||
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
import { AuthModal } from '@/auth/components/AuthModal';
|
import { AuthModal } from '@/auth/components/AuthModal';
|
||||||
import { CommandMenu } from '@/command-menu/components/CommandMenu';
|
import { CommandMenu } from '@/command-menu/components/CommandMenu';
|
||||||
|
import { CommandMenuCommandsEffect } from '@/command-menu/components/CommandMenuCommandsEffect';
|
||||||
|
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||||
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
|
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
|
||||||
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
|
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
|
||||||
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
|
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
|
||||||
@ -80,7 +86,19 @@ export const DefaultLayout = () => {
|
|||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
<StyledLayout>
|
<StyledLayout>
|
||||||
<CommandMenu />
|
<ContextStoreComponentInstanceContext.Provider
|
||||||
|
value={{ instanceId: 'command-menu' }}
|
||||||
|
>
|
||||||
|
<ActionMenuComponentInstanceContext.Provider
|
||||||
|
value={{ instanceId: 'command-menu' }}
|
||||||
|
>
|
||||||
|
<RecordActionMenuEntriesSetter />
|
||||||
|
<GlobalActionMenuEntriesSetter />
|
||||||
|
<ActionMenuConfirmationModals />
|
||||||
|
<CommandMenuCommandsEffect />
|
||||||
|
<CommandMenu />
|
||||||
|
</ActionMenuComponentInstanceContext.Provider>
|
||||||
|
</ContextStoreComponentInstanceContext.Provider>
|
||||||
<KeyboardShortcutMenu />
|
<KeyboardShortcutMenu />
|
||||||
|
|
||||||
<StyledPageContainer
|
<StyledPageContainer
|
||||||
|
|||||||
Reference in New Issue
Block a user