7338 refactor actionbar and contextmenu to use the context store (#7462)
Closes #7338
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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}`);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const ActionMenuComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ActionBarHotkeyScope {
|
||||
ActionBar = 'action-bar',
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ActionMenuDropdownHotkeyScope {
|
||||
ActionMenuDropdown = 'action-menu-dropdown',
|
||||
}
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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]),
|
||||
};
|
||||
};
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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 />;
|
||||
};
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -46,7 +46,7 @@ export const RecordTableInternalEffect = ({
|
||||
|
||||
useListenClickOutsideByClassName({
|
||||
classNames: ['entity-table-cell'],
|
||||
excludeClassNames: ['action-bar', 'context-menu'],
|
||||
excludeClassNames: ['bottom-bar', 'context-menu'],
|
||||
callback: () => {
|
||||
resetTableRowSelection();
|
||||
},
|
||||
|
||||
@ -81,7 +81,9 @@ export const RecordTableWithWrappers = ({
|
||||
/>
|
||||
<DragSelect
|
||||
dragSelectable={tableBodyRef}
|
||||
onDragSelectionStart={resetTableRowSelection}
|
||||
onDragSelectionStart={() => {
|
||||
resetTableRowSelection();
|
||||
}}
|
||||
onDragSelectionChange={setRowSelected}
|
||||
/>
|
||||
</StyledTableInternalContainer>
|
||||
|
||||
@ -70,7 +70,7 @@ const meta: Meta = {
|
||||
onMoveFocus: () => {},
|
||||
onCloseTableCell: () => {},
|
||||
onMoveSoftFocusToCell: () => {},
|
||||
onContextMenu: () => {},
|
||||
onActionMenuDropdownOpened: () => {},
|
||||
onCellMouseEnter: () => {},
|
||||
visibleTableColumns: mockPerformance.visibleTableColumns as any,
|
||||
objectNameSingular:
|
||||
|
||||
@ -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 />;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const BottomBarInstanceContext = createComponentInstanceContext();
|
||||
@ -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,
|
||||
});
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 = {};
|
||||
@ -1,8 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
import { ActionBarEntry } from '../types/ActionBarEntry';
|
||||
|
||||
export const actionBarEntriesState = createState<ActionBarEntry[]>({
|
||||
key: 'actionBarEntriesState',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const actionBarOpenState = createState<boolean>({
|
||||
key: 'actionBarOpenState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry';
|
||||
|
||||
export type ActionBarEntry = ContextMenuEntry & {
|
||||
subActions?: ActionBarEntry[];
|
||||
};
|
||||
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
@ -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 = {};
|
||||
@ -1,8 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
import { ContextMenuEntry } from '../types/ContextMenuEntry';
|
||||
|
||||
export const contextMenuEntriesState = createState<ContextMenuEntry[]>({
|
||||
key: 'contextMenuEntriesState',
|
||||
defaultValue: [],
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const contextMenuIsOpenState = createState<boolean>({
|
||||
key: 'contextMenuIsOpenState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export type ContextMenuItemAccent = 'default' | 'danger';
|
||||
@ -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 = {};
|
||||
@ -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;
|
||||
@ -35,7 +35,7 @@ export const isNonTextWritingKey = (key: string) => {
|
||||
'Delete',
|
||||
'End',
|
||||
'PageDown',
|
||||
'ContextMenu',
|
||||
'ActionMenuDropdown',
|
||||
'PrintScreen',
|
||||
'BrowserBack',
|
||||
'BrowserForward',
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -24,7 +24,7 @@ export const RecordTableDecorator: Decorator = (Story) => {
|
||||
onCellMouseEnter: () => {},
|
||||
onCloseTableCell: () => {},
|
||||
onOpenTableCell: () => {},
|
||||
onContextMenu: () => {},
|
||||
onActionMenuDropdownOpened: () => {},
|
||||
onMoveFocus: () => {},
|
||||
onMoveSoftFocusToCell: () => {},
|
||||
onUpsertRecord: () => {},
|
||||
|
||||
@ -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('');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
export const turnIntoUndefinedIfWhitespacesOnly = (
|
||||
value: string,
|
||||
): string | undefined => {
|
||||
return value.trim() === '' ? undefined : value.trim();
|
||||
return value.trim() === '' ? undefined : value;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user