7338 refactor actionbar and contextmenu to use the context store (#7462)

Closes #7338
This commit is contained in:
Raphaël Bosi
2024-10-10 13:26:19 +02:00
committed by GitHub
parent 54c328a7e6
commit a7d5aa933d
84 changed files with 1481 additions and 954 deletions

View File

@ -0,0 +1,49 @@
import styled from '@emotion/styled';
import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilValue } from 'recoil';
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const ActionMenuBar = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return (
<BottomBar
bottomBarId={`action-bar-${actionMenuId}`}
bottomBarHotkeyScopeFromParent={{
scope: ActionBarHotkeyScope.ActionBar,
}}
>
<StyledLabel>
{contextStoreTargetedRecordIds.length} selected:
</StyledLabel>
{actionMenuEntries.map((entry, index) => (
<ActionMenuBarEntry key={index} entry={entry} />
))}
</BottomBar>
);
};

View File

@ -0,0 +1,49 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
type ActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
const StyledButton = styled.div<{ accent: MenuItemAccent }>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.accent === 'danger'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, accent }) =>
accent === 'danger'
? theme.background.danger
: theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
export const ActionMenuBarEntry = ({ entry }: ActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton
accent={entry.accent ?? 'default'}
onClick={() => entry.onClick?.()}
>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{entry.label}</StyledButtonLabel>
</StyledButton>
);
};

View File

@ -0,0 +1,18 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const ActionMenuConfirmationModals = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return (
<div data-select-disable>
{actionMenuEntries.map((actionMenuEntry, index) =>
actionMenuEntry.ConfirmationModal ? (
<div key={index}>{actionMenuEntry.ConfirmationModal}</div>
) : null,
)}
</div>
);
};

View File

@ -0,0 +1,84 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { PositionType } from '../types/PositionType';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
type StyledContainerProps = {
position: PositionType;
};
const StyledContainerActionMenuDropdown = styled.div<StyledContainerProps>`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
left: ${(props) => `${props.position.x}px`};
position: fixed;
top: ${(props) => `${props.position.y}px`};
transform: translateX(-50%);
width: auto;
`;
export const ActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const actionMenuDropdownPosition = useRecoilValue(
extractComponentState(
actionMenuDropdownPositionComponentState,
`action-menu-dropdown-${actionMenuId}`,
),
);
//TODO: remove this
const width = actionMenuEntries.some(
(actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites',
)
? 200
: undefined;
return (
<StyledContainerActionMenuDropdown
position={actionMenuDropdownPosition}
className="context-menu"
>
<Dropdown
dropdownId={`action-menu-dropdown-${actionMenuId}`}
dropdownHotkeyScope={{
scope: ActionMenuDropdownHotkeyScope.ActionMenuDropdown,
}}
data-select-disable
dropdownMenuWidth={width}
dropdownComponents={actionMenuEntries.map((item, index) => (
<MenuItem
key={index}
LeftIcon={item.Icon}
onClick={item.onClick}
accent={item.accent}
text={item.label}
/>
))}
/>
</StyledContainerActionMenuDropdown>
);
};

View File

@ -0,0 +1,46 @@
import { useActionMenu } from '@/action-menu/hooks/useActionMenu';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
export const ActionMenuEffect = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const { openActionBar, closeActionBar } = useActionMenu(actionMenuId);
const isDropdownOpen = useRecoilValue(
extractComponentState(
isDropdownOpenComponentState,
`action-menu-dropdown-${actionMenuId}`,
),
);
useEffect(() => {
if (contextStoreTargetedRecordIds.length > 0 && !isDropdownOpen) {
// We only handle opening the ActionMenuBar here, not the Dropdown.
// The Dropdown is already managed by sync handlers for events like
// right-click to open and click outside to close.
openActionBar();
}
if (contextStoreTargetedRecordIds.length === 0) {
closeActionBar();
}
}, [
contextStoreTargetedRecordIds,
openActionBar,
closeActionBar,
isDropdownOpen,
]);
return null;
};

View File

@ -0,0 +1,25 @@
import { EmptyActionMenuEntriesEffect } from '@/action-menu/components/EmptyActionMenuEntriesEffect';
import { NonEmptyActionMenuEntriesEffect } from '@/action-menu/components/NonEmptyActionMenuEntriesEffect';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { useRecoilValue } from 'recoil';
export const ActionMenuEntriesProvider = () => {
//TODO: Refactor this
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
);
return (
<>
{contextStoreCurrentObjectMetadataId ? (
<NonEmptyActionMenuEntriesEffect
contextStoreCurrentObjectMetadataId={
contextStoreCurrentObjectMetadataId
}
/>
) : (
<EmptyActionMenuEntriesEffect />
)}
</>
);
};

View File

@ -0,0 +1,14 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';
export const EmptyActionMenuEntriesEffect = () => {
const setActionMenuEntries = useSetRecoilComponentStateV2(
actionMenuEntriesComponentState,
);
useEffect(() => {
setActionMenuEntries([]);
}, [setActionMenuEntries]);
return null;
};

View File

@ -0,0 +1,28 @@
import { useComputeActionsBasedOnContextStore } from '@/action-menu/hooks/useComputeActionsBasedOnContextStore';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';
export const NonEmptyActionMenuEntriesEffect = ({
contextStoreCurrentObjectMetadataId,
}: {
contextStoreCurrentObjectMetadataId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId,
});
const { availableActionsInContext } = useComputeActionsBasedOnContextStore({
objectMetadataItem,
});
const setActionMenuEntries = useSetRecoilComponentStateV2(
actionMenuEntriesComponentState,
);
useEffect(() => {
setActionMenuEntries(availableActionsInContext);
}, [availableActionsInContext, setActionMenuEntries]);
return null;
};

View File

@ -0,0 +1,101 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { userEvent, waitFor, within } from '@storybook/test';
import { IconCheckbox, IconTrash } from 'twenty-ui';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
const meta: Meta<typeof ActionMenuBar> = {
title: 'Modules/ActionMenu/ActionMenuBar',
component: ActionMenuBar,
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(contextStoreTargetedRecordIdsState, ['1', '2', '3']);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
[
{
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
},
{
label: 'Mark as done',
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
],
);
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: 'action-bar-story-action-menu',
}),
true,
);
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
],
args: {
actionMenuId: 'story-action-menu',
},
};
export default meta;
type Story = StoryObj<typeof ActionMenuBar>;
export const Default: Story = {
args: {
actionMenuId: 'story-action-menu',
},
};
export const WithCustomSelection: Story = {
args: {
actionMenuId: 'story-action-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectionText = await canvas.findByText('3 selected:');
expect(selectionText).toBeInTheDocument();
},
};
export const WithButtonClicks: Story = {
args: {
actionMenuId: 'story-action-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
const markAsDoneButton = await canvas.findByText('Mark as done');
await userEvent.click(markAsDoneButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(markAsDoneMock).toHaveBeenCalled();
});
},
};

View File

@ -0,0 +1,56 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator, IconCheckbox, IconTrash } from 'twenty-ui';
import { ActionMenuBarEntry } from '../ActionMenuBarEntry';
const meta: Meta<typeof ActionMenuBarEntry> = {
title: 'Modules/ActionMenu/ActionMenuBarEntry',
component: ActionMenuBarEntry,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof ActionMenuBarEntry>;
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
export const Default: Story = {
args: {
entry: {
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
},
},
};
export const WithDangerAccent: Story = {
args: {
entry: {
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger',
},
},
};
export const WithInteraction: Story = {
args: {
entry: {
label: 'Mark as done',
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByText('Mark as done');
await userEvent.click(button);
expect(markAsDoneMock).toHaveBeenCalled();
},
};

View File

@ -0,0 +1,102 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { RecoilRoot } from 'recoil';
import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
const addToFavoritesMock = jest.fn();
const meta: Meta<typeof ActionMenuDropdown> = {
title: 'Modules/ActionMenu/ActionMenuDropdown',
component: ActionMenuDropdown,
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(
extractComponentState(
actionMenuDropdownPositionComponentState,
'action-menu-dropdown-story',
),
{ x: 10, y: 10 },
);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
[
{
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
},
{
label: 'Mark as done',
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
{
label: 'Add to favorites',
Icon: IconHeart,
onClick: addToFavoritesMock,
},
],
);
set(
extractComponentState(
isDropdownOpenComponentState,
'action-menu-dropdown-story-action-menu',
),
true,
);
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
],
};
export default meta;
type Story = StoryObj<typeof ActionMenuDropdown>;
export const Default: Story = {
args: {
actionMenuId: 'story',
},
};
export const WithInteractions: Story = {
args: {
actionMenuId: 'story',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
expect(deleteMock).toHaveBeenCalled();
const markAsDoneButton = await canvas.findByText('Mark as done');
await userEvent.click(markAsDoneButton);
expect(markAsDoneMock).toHaveBeenCalled();
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};

View File

@ -0,0 +1,83 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { useActionMenu } from '../useActionMenu';
const openBottomBar = jest.fn();
const closeBottomBar = jest.fn();
const openDropdown = jest.fn();
const closeDropdown = jest.fn();
jest.mock('@/ui/layout/bottom-bar/hooks/useBottomBar', () => ({
useBottomBar: jest.fn(() => ({
openBottomBar: openBottomBar,
closeBottomBar: closeBottomBar,
})),
}));
jest.mock('@/ui/layout/dropdown/hooks/useDropdownV2', () => ({
useDropdownV2: jest.fn(() => ({
openDropdown: openDropdown,
closeDropdown: closeDropdown,
})),
}));
describe('useActionMenu', () => {
const actionMenuId = 'test-action-menu';
it('should return the correct functions', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
expect(result.current).toHaveProperty('openActionMenuDropdown');
expect(result.current).toHaveProperty('openActionBar');
expect(result.current).toHaveProperty('closeActionBar');
expect(result.current).toHaveProperty('closeActionMenuDropdown');
});
it('should call the correct functions when opening action menu dropdown', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.openActionMenuDropdown();
});
expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
expect(openDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
});
it('should call the correct functions when opening action bar', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.openActionBar();
});
expect(closeDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
expect(openBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
});
it('should call the correct function when closing action menu dropdown', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.closeActionMenuDropdown();
});
expect(closeDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
});
it('should call the correct function when closing action bar', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.closeActionBar();
});
expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
});
});

View File

@ -0,0 +1,32 @@
import { useBottomBar } from '@/ui/layout/bottom-bar/hooks/useBottomBar';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
export const useActionMenu = (actionMenuId: string) => {
const { openDropdown, closeDropdown } = useDropdownV2();
const { openBottomBar, closeBottomBar } = useBottomBar();
const openActionMenuDropdown = () => {
closeBottomBar(`action-bar-${actionMenuId}`);
openDropdown(`action-menu-dropdown-${actionMenuId}`);
};
const openActionBar = () => {
closeDropdown(`action-menu-dropdown-${actionMenuId}`);
openBottomBar(`action-bar-${actionMenuId}`);
};
const closeActionMenuDropdown = () => {
closeDropdown(`action-menu-dropdown-${actionMenuId}`);
};
const closeActionBar = () => {
closeBottomBar(`action-bar-${actionMenuId}`);
};
return {
openActionMenuDropdown,
openActionBar,
closeActionBar,
closeActionMenuDropdown,
};
};

View File

@ -0,0 +1,148 @@
import { useHandleFavoriteButton } from '@/action-menu/hooks/useHandleFavoriteButton';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
import {
displayedExportProgress,
useExportTableData,
} from '@/object-record/record-index/options/hooks/useExportTableData';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback, useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
IconFileExport,
IconHeart,
IconHeartOff,
IconTrash,
isDefined,
} from 'twenty-ui';
export const useComputeActionsBasedOnContextStore = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const { handleFavoriteButtonClick } = useHandleFavoriteButton(
contextStoreTargetedRecordIds,
objectMetadataItem,
);
const baseTableDataParams = {
delayMs: 100,
objectNameSingular: objectMetadataItem.nameSingular,
recordIndexId: objectMetadataItem.namePlural,
};
const { deleteTableData } = useDeleteTableData(baseTableDataParams);
const handleDeleteClick = useCallback(() => {
deleteTableData(contextStoreTargetedRecordIds);
}, [deleteTableData, contextStoreTargetedRecordIds]);
const { progress, download } = useExportTableData({
...baseTableDataParams,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const isRemoteObject = objectMetadataItem.isRemote;
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
const canDelete =
!isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
const menuActions: ActionMenuEntry[] = useMemo(
() =>
[
{
label: displayedExportProgress(progress),
Icon: IconFileExport,
accent: 'default',
onClick: () => download(),
} satisfies ActionMenuEntry,
canDelete
? ({
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: () => {
setIsDeleteRecordsModalOpen(true);
},
ConfirmationModal: (
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${numberOfSelectedRecords} ${
numberOfSelectedRecords === 1 ? `record` : 'records'
}`}
subtitle={`Are you sure you want to delete ${
numberOfSelectedRecords === 1
? 'this record'
: 'these records'
}? ${
numberOfSelectedRecords === 1 ? 'It' : 'They'
} can be recovered from the Options menu.`}
onConfirmClick={() => handleDeleteClick()}
deleteButtonText={`Delete ${
numberOfSelectedRecords > 1 ? 'Records' : 'Record'
}`}
/>
),
} satisfies ActionMenuEntry)
: undefined,
].filter(isDefined),
[
download,
progress,
canDelete,
handleDeleteClick,
isDeleteRecordsModalOpen,
numberOfSelectedRecords,
],
);
const hasOnlyOneRecordSelected = contextStoreTargetedRecordIds.length === 1;
const { favorites } = useFavorites();
const isFavorite =
isNonEmptyString(contextStoreTargetedRecordIds[0]) &&
!!favorites?.find(
(favorite) => favorite.recordId === contextStoreTargetedRecordIds[0],
);
return {
availableActionsInContext: [
...menuActions,
...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Remove from favorites',
Icon: IconHeartOff,
onClick: handleFavoriteButtonClick,
},
]
: []),
...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Add to favorites',
Icon: IconHeart,
onClick: handleFavoriteButtonClick,
},
]
: []),
],
};
};

View File

@ -0,0 +1,49 @@
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useHandleFavoriteButton = (
selectedRecordIds: string[],
objectMetadataItem: ObjectMetadataItem,
callback?: () => void,
) => {
const { createFavorite, favorites, deleteFavorite } = useFavorites();
const handleFavoriteButtonClick = useRecoilCallback(
({ snapshot }) =>
() => {
if (selectedRecordIds.length > 1) {
return;
}
const selectedRecordId = selectedRecordIds[0];
const selectedRecord = snapshot
.getLoadable(recordStoreFamilyState(selectedRecordId))
.getValue();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === selectedRecordId,
);
const isFavorite = !!selectedRecordId && !!foundFavorite;
if (isFavorite) {
deleteFavorite(foundFavorite.id);
} else if (isDefined(selectedRecord)) {
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
}
callback?.();
},
[
callback,
createFavorite,
deleteFavorite,
favorites,
objectMetadataItem.nameSingular,
selectedRecordIds,
],
);
return { handleFavoriteButtonClick };
};

View File

@ -0,0 +1,11 @@
import { PositionType } from '@/action-menu/types/PositionType';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const actionMenuDropdownPositionComponentState =
createComponentState<PositionType>({
key: 'actionMenuDropdownPositionComponentState',
defaultValue: {
x: null,
y: null,
},
});

View File

@ -0,0 +1,11 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ActionMenuEntry } from '../types/ActionMenuEntry';
export const actionMenuEntriesComponentState = createComponentStateV2<
ActionMenuEntry[]
>({
key: 'actionMenuEntriesComponentState',
defaultValue: [],
componentInstanceContext: ActionMenuComponentInstanceContext,
});

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const ActionMenuComponentInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,3 @@
export enum ActionBarHotkeyScope {
ActionBar = 'action-bar',
}

View File

@ -0,0 +1,3 @@
export enum ActionMenuDropdownHotkeyScope {
ActionMenuDropdown = 'action-menu-dropdown',
}

View File

@ -3,7 +3,7 @@ import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
export type ContextMenuEntry = {
export type ActionMenuEntry = {
label: string;
Icon: IconComponent;
accent?: MenuItemAccent;

View File

@ -1,5 +1,5 @@
import { act } from 'react-dom/test-utils';
import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';

View File

@ -1,6 +1,6 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { IconRefresh } from 'twenty-ui';

View File

@ -1,6 +1,6 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum';
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { IconRefresh } from 'twenty-ui';

View File

@ -1,6 +1,6 @@
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentUserState } from '@/auth/states/currentUserState';
import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum';
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useRecoilValue } from 'recoil';

View File

@ -1,5 +1,6 @@
import { renderHook } from '@testing-library/react';
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@ -25,4 +26,15 @@ describe('useObjectMetadataItem', () => {
expect(objectMetadataItem.id).toBe(opportunityObjectMetadata?.id);
});
it('should throw an error when invalid object name singular is provided', async () => {
expect(() =>
renderHook(
() => useObjectMetadataItem({ objectNameSingular: 'invalid-object' }),
{
wrapper: Wrapper,
},
),
).toThrow(ObjectMetadataItemNotFoundError);
});
});

View File

@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useObjectMetadataItemById', () => {
const opportunityObjectMetadata = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'opportunity',
);
if (!opportunityObjectMetadata) {
throw new Error('Opportunity object metadata not found');
}
it('should return correct properties', async () => {
const { result } = renderHook(
() =>
useObjectMetadataItemById({
objectId: opportunityObjectMetadata.id,
}),
{
wrapper: Wrapper,
},
);
const { objectMetadataItem } = result.current;
expect(objectMetadataItem.id).toBe(opportunityObjectMetadata.id);
});
it('should throw an error when invalid ID is provided', async () => {
expect(() =>
renderHook(() => useObjectMetadataItemById({ objectId: 'invalid-id' }), {
wrapper: Wrapper,
}),
).toThrow(ObjectMetadataItemNotFoundError);
});
});

View File

@ -0,0 +1,25 @@
import { useRecoilValue } from 'recoil';
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { isDefined } from '~/utils/isDefined';
export const useObjectMetadataItemById = ({
objectId,
}: {
objectId: string;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectId,
);
if (!isDefined(objectMetadataItem)) {
throw new ObjectMetadataItemNotFoundError(objectId, objectMetadataItems);
}
return {
objectMetadataItem,
};
};

View File

@ -1,212 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback, useMemo, useState } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { IconFileExport, IconHeart, IconHeartOff, IconTrash } from 'twenty-ui';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
import {
displayedExportProgress,
useExportTableData,
} from '@/object-record/record-index/options/hooks/useExportTableData';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState';
import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry';
import { isDefined } from '~/utils/isDefined';
type useRecordActionBarProps = {
objectMetadataItem: ObjectMetadataItem;
selectedRecordIds: string[];
callback?: () => void;
totalNumberOfRecordsSelected?: number;
};
export const useRecordActionBar = ({
objectMetadataItem,
selectedRecordIds,
callback,
totalNumberOfRecordsSelected,
}: useRecordActionBarProps) => {
const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState);
const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState);
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const { createFavorite, favorites, deleteFavorite } = useFavorites();
const handleFavoriteButtonClick = useRecoilCallback(
({ snapshot }) =>
() => {
if (selectedRecordIds.length > 1) {
return;
}
const selectedRecordId = selectedRecordIds[0];
const selectedRecord = snapshot
.getLoadable(recordStoreFamilyState(selectedRecordId))
.getValue();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === selectedRecordId,
);
const isFavorite = !!selectedRecordId && !!foundFavorite;
if (isFavorite) {
deleteFavorite(foundFavorite.id);
} else if (isDefined(selectedRecord)) {
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
}
callback?.();
},
[
callback,
createFavorite,
deleteFavorite,
favorites,
objectMetadataItem.nameSingular,
selectedRecordIds,
],
);
const baseTableDataParams = {
delayMs: 100,
objectNameSingular: objectMetadataItem.nameSingular,
recordIndexId: objectMetadataItem.namePlural,
};
const { deleteTableData } = useDeleteTableData(baseTableDataParams);
const handleDeleteClick = useCallback(() => {
deleteTableData(selectedRecordIds);
}, [deleteTableData, selectedRecordIds]);
const { progress, download } = useExportTableData({
...baseTableDataParams,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const isRemoteObject = objectMetadataItem.isRemote;
const numberOfSelectedRecords =
totalNumberOfRecordsSelected ?? selectedRecordIds.length;
const canDelete =
!isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
const menuActions: ContextMenuEntry[] = useMemo(
() =>
[
{
label: displayedExportProgress(progress),
Icon: IconFileExport,
accent: 'default',
onClick: () => download(),
} satisfies ContextMenuEntry,
canDelete
? ({
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: () => {
setIsDeleteRecordsModalOpen(true);
},
ConfirmationModal: (
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${numberOfSelectedRecords} ${
numberOfSelectedRecords === 1 ? `record` : 'records'
}`}
subtitle={`Are you sure you want to delete ${
numberOfSelectedRecords === 1
? 'this record'
: 'these records'
}? ${
numberOfSelectedRecords === 1 ? 'It' : 'They'
} can be recovered from the Options menu.`}
onConfirmClick={() => handleDeleteClick()}
deleteButtonText={`Delete ${
numberOfSelectedRecords > 1 ? 'Records' : 'Record'
}`}
/>
),
} satisfies ContextMenuEntry)
: undefined,
].filter(isDefined),
[
download,
progress,
canDelete,
handleDeleteClick,
isDeleteRecordsModalOpen,
numberOfSelectedRecords,
],
);
const hasOnlyOneRecordSelected = selectedRecordIds.length === 1;
const isFavorite =
isNonEmptyString(selectedRecordIds[0]) &&
!!favorites?.find((favorite) => favorite.recordId === selectedRecordIds[0]);
return {
setContextMenuEntries: useCallback(() => {
setContextMenuEntries([
...menuActions,
...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Remove from favorites',
Icon: IconHeartOff,
onClick: handleFavoriteButtonClick,
},
]
: []),
...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Add to favorites',
Icon: IconHeart,
onClick: handleFavoriteButtonClick,
},
]
: []),
]);
}, [
menuActions,
handleFavoriteButtonClick,
hasOnlyOneRecordSelected,
isFavorite,
isRemoteObject,
setContextMenuEntries,
]),
setActionBarEntries: useCallback(() => {
setActionBarEntriesState([
/*
{
label: 'Actions',
Icon: IconClick,
subActions:
/* [
{
label: 'Enrich',
Icon: IconPuzzle,
onClick: handleExecuteQuickActionOnClick,
},
{
label: 'Send to mailjet',
Icon: IconMail,
},
],
*/
...menuActions,
]);
}, [menuActions, setActionBarEntriesState]),
};
};

View File

@ -1,22 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar';
type RecordBoardActionBarProps = {
recordBoardId: string;
};
export const RecordBoardActionBar = ({
recordBoardId,
}: RecordBoardActionBarProps) => {
const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId);
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
if (!selectedRecordIds.length) {
return null;
}
return <ActionBar selectedIds={selectedRecordIds} />;
};

View File

@ -69,7 +69,7 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
useListenClickOutsideByClassName({
classNames: ['record-board-card'],
excludeClassNames: ['action-bar', 'context-menu'],
excludeClassNames: ['bottom-bar', 'context-menu'],
callback: resetRecordSelection,
});

View File

@ -1,22 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu';
type RecordBoardContextMenuProps = {
recordBoardId: string;
};
export const RecordBoardContextMenu = ({
recordBoardId,
}: RecordBoardContextMenuProps) => {
const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId);
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
if (!selectedRecordIds.length) {
return null;
}
return <ContextMenu />;
};

View File

@ -1,17 +1,23 @@
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
export const useRecordBoardSelection = (recordBoardId?: string) => {
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
export const useRecordBoardSelection = (recordBoardId: string) => {
const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } =
useRecordBoardStates(recordBoardId);
const resetRecordSelection = useRecoilCallback(
({ snapshot, set }) =>
() => {
setContextMenuOpenState(false);
const isActionMenuDropdownOpenState = extractComponentState(
isDropdownOpenComponentState,
`action-menu-dropdown-${recordBoardId}`,
);
set(isActionMenuDropdownOpenState, false);
const recordIds = snapshot
.getLoadable(selectedRecordIdsSelector())
.getValue();
@ -21,9 +27,9 @@ export const useRecordBoardSelection = (recordBoardId?: string) => {
}
},
[
recordBoardId,
selectedRecordIdsSelector,
isRecordBoardCardSelectedFamilyState,
setContextMenuOpenState,
],
);

View File

@ -1,6 +1,9 @@
import { useActionMenu } from '@/action-menu/hooks/useActionMenu';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import {
FieldContext,
RecordUpdateHook,
@ -17,10 +20,10 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
import { TextInput } from '@/ui/input/components/TextInput';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import styled from '@emotion/styled';
import { ReactNode, useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
@ -175,17 +178,27 @@ export const RecordBoardCard = ({
const record = useRecoilValue(recordStoreFamilyState(recordId));
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
const recordBoardId = useAvailableScopeIdOrThrow(
RecordBoardScopeInternalContext,
);
const handleContextMenu = (event: React.MouseEvent) => {
const setActionMenuDropdownPosition = useSetRecoilState(
extractComponentState(
actionMenuDropdownPositionComponentState,
`action-menu-dropdown-${recordBoardId}`,
),
);
const { openActionMenuDropdown } = useActionMenu(recordBoardId);
const handleActionMenuDropdown = (event: React.MouseEvent) => {
event.preventDefault();
setIsCurrentCardSelected(true);
setContextMenuPosition({
setActionMenuDropdownPosition({
x: event.clientX,
y: event.clientY,
});
setContextMenuOpenState(true);
openActionMenuDropdown();
};
const PreventSelectOnClickContainer = ({
@ -235,7 +248,7 @@ export const RecordBoardCard = ({
);
return (
<StyledBoardCardWrapper onContextMenu={handleContextMenu}>
<StyledBoardCardWrapper onContextMenu={handleActionMenuDropdown}>
{!isCreating && <RecordValueSetterEffect recordId={recordId} />}
<StyledBoardCard
ref={cardRef}

View File

@ -4,9 +4,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordBoardActionBar } from '@/object-record/record-board/action-bar/components/RecordBoardActionBar';
import { RecordBoard } from '@/object-record/record-board/components/RecordBoard';
import { RecordBoardContextMenu } from '@/object-record/record-board/context-menu/components/RecordBoardContextMenu';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
@ -51,8 +49,6 @@ export const RecordIndexBoardContainer = ({
}}
>
<RecordBoard recordBoardId={recordBoardId} />
<RecordBoardActionBar recordBoardId={recordBoardId} />
<RecordBoardContextMenu recordBoardId={recordBoardId} />
</RecordBoardContext.Provider>
);
};

View File

@ -6,9 +6,7 @@ import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
@ -79,8 +77,6 @@ export const RecordIndexBoardDataLoaderEffect = ({
setNavigationMemorizedUrl,
]);
const { resetRecordSelection } = useRecordBoardSelection(recordBoardId);
useEffect(() => {
setObjectSingularName(objectNameSingular);
}, [objectNameSingular, setObjectSingularName]);
@ -125,12 +121,6 @@ export const RecordIndexBoardDataLoaderEffect = ({
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
objectMetadataItem,
selectedRecordIds,
callback: resetRecordSelection,
});
const setContextStoreTargetedRecordIds = useSetRecoilState(
contextStoreTargetedRecordIdsState,
);
@ -140,9 +130,8 @@ export const RecordIndexBoardDataLoaderEffect = ({
);
useEffect(() => {
setActionBarEntries?.();
setContextMenuEntries?.();
}, [setActionBarEntries, setContextMenuEntries]);
setContextStoreTargetedRecordIds(selectedRecordIds);
}, [selectedRecordIds, setContextStoreTargetedRecordIds]);
useEffect(() => {
setContextStoreTargetedRecordIds(selectedRecordIds);

View File

@ -23,6 +23,13 @@ import { RecordIndexRootPropsContext } from '@/object-record/record-index/contex
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown';
import { ActionMenuEffect } from '@/action-menu/components/ActionMenuEffect';
import { ActionMenuEntriesProvider } from '@/action-menu/components/ActionMenuEntriesProvider';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewField } from '@/views/types/ViewField';
@ -191,6 +198,15 @@ export const RecordIndexContainer = () => {
/>
</StyledContainerWithPadding>
)}
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<ActionMenuEffect />
<ActionMenuEntriesProvider />
<ActionMenuBar />
<ActionMenuDropdown />
<ActionMenuConfirmationModals />
</ActionMenuComponentInstanceContext.Provider>
</RecordFieldValueSelectorContextProvider>
</ViewComponentInstanceContext.Provider>
</StyledContainer>

View File

@ -2,9 +2,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
import { useContext } from 'react';
type RecordIndexTableContainerProps = {
@ -37,9 +35,7 @@ export const RecordIndexTableContainer = ({
viewBarId={viewBarId}
updateRecordMutation={updateEntity}
/>
<RecordTableActionBar recordTableId={recordTableId} />
<RecordIndexRemoveSortingModal recordTableId={recordTableId} />
<RecordTableContextMenu recordTableId={recordTableId} />
</>
);
};

View File

@ -5,14 +5,10 @@ import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar';
import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter';
import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
import { entityCountInCurrentViewComponentState } from '@/views/states/entityCountInCurrentViewComponentState';
type RecordIndexTableContainerEffectProps = {
objectNameSingular: string;
@ -28,7 +24,6 @@ export const RecordIndexTableContainerEffect = ({
const {
setAvailableTableColumns,
setOnEntityCountChange,
resetTableRowSelection,
selectedRowIdsSelector,
setOnToggleColumnFilter,
setOnToggleColumnSort,
@ -58,34 +53,8 @@ export const RecordIndexTableContainerEffect = ({
setAvailableTableColumns(columnDefinitions);
}, [columnDefinitions, setAvailableTableColumns]);
const { tableRowIdsState, hasUserSelectedAllRowsState } =
useRecordTableStates(recordTableId);
// TODO: verify this instance id works
const entityCountInCurrentView = useRecoilComponentValueV2(
entityCountInCurrentViewComponentState,
recordTableId,
);
const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState);
const tableRowIds = useRecoilValue(tableRowIdsState);
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
const numSelected =
hasUserSelectedAllRows && entityCountInCurrentView
? selectedRowIds.length === tableRowIds.length
? entityCountInCurrentView
: entityCountInCurrentView -
(tableRowIds.length - selectedRowIds.length) // unselected row Ids
: selectedRowIds.length;
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
objectMetadataItem,
selectedRecordIds: selectedRowIds,
callback: resetTableRowSelection,
totalNumberOfRecordsSelected: numSelected,
});
const handleToggleColumnFilter = useHandleToggleColumnFilter({
objectNameSingular,
viewBarId,
@ -110,11 +79,6 @@ export const RecordIndexTableContainerEffect = ({
);
}, [setOnToggleColumnSort, handleToggleColumnSort]);
useEffect(() => {
setActionBarEntries?.();
setContextMenuEntries?.();
}, [setActionBarEntries, setContextMenuEntries]);
useEffect(() => {
setOnEntityCountChange(
() => (entityCount: number) => setRecordCountInCurrentView(entityCount),

View File

@ -1,47 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { entityCountInCurrentViewComponentState } from '@/views/states/entityCountInCurrentViewComponentState';
export const RecordTableActionBar = ({
recordTableId,
}: {
recordTableId: string;
}) => {
const {
selectedRowIdsSelector,
tableRowIdsState,
hasUserSelectedAllRowsState,
} = useRecordTableStates(recordTableId);
// TODO: verify this instance id works
const entityCountInCurrentView = useRecoilComponentValueV2(
entityCountInCurrentViewComponentState,
recordTableId,
);
const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState);
const tableRowIds = useRecoilValue(tableRowIdsState);
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
const totalNumberOfSelectedRecords =
hasUserSelectedAllRows && entityCountInCurrentView
? selectedRowIds.length === tableRowIds.length
? entityCountInCurrentView
: entityCountInCurrentView -
(tableRowIds.length - selectedRowIds.length) // unselected row Ids
: selectedRowIds.length;
if (!selectedRowIds.length) {
return null;
}
return (
<ActionBar
selectedIds={selectedRowIds}
totalNumberOfSelectedRecords={totalNumberOfSelectedRecords}
/>
);
};

View File

@ -12,7 +12,7 @@ import {
OpenTableCellArgs,
useOpenRecordTableCellV2,
} from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useTriggerContextMenu } from '@/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu';
import { useTriggerActionMenuDropdown } from '@/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown';
import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
@ -75,12 +75,15 @@ export const RecordTableContextProvider = ({
moveSoftFocusToCell(cellPosition);
};
const { triggerContextMenu } = useTriggerContextMenu({
const { triggerActionMenuDropdown } = useTriggerActionMenuDropdown({
recordTableId,
});
const handleContextMenu = (event: React.MouseEvent, recordId: string) => {
triggerContextMenu(event, recordId);
const handleActionMenuDropdown = (
event: React.MouseEvent,
recordId: string,
) => {
triggerActionMenuDropdown(event, recordId);
};
const { handleContainerMouseEnter } = useHandleContainerMouseEnter({
@ -99,7 +102,7 @@ export const RecordTableContextProvider = ({
onMoveFocus: handleMoveFocus,
onCloseTableCell: handleCloseTableCell,
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
onContextMenu: handleContextMenu,
onActionMenuDropdownOpened: handleActionMenuDropdown,
onCellMouseEnter: handleContainerMouseEnter,
visibleTableColumns,
recordTableId,

View File

@ -46,7 +46,7 @@ export const RecordTableInternalEffect = ({
useListenClickOutsideByClassName({
classNames: ['entity-table-cell'],
excludeClassNames: ['action-bar', 'context-menu'],
excludeClassNames: ['bottom-bar', 'context-menu'],
callback: () => {
resetTableRowSelection();
},

View File

@ -81,7 +81,9 @@ export const RecordTableWithWrappers = ({
/>
<DragSelect
dragSelectable={tableBodyRef}
onDragSelectionStart={resetTableRowSelection}
onDragSelectionStart={() => {
resetTableRowSelection();
}}
onDragSelectionChange={setRowSelected}
/>
</StyledTableInternalContainer>

View File

@ -70,7 +70,7 @@ const meta: Meta = {
onMoveFocus: () => {},
onCloseTableCell: () => {},
onMoveSoftFocusToCell: () => {},
onContextMenu: () => {},
onActionMenuDropdownOpened: () => {},
onCellMouseEnter: () => {},
visibleTableColumns: mockPerformance.visibleTableColumns as any,
objectNameSingular:

View File

@ -1,20 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu';
export const RecordTableContextMenu = ({
recordTableId,
}: {
recordTableId: string;
}) => {
const { selectedRowIdsSelector } = useRecordTableStates(recordTableId);
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
if (!selectedRowIds.length) {
return null;
}
return <ContextMenu />;
};

View File

@ -24,7 +24,10 @@ export type RecordTableContextProps = {
onMoveFocus: (direction: MoveFocusDirection) => void;
onCloseTableCell: () => void;
onMoveSoftFocusToCell: (cellPosition: TableCellPosition) => void;
onContextMenu: (event: React.MouseEvent, recordId: string) => void;
onActionMenuDropdownOpened: (
event: React.MouseEvent,
recordId: string,
) => void;
onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void;
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
recordTableId: string;

View File

@ -1,7 +1,9 @@
import { useRecoilCallback } from 'recoil';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
export const useResetTableRowSelection = (recordTableId?: string) => {
const {
@ -20,7 +22,19 @@ export const useResetTableRowSelection = (recordTableId?: string) => {
}
set(hasUserSelectedAllRowsState, false);
const isActionMenuDropdownOpenState = extractComponentState(
isDropdownOpenComponentState,
`action-menu-dropdown-${recordTableId}`,
);
set(isActionMenuDropdownOpenState, false);
},
[tableRowIdsState, isRowSelectedFamilyState, hasUserSelectedAllRowsState],
[
tableRowIdsState,
hasUserSelectedAllRowsState,
recordTableId,
isRowSelectedFamilyState,
],
);
};

View File

@ -71,10 +71,10 @@ export const RecordTableCellBaseContainer = ({
}
};
const { onContextMenu } = useContext(RecordTableContext);
const { onActionMenuDropdownOpened } = useContext(RecordTableContext);
const handleContextMenu = (event: React.MouseEvent) => {
onContextMenu(event, recordId);
const handleActionMenuDropdown = (event: React.MouseEvent) => {
onActionMenuDropdownOpened(event, recordId);
};
const { hotkeyScope } = useContext(FieldContext);
@ -87,7 +87,7 @@ export const RecordTableCellBaseContainer = ({
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
onClick={handleContainerClick}
onContextMenu={handleContextMenu}
onContextMenu={handleActionMenuDropdown}
backgroundColorTransparentSecondary={
theme.background.transparent.secondary
}

View File

@ -1,13 +1,12 @@
import styled from '@emotion/styled';
import { useCallback, useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected';
import { Checkbox } from '@/ui/input/components/Checkbox';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
const StyledContainer = styled.div`
align-items: center;
@ -24,14 +23,12 @@ export const RecordTableCellCheckbox = () => {
const { recordId } = useContext(RecordTableRowContext);
const { isRowSelectedFamilyState } = useRecordTableStates();
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
const { setCurrentRowSelected } = useSetCurrentRowSelected();
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
const handleClick = useCallback(() => {
setCurrentRowSelected(!currentRowSelected);
setActionBarOpenState(true);
}, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]);
}, [currentRowSelected, setCurrentRowSelected]);
return (
<RecordTableTd isSelected={isSelected} hasRightBorder={false}>

View File

@ -0,0 +1,67 @@
import { useRecoilCallback } from 'recoil';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
export const useTriggerActionMenuDropdown = ({
recordTableId,
}: {
recordTableId: string;
}) => {
const triggerActionMenuDropdown = useRecoilCallback(
({ set, snapshot }) =>
(event: React.MouseEvent, recordId: string) => {
event.preventDefault();
const tableScopeId = getScopeIdFromComponentId(recordTableId);
set(
extractComponentState(
actionMenuDropdownPositionComponentState,
`action-menu-dropdown-${recordTableId}`,
),
{
x: event.clientX,
y: event.clientY,
},
);
const isRowSelectedFamilyState = extractComponentFamilyState(
isRowSelectedComponentFamilyState,
tableScopeId,
);
const isRowSelected = getSnapshotValue(
snapshot,
isRowSelectedFamilyState(recordId),
);
if (isRowSelected !== true) {
set(isRowSelectedFamilyState(recordId), true);
}
const isActionMenuDropdownOpenState = extractComponentState(
isDropdownOpenComponentState,
`action-menu-dropdown-${recordTableId}`,
);
const isActionBarOpenState = isBottomBarOpenedComponentState.atomFamily(
{
instanceId: `action-bar-${recordTableId}`,
},
);
set(isActionBarOpenState, false);
set(isActionMenuDropdownOpenState, true);
},
[recordTableId],
);
return { triggerActionMenuDropdown };
};

View File

@ -1,46 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
export const useTriggerContextMenu = ({
recordTableId,
}: {
recordTableId: string;
}) => {
const triggerContextMenu = useRecoilCallback(
({ set, snapshot }) =>
(event: React.MouseEvent, recordId: string) => {
event.preventDefault();
const tableScopeId = getScopeIdFromComponentId(recordTableId);
set(contextMenuPositionState, {
x: event.clientX,
y: event.clientY,
});
set(contextMenuIsOpenState, true);
const isRowSelectedFamilyState = extractComponentFamilyState(
isRowSelectedComponentFamilyState,
tableScopeId,
);
const isRowSelected = getSnapshotValue(
snapshot,
isRowSelectedFamilyState(recordId),
);
if (isRowSelected !== true) {
set(isRowSelectedFamilyState(recordId), true);
}
},
[recordTableId],
);
return { triggerContextMenu };
};

View File

@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions';
import { SIGN_IN_BACKGROUND_MOCK_FILTER_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockFilterDefinitions';
@ -23,14 +22,10 @@ export const SignInBackgroundMockContainerEffect = ({
recordTableId,
viewId,
}: SignInBackgroundMockContainerEffectProps) => {
const {
setAvailableTableColumns,
setOnEntityCountChange,
setTableColumns,
resetTableRowSelection,
} = useRecordTable({
recordTableId,
});
const { setAvailableTableColumns, setOnEntityCountChange, setTableColumns } =
useRecordTable({
recordTableId,
});
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
@ -75,17 +70,6 @@ export const SignInBackgroundMockContainerEffect = ({
setTableColumns,
]);
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
objectMetadataItem,
selectedRecordIds: [],
callback: resetTableRowSelection,
});
useEffect(() => {
setActionBarEntries?.();
setContextMenuEntries?.();
}, [setActionBarEntries, setContextMenuEntries]);
useEffect(() => {
setOnEntityCountChange(
() => (entityCount: number) => setRecordCountInCurrentView(entityCount),

View File

@ -2,8 +2,6 @@ import styled from '@emotion/styled';
import { IconBuildingSkyscraper } from 'twenty-ui';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
import { SignInBackgroundMockContainer } from '@/sign-in-background-mock/components/SignInBackgroundMockContainer';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
import { PageBody } from '@/ui/layout/page/PageBody';
@ -29,8 +27,6 @@ export const SignInBackgroundMockPage = () => {
<StyledTableContainer>
<SignInBackgroundMockContainer />
</StyledTableContainer>
<RecordTableActionBar recordTableId="mock" />
<RecordTableContextMenu recordTableId="mock" />
</RecordFieldValueSelectorContextProvider>
</PageBody>
</PageContainer>

View File

@ -0,0 +1,61 @@
import styled from '@emotion/styled';
import { useBottomBarInternalHotkeyScopeManagement } from '@/ui/layout/bottom-bar/hooks/useBottomBarInternalHotkeyScopeManagement';
import { BottomBarInstanceContext } from '@/ui/layout/bottom-bar/states/contexts/BottomBarInstanceContext';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const StyledContainerActionBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
bottom: 38px;
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
height: 48px;
width: max-content;
left: 50%;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
position: absolute;
top: auto;
transform: translateX(-50%);
z-index: 1;
`;
type BottomBarProps = {
bottomBarId: string;
bottomBarHotkeyScopeFromParent: HotkeyScope;
children: React.ReactNode;
};
export const BottomBar = ({
bottomBarId,
bottomBarHotkeyScopeFromParent,
children,
}: BottomBarProps) => {
const isBottomBarOpen = useRecoilComponentValueV2(
isBottomBarOpenedComponentState,
bottomBarId,
);
useBottomBarInternalHotkeyScopeManagement({
bottomBarId,
bottomBarHotkeyScopeFromParent,
});
if (!isBottomBarOpen) {
return null;
}
return (
<BottomBarInstanceContext.Provider value={{ instanceId: bottomBarId }}>
<StyledContainerActionBar data-select-disable className="bottom-bar">
{children}
</StyledContainerActionBar>
</BottomBarInstanceContext.Provider>
);
};

View File

@ -0,0 +1,65 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconPlus } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button';
import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import styled from '@emotion/styled';
import { RecoilRoot } from 'recoil';
const StyledContainer = styled.div`
display: flex;
gap: 10px;
`;
const meta: Meta<typeof BottomBar> = {
title: 'UI/Layout/BottomBar/BottomBar',
component: BottomBar,
args: {
bottomBarId: 'test',
bottomBarHotkeyScopeFromParent: { scope: 'test' },
children: (
<StyledContainer>
<Button title="Test 1" Icon={IconPlus} />
<Button title="Test 2" Icon={IconPlus} />
<Button title="Test 3" Icon={IconPlus} />
</StyledContainer>
),
},
argTypes: {
bottomBarId: { control: false },
bottomBarHotkeyScopeFromParent: { control: false },
children: { control: false },
},
};
export default meta;
export const Default: StoryObj<typeof BottomBar> = {
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: 'test',
}),
true,
);
}}
>
<Story />
</RecoilRoot>
),
],
};
export const Closed: StoryObj<typeof BottomBar> = {
decorators: [
(Story) => (
<RecoilRoot>
<Story />
</RecoilRoot>
),
],
};

View File

@ -0,0 +1,87 @@
import { useRecoilCallback } from 'recoil';
import { bottomBarHotkeyComponentState } from '@/ui/layout/bottom-bar/states/bottomBarHotkeyComponentState';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDefined } from '~/utils/isDefined';
export const useBottomBar = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const closeBottomBar = useRecoilCallback(
({ set }) =>
(specificComponentId: string) => {
goBackToPreviousHotkeyScope();
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: specificComponentId,
}),
false,
);
},
[goBackToPreviousHotkeyScope],
);
const openBottomBar = useRecoilCallback(
({ set, snapshot }) =>
(specificComponentId: string, customHotkeyScope?: HotkeyScope) => {
const bottomBarHotkeyScope = snapshot
.getLoadable(
bottomBarHotkeyComponentState.atomFamily({
instanceId: specificComponentId,
}),
)
.getValue();
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: specificComponentId,
}),
true,
);
if (isDefined(customHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
customHotkeyScope.scope,
customHotkeyScope.customScopes,
);
} else if (isDefined(bottomBarHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope(
bottomBarHotkeyScope.scope,
bottomBarHotkeyScope.customScopes,
);
}
},
[setHotkeyScopeAndMemorizePreviousScope],
);
const toggleBottomBar = useRecoilCallback(
({ snapshot }) =>
(specificComponentId: string) => {
const isBottomBarOpen = snapshot
.getLoadable(
isBottomBarOpenedComponentState.atomFamily({
instanceId: specificComponentId,
}),
)
.getValue();
if (isBottomBarOpen) {
closeBottomBar(specificComponentId);
} else {
openBottomBar(specificComponentId);
}
},
[closeBottomBar, openBottomBar],
);
return {
closeBottomBar,
openBottomBar,
toggleBottomBar,
};
};

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { bottomBarHotkeyComponentState } from '@/ui/layout/bottom-bar/states/bottomBarHotkeyComponentState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useBottomBarInternalHotkeyScopeManagement = ({
bottomBarId,
bottomBarHotkeyScopeFromParent,
}: {
bottomBarId?: string;
bottomBarHotkeyScopeFromParent?: HotkeyScope;
}) => {
const [bottomBarHotkeyScope, setBottomBarHotkeyScope] =
useRecoilComponentStateV2(bottomBarHotkeyComponentState, bottomBarId);
useEffect(() => {
if (!isDeeplyEqual(bottomBarHotkeyScopeFromParent, bottomBarHotkeyScope)) {
setBottomBarHotkeyScope(bottomBarHotkeyScopeFromParent);
}
}, [
bottomBarHotkeyScope,
bottomBarHotkeyScopeFromParent,
setBottomBarHotkeyScope,
]);
};

View File

@ -0,0 +1,11 @@
import { BottomBarInstanceContext } from '@/ui/layout/bottom-bar/states/contexts/BottomBarInstanceContext';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const bottomBarHotkeyComponentState = createComponentStateV2<
HotkeyScope | null | undefined
>({
key: 'bottomBarHotkeyComponentState',
defaultValue: null,
componentInstanceContext: BottomBarInstanceContext,
});

View File

@ -0,0 +1,3 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const BottomBarInstanceContext = createComponentInstanceContext();

View File

@ -0,0 +1,8 @@
import { BottomBarInstanceContext } from '@/ui/layout/bottom-bar/states/contexts/BottomBarInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isBottomBarOpenedComponentState = createComponentStateV2<boolean>({
key: 'isBottomBarOpenedComponentState',
defaultValue: false,
componentInstanceContext: BottomBarInstanceContext,
});

View File

@ -1,91 +0,0 @@
import styled from '@emotion/styled';
import { useEffect, useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal';
import { isDefined } from '~/utils/isDefined';
import { ActionBarItem } from './ActionBarItem';
type ActionBarProps = {
selectedIds?: string[];
totalNumberOfSelectedRecords?: number;
};
const StyledContainerActionBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
bottom: 38px;
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
height: 48px;
width: max-content;
left: 50%;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
position: absolute;
top: auto;
transform: translateX(-50%);
z-index: 1;
`;
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const ActionBar = ({
selectedIds = [],
totalNumberOfSelectedRecords,
}: ActionBarProps) => {
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
useEffect(() => {
if (selectedIds && selectedIds.length > 1) {
setContextMenuOpenState(false);
}
}, [selectedIds, setContextMenuOpenState]);
const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState);
const actionBarEntries = useRecoilValue(actionBarEntriesState);
const wrapperRef = useRef<HTMLDivElement>(null);
if (contextMenuIsOpen) {
return null;
}
const selectedNumberLabel =
totalNumberOfSelectedRecords ?? selectedIds?.length;
const showSelectedNumberLabel =
isDefined(totalNumberOfSelectedRecords) || Array.isArray(selectedIds);
return (
<>
<StyledContainerActionBar
data-select-disable
className="action-bar"
ref={wrapperRef}
>
{showSelectedNumberLabel && (
<StyledLabel>{selectedNumberLabel} selected:</StyledLabel>
)}
{actionBarEntries.map((item, index) => (
<ActionBarItem key={index} item={item} />
))}
</StyledContainerActionBar>
<SharedNavigationModal
actionBarEntries={actionBarEntries}
customClassName="action-bar"
/>
</>
);
};

View File

@ -1,93 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from 'twenty-ui';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { ActionBarEntry } from '@/ui/navigation/action-bar/types/ActionBarEntry';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
type ActionBarItemProps = {
item: ActionBarEntry;
};
const StyledButton = styled.div<{ accent: MenuItemAccent }>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.accent === 'danger'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, accent }) =>
accent === 'danger'
? theme.background.danger
: theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
export const ActionBarItem = ({ item }: ActionBarItemProps) => {
const theme = useTheme();
const dropdownId = `action-bar-item-${item.label}`;
const { toggleDropdown, closeDropdown } = useDropdown(dropdownId);
return (
<>
{Array.isArray(item.subActions) ? (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="top-start"
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<StyledButton
accent={item.accent ?? 'default'}
onClick={toggleDropdown}
>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{item.label}</StyledButtonLabel>
<IconChevronDown size={theme.icon.size.md} />
</StyledButton>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{item.subActions.map((subAction) => (
<MenuItem
key={subAction.label}
text={subAction.label}
LeftIcon={subAction.Icon}
onClick={() => {
closeDropdown();
subAction.onClick?.();
}}
/>
))}
</DropdownMenuItemsContainer>
}
/>
) : (
<StyledButton
accent={item.accent ?? 'default'}
onClick={() => item.onClick?.()}
>
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{item.label}</StyledButtonLabel>
</StyledButton>
)}
</>
);
};

View File

@ -1,38 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { actionBarOpenState } from '../../states/actionBarIsOpenState';
import { ActionBar } from '../ActionBar';
const FilledActionBar = () => {
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
setActionBarOpenState(true);
return <ActionBar />;
};
const meta: Meta<typeof ActionBar> = {
title: 'UI/Navigation/ActionBar/ActionBar',
component: FilledActionBar,
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordTableScope
recordTableScopeId="companies"
onColumnsChange={() => {}}
>
<Story />
</RecordTableScope>
),
ComponentDecorator,
],
args: { selectedIds: ['TestId'] },
};
export default meta;
type Story = StoryObj<typeof ActionBar>;
export const Default: Story = {};

View File

@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
import { ActionBarEntry } from '../types/ActionBarEntry';
export const actionBarEntriesState = createState<ActionBarEntry[]>({
key: 'actionBarEntriesState',
defaultValue: [],
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const actionBarOpenState = createState<boolean>({
key: 'actionBarOpenState',
defaultValue: false,
});

View File

@ -1,5 +0,0 @@
import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry';
export type ActionBarEntry = ContextMenuEntry & {
subActions?: ActionBarEntry[];
};

View File

@ -1,78 +0,0 @@
import React, { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal';
import { contextMenuEntriesState } from '../states/contextMenuEntriesState';
import { contextMenuIsOpenState } from '../states/contextMenuIsOpenState';
import { PositionType } from '../types/PositionType';
import { ContextMenuItem } from './ContextMenuItem';
type StyledContainerProps = {
position: PositionType;
};
const StyledContainerContextMenu = styled.div<StyledContainerProps>`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
gap: 1px;
left: ${(props) => `${props.position.x}px`};
position: fixed;
top: ${(props) => `${props.position.y}px`};
transform: translateX(-50%);
width: auto;
z-index: 2;
`;
export const ContextMenu = () => {
const contextMenuPosition = useRecoilValue(contextMenuPositionState);
const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState);
const contextMenuEntries = useRecoilValue(contextMenuEntriesState);
const wrapperRef = useRef<HTMLDivElement>(null);
const actionBarEntries = useRecoilValue(actionBarEntriesState);
if (!contextMenuIsOpen) {
return null;
}
const width = contextMenuEntries.some(
(contextMenuEntry) => contextMenuEntry.label === 'Remove from favorites',
)
? 200
: undefined;
return (
<>
<StyledContainerContextMenu
className="context-menu"
ref={wrapperRef}
position={contextMenuPosition}
>
<DropdownMenu data-select-disable width={width}>
<DropdownMenuItemsContainer>
{contextMenuEntries.map((item, index) => {
return <ContextMenuItem key={index} item={item} />;
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledContainerContextMenu>
<SharedNavigationModal
actionBarEntries={actionBarEntries}
customClassName="context-menu"
/>
</>
);
};

View File

@ -1,15 +0,0 @@
import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type ContextMenuItemProps = {
item: ContextMenuEntry;
};
export const ContextMenuItem = ({ item }: ContextMenuItemProps) => (
<MenuItem
LeftIcon={item.Icon}
onClick={item.onClick}
accent={item.accent}
text={item.label}
/>
);

View File

@ -1,44 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { contextMenuIsOpenState } from '../../states/contextMenuIsOpenState';
import { contextMenuPositionState } from '../../states/contextMenuPositionState';
import { ContextMenu } from '../ContextMenu';
const FilledContextMenu = () => {
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
setContextMenuPosition({
x: 100,
y: 10,
});
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
setContextMenuOpenState(true);
return <ContextMenu />;
};
const meta: Meta<typeof ContextMenu> = {
title: 'UI/Navigation/ContextMenu/ContextMenu',
component: FilledContextMenu,
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordTableScope
recordTableScopeId="companies"
onColumnsChange={() => {}}
>
<Story />
</RecordTableScope>
),
ComponentDecorator,
],
args: { selectedIds: ['TestId'] },
};
export default meta;
type Story = StoryObj<typeof ContextMenu>;
export const Default: Story = {};

View File

@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
import { ContextMenuEntry } from '../types/ContextMenuEntry';
export const contextMenuEntriesState = createState<ContextMenuEntry[]>({
key: 'contextMenuEntriesState',
defaultValue: [],
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const contextMenuIsOpenState = createState<boolean>({
key: 'contextMenuIsOpenState',
defaultValue: false,
});

View File

@ -1,11 +0,0 @@
import { createState } from 'twenty-ui';
import { PositionType } from '@/ui/navigation/context-menu/types/PositionType';
export const contextMenuPositionState = createState<PositionType>({
key: 'contextMenuPositionState',
defaultValue: {
x: null,
y: null,
},
});

View File

@ -1 +0,0 @@
export type ContextMenuItemAccent = 'default' | 'danger';

View File

@ -1,35 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconTrash } from 'twenty-ui';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal';
const meta: Meta<typeof SharedNavigationModal> = {
title: 'UI/Navigation/Shared/SharedNavigationModal',
component: SharedNavigationModal,
args: {
actionBarEntries: [
{
ConfirmationModal: (
<ConfirmationModal
title="Title"
deleteButtonText="Delete"
onConfirmClick={() => {}}
setIsOpen={() => {}}
isOpen={false}
subtitle="Subtitle"
/>
),
Icon: IconTrash,
label: 'Label',
onClick: () => {},
},
],
customClassName: 'customClassName',
},
};
export default meta;
type Story = StoryObj<typeof SharedNavigationModal>;
export const Default: Story = {};

View File

@ -1,23 +0,0 @@
import { ActionBarEntry } from '@/ui/navigation/action-bar/types/ActionBarEntry';
type SharedNavigationModalProps = {
actionBarEntries: ActionBarEntry[];
customClassName: string;
};
const SharedNavigationModal = ({
actionBarEntries,
customClassName,
}: SharedNavigationModalProps) => {
return (
<div data-select-disable className={customClassName}>
{actionBarEntries.map((actionBarEntry, index) =>
actionBarEntry.ConfirmationModal ? (
<div key={index}>{actionBarEntry.ConfirmationModal}</div>
) : null,
)}
</div>
);
};
export default SharedNavigationModal;

View File

@ -35,7 +35,7 @@ export const isNonTextWritingKey = (key: string) => {
'Delete',
'End',
'PageDown',
'ContextMenu',
'ActionMenuDropdown',
'PrintScreen',
'BrowserBack',
'BrowserForward',

View File

@ -0,0 +1,15 @@
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
export const RecordShowPageEffect = ({ recordId }: { recordId: string }) => {
const setContextStoreTargetedRecordIds = useSetRecoilState(
contextStoreTargetedRecordIdsState,
);
useEffect(() => {
setContextStoreTargetedRecordIds([recordId]);
}, [recordId, setContextStoreTargetedRecordIds]);
return null;
};

View File

@ -24,7 +24,7 @@ export const RecordTableDecorator: Decorator = (Story) => {
onCellMouseEnter: () => {},
onCloseTableCell: () => {},
onOpenTableCell: () => {},
onContextMenu: () => {},
onActionMenuDropdownOpened: () => {},
onMoveFocus: () => {},
onMoveSoftFocusToCell: () => {},
onUpsertRecord: () => {},

View File

@ -0,0 +1,19 @@
import { turnIntoEmptyStringIfWhitespacesOnly } from '../turnIntoEmptyStringIfWhitespacesOnly';
describe('turnIntoEmptyStringIfWhitespacesOnly', () => {
it('should return an empty string for whitespace-only input', () => {
expect(turnIntoEmptyStringIfWhitespacesOnly(' ')).toBe('');
expect(turnIntoEmptyStringIfWhitespacesOnly('\t\n ')).toBe('');
expect(turnIntoEmptyStringIfWhitespacesOnly(' \n\r\t')).toBe('');
});
it('should return the original string for non-whitespace input', () => {
expect(turnIntoEmptyStringIfWhitespacesOnly('hello')).toBe('hello');
expect(turnIntoEmptyStringIfWhitespacesOnly(' hello ')).toBe(' hello ');
expect(turnIntoEmptyStringIfWhitespacesOnly('123')).toBe('123');
});
it('should handle empty string input', () => {
expect(turnIntoEmptyStringIfWhitespacesOnly('')).toBe('');
});
});

View File

@ -0,0 +1,19 @@
import { turnIntoUndefinedIfWhitespacesOnly } from '../turnIntoUndefinedIfWhitespacesOnly';
describe('turnIntoUndefinedIfWhitespacesOnly', () => {
it('should return undefined for whitespace-only input', () => {
expect(turnIntoUndefinedIfWhitespacesOnly(' ')).toBeUndefined();
expect(turnIntoUndefinedIfWhitespacesOnly('\t\n ')).toBeUndefined();
expect(turnIntoUndefinedIfWhitespacesOnly(' \n\r\t')).toBeUndefined();
});
it('should return the original string for non-whitespace input', () => {
expect(turnIntoUndefinedIfWhitespacesOnly('hello')).toBe('hello');
expect(turnIntoUndefinedIfWhitespacesOnly(' hello ')).toBe(' hello ');
expect(turnIntoUndefinedIfWhitespacesOnly('123')).toBe('123');
});
it('should handle empty string input', () => {
expect(turnIntoUndefinedIfWhitespacesOnly('')).toBeUndefined();
});
});

View File

@ -1,5 +1,5 @@
export const turnIntoUndefinedIfWhitespacesOnly = (
value: string,
): string | undefined => {
return value.trim() === '' ? undefined : value.trim();
return value.trim() === '' ? undefined : value;
};