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';
|
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
|
||||||
|
|
||||||
export type ContextMenuEntry = {
|
export type ActionMenuEntry = {
|
||||||
label: string;
|
label: string;
|
||||||
Icon: IconComponent;
|
Icon: IconComponent;
|
||||||
accent?: MenuItemAccent;
|
accent?: MenuItemAccent;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer';
|
import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer';
|
||||||
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
|
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||||
import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum';
|
|
||||||
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
||||||
|
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
||||||
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
||||||
import { IconRefresh } from 'twenty-ui';
|
import { IconRefresh } from 'twenty-ui';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||||
import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum';
|
|
||||||
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect';
|
||||||
|
import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys';
|
||||||
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
||||||
import { IconRefresh } from 'twenty-ui';
|
import { IconRefresh } from 'twenty-ui';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
||||||
import { currentUserState } from '@/auth/states/currentUserState';
|
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
|
||||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||||
@ -25,4 +26,15 @@ describe('useObjectMetadataItem', () => {
|
|||||||
|
|
||||||
expect(objectMetadataItem.id).toBe(opportunityObjectMetadata?.id);
|
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({
|
useListenClickOutsideByClassName({
|
||||||
classNames: ['record-board-card'],
|
classNames: ['record-board-card'],
|
||||||
excludeClassNames: ['action-bar', 'context-menu'],
|
excludeClassNames: ['bottom-bar', 'context-menu'],
|
||||||
callback: resetRecordSelection,
|
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 { 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) => {
|
export const useRecordBoardSelection = (recordBoardId: string) => {
|
||||||
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
|
|
||||||
const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } =
|
const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } =
|
||||||
useRecordBoardStates(recordBoardId);
|
useRecordBoardStates(recordBoardId);
|
||||||
|
|
||||||
const resetRecordSelection = useRecoilCallback(
|
const resetRecordSelection = useRecoilCallback(
|
||||||
({ snapshot, set }) =>
|
({ snapshot, set }) =>
|
||||||
() => {
|
() => {
|
||||||
setContextMenuOpenState(false);
|
const isActionMenuDropdownOpenState = extractComponentState(
|
||||||
|
isDropdownOpenComponentState,
|
||||||
|
`action-menu-dropdown-${recordBoardId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
set(isActionMenuDropdownOpenState, false);
|
||||||
|
|
||||||
const recordIds = snapshot
|
const recordIds = snapshot
|
||||||
.getLoadable(selectedRecordIdsSelector())
|
.getLoadable(selectedRecordIdsSelector())
|
||||||
.getValue();
|
.getValue();
|
||||||
@ -21,9 +27,9 @@ export const useRecordBoardSelection = (recordBoardId?: string) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
recordBoardId,
|
||||||
selectedRecordIdsSelector,
|
selectedRecordIdsSelector,
|
||||||
isRecordBoardCardSelectedFamilyState,
|
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 { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||||
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
|
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
|
||||||
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
|
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 {
|
import {
|
||||||
FieldContext,
|
FieldContext,
|
||||||
RecordUpdateHook,
|
RecordUpdateHook,
|
||||||
@ -17,10 +20,10 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
|||||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||||
import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
|
import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
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 { 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 { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
|
||||||
|
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { ReactNode, useContext, useState } from 'react';
|
import { ReactNode, useContext, useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@ -175,17 +178,27 @@ export const RecordBoardCard = ({
|
|||||||
|
|
||||||
const record = useRecoilValue(recordStoreFamilyState(recordId));
|
const record = useRecoilValue(recordStoreFamilyState(recordId));
|
||||||
|
|
||||||
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
|
const recordBoardId = useAvailableScopeIdOrThrow(
|
||||||
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
|
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();
|
event.preventDefault();
|
||||||
setIsCurrentCardSelected(true);
|
setIsCurrentCardSelected(true);
|
||||||
setContextMenuPosition({
|
setActionMenuDropdownPosition({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
});
|
});
|
||||||
setContextMenuOpenState(true);
|
openActionMenuDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
const PreventSelectOnClickContainer = ({
|
const PreventSelectOnClickContainer = ({
|
||||||
@ -235,7 +248,7 @@ export const RecordBoardCard = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledBoardCardWrapper onContextMenu={handleContextMenu}>
|
<StyledBoardCardWrapper onContextMenu={handleActionMenuDropdown}>
|
||||||
{!isCreating && <RecordValueSetterEffect recordId={recordId} />}
|
{!isCreating && <RecordValueSetterEffect recordId={recordId} />}
|
||||||
<StyledBoardCard
|
<StyledBoardCard
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
|||||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
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 { 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 { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||||
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
|
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
|
||||||
|
|
||||||
@ -51,8 +49,6 @@ export const RecordIndexBoardContainer = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RecordBoard recordBoardId={recordBoardId} />
|
<RecordBoard recordBoardId={recordBoardId} />
|
||||||
<RecordBoardActionBar recordBoardId={recordBoardId} />
|
|
||||||
<RecordBoardContextMenu recordBoardId={recordBoardId} />
|
|
||||||
</RecordBoardContext.Provider>
|
</RecordBoardContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states
|
|||||||
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||||
import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug';
|
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 { 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 { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
|
||||||
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
|
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
|
||||||
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
|
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
|
||||||
@ -79,8 +77,6 @@ export const RecordIndexBoardDataLoaderEffect = ({
|
|||||||
setNavigationMemorizedUrl,
|
setNavigationMemorizedUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { resetRecordSelection } = useRecordBoardSelection(recordBoardId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setObjectSingularName(objectNameSingular);
|
setObjectSingularName(objectNameSingular);
|
||||||
}, [objectNameSingular, setObjectSingularName]);
|
}, [objectNameSingular, setObjectSingularName]);
|
||||||
@ -125,12 +121,6 @@ export const RecordIndexBoardDataLoaderEffect = ({
|
|||||||
|
|
||||||
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
|
const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector());
|
||||||
|
|
||||||
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
|
|
||||||
objectMetadataItem,
|
|
||||||
selectedRecordIds,
|
|
||||||
callback: resetRecordSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setContextStoreTargetedRecordIds = useSetRecoilState(
|
const setContextStoreTargetedRecordIds = useSetRecoilState(
|
||||||
contextStoreTargetedRecordIdsState,
|
contextStoreTargetedRecordIdsState,
|
||||||
);
|
);
|
||||||
@ -140,9 +130,8 @@ export const RecordIndexBoardDataLoaderEffect = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActionBarEntries?.();
|
setContextStoreTargetedRecordIds(selectedRecordIds);
|
||||||
setContextMenuEntries?.();
|
}, [selectedRecordIds, setContextStoreTargetedRecordIds]);
|
||||||
}, [setActionBarEntries, setContextMenuEntries]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContextStoreTargetedRecordIds(selectedRecordIds);
|
setContextStoreTargetedRecordIds(selectedRecordIds);
|
||||||
|
|||||||
@ -23,6 +23,13 @@ import { RecordIndexRootPropsContext } from '@/object-record/record-index/contex
|
|||||||
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
|
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
|
||||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||||
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
|
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 { ViewBar } from '@/views/components/ViewBar';
|
||||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||||
import { ViewField } from '@/views/types/ViewField';
|
import { ViewField } from '@/views/types/ViewField';
|
||||||
@ -191,6 +198,15 @@ export const RecordIndexContainer = () => {
|
|||||||
/>
|
/>
|
||||||
</StyledContainerWithPadding>
|
</StyledContainerWithPadding>
|
||||||
)}
|
)}
|
||||||
|
<ActionMenuComponentInstanceContext.Provider
|
||||||
|
value={{ instanceId: recordIndexId }}
|
||||||
|
>
|
||||||
|
<ActionMenuEffect />
|
||||||
|
<ActionMenuEntriesProvider />
|
||||||
|
<ActionMenuBar />
|
||||||
|
<ActionMenuDropdown />
|
||||||
|
<ActionMenuConfirmationModals />
|
||||||
|
</ActionMenuComponentInstanceContext.Provider>
|
||||||
</RecordFieldValueSelectorContextProvider>
|
</RecordFieldValueSelectorContextProvider>
|
||||||
</ViewComponentInstanceContext.Provider>
|
</ViewComponentInstanceContext.Provider>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
|
|||||||
@ -2,9 +2,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
|||||||
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
|
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
|
||||||
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
|
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
|
||||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
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 { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
|
||||||
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
|
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
type RecordIndexTableContainerProps = {
|
type RecordIndexTableContainerProps = {
|
||||||
@ -37,9 +35,7 @@ export const RecordIndexTableContainer = ({
|
|||||||
viewBarId={viewBarId}
|
viewBarId={viewBarId}
|
||||||
updateRecordMutation={updateEntity}
|
updateRecordMutation={updateEntity}
|
||||||
/>
|
/>
|
||||||
<RecordTableActionBar recordTableId={recordTableId} />
|
|
||||||
<RecordIndexRemoveSortingModal 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 { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||||
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
|
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
|
||||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
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 { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter';
|
||||||
import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort';
|
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 { 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 { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
|
||||||
import { entityCountInCurrentViewComponentState } from '@/views/states/entityCountInCurrentViewComponentState';
|
|
||||||
|
|
||||||
type RecordIndexTableContainerEffectProps = {
|
type RecordIndexTableContainerEffectProps = {
|
||||||
objectNameSingular: string;
|
objectNameSingular: string;
|
||||||
@ -28,7 +24,6 @@ export const RecordIndexTableContainerEffect = ({
|
|||||||
const {
|
const {
|
||||||
setAvailableTableColumns,
|
setAvailableTableColumns,
|
||||||
setOnEntityCountChange,
|
setOnEntityCountChange,
|
||||||
resetTableRowSelection,
|
|
||||||
selectedRowIdsSelector,
|
selectedRowIdsSelector,
|
||||||
setOnToggleColumnFilter,
|
setOnToggleColumnFilter,
|
||||||
setOnToggleColumnSort,
|
setOnToggleColumnSort,
|
||||||
@ -58,34 +53,8 @@ export const RecordIndexTableContainerEffect = ({
|
|||||||
setAvailableTableColumns(columnDefinitions);
|
setAvailableTableColumns(columnDefinitions);
|
||||||
}, [columnDefinitions, setAvailableTableColumns]);
|
}, [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 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({
|
const handleToggleColumnFilter = useHandleToggleColumnFilter({
|
||||||
objectNameSingular,
|
objectNameSingular,
|
||||||
viewBarId,
|
viewBarId,
|
||||||
@ -110,11 +79,6 @@ export const RecordIndexTableContainerEffect = ({
|
|||||||
);
|
);
|
||||||
}, [setOnToggleColumnSort, handleToggleColumnSort]);
|
}, [setOnToggleColumnSort, handleToggleColumnSort]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActionBarEntries?.();
|
|
||||||
setContextMenuEntries?.();
|
|
||||||
}, [setActionBarEntries, setContextMenuEntries]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOnEntityCountChange(
|
setOnEntityCountChange(
|
||||||
() => (entityCount: number) => setRecordCountInCurrentView(entityCount),
|
() => (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,
|
OpenTableCellArgs,
|
||||||
useOpenRecordTableCellV2,
|
useOpenRecordTableCellV2,
|
||||||
} from '@/object-record/record-table/record-table-cell/hooks/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 { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
|
||||||
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
|
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
|
||||||
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
|
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
|
||||||
@ -75,12 +75,15 @@ export const RecordTableContextProvider = ({
|
|||||||
moveSoftFocusToCell(cellPosition);
|
moveSoftFocusToCell(cellPosition);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { triggerContextMenu } = useTriggerContextMenu({
|
const { triggerActionMenuDropdown } = useTriggerActionMenuDropdown({
|
||||||
recordTableId,
|
recordTableId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleContextMenu = (event: React.MouseEvent, recordId: string) => {
|
const handleActionMenuDropdown = (
|
||||||
triggerContextMenu(event, recordId);
|
event: React.MouseEvent,
|
||||||
|
recordId: string,
|
||||||
|
) => {
|
||||||
|
triggerActionMenuDropdown(event, recordId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { handleContainerMouseEnter } = useHandleContainerMouseEnter({
|
const { handleContainerMouseEnter } = useHandleContainerMouseEnter({
|
||||||
@ -99,7 +102,7 @@ export const RecordTableContextProvider = ({
|
|||||||
onMoveFocus: handleMoveFocus,
|
onMoveFocus: handleMoveFocus,
|
||||||
onCloseTableCell: handleCloseTableCell,
|
onCloseTableCell: handleCloseTableCell,
|
||||||
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
|
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
|
||||||
onContextMenu: handleContextMenu,
|
onActionMenuDropdownOpened: handleActionMenuDropdown,
|
||||||
onCellMouseEnter: handleContainerMouseEnter,
|
onCellMouseEnter: handleContainerMouseEnter,
|
||||||
visibleTableColumns,
|
visibleTableColumns,
|
||||||
recordTableId,
|
recordTableId,
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export const RecordTableInternalEffect = ({
|
|||||||
|
|
||||||
useListenClickOutsideByClassName({
|
useListenClickOutsideByClassName({
|
||||||
classNames: ['entity-table-cell'],
|
classNames: ['entity-table-cell'],
|
||||||
excludeClassNames: ['action-bar', 'context-menu'],
|
excludeClassNames: ['bottom-bar', 'context-menu'],
|
||||||
callback: () => {
|
callback: () => {
|
||||||
resetTableRowSelection();
|
resetTableRowSelection();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -81,7 +81,9 @@ export const RecordTableWithWrappers = ({
|
|||||||
/>
|
/>
|
||||||
<DragSelect
|
<DragSelect
|
||||||
dragSelectable={tableBodyRef}
|
dragSelectable={tableBodyRef}
|
||||||
onDragSelectionStart={resetTableRowSelection}
|
onDragSelectionStart={() => {
|
||||||
|
resetTableRowSelection();
|
||||||
|
}}
|
||||||
onDragSelectionChange={setRowSelected}
|
onDragSelectionChange={setRowSelected}
|
||||||
/>
|
/>
|
||||||
</StyledTableInternalContainer>
|
</StyledTableInternalContainer>
|
||||||
|
|||||||
@ -70,7 +70,7 @@ const meta: Meta = {
|
|||||||
onMoveFocus: () => {},
|
onMoveFocus: () => {},
|
||||||
onCloseTableCell: () => {},
|
onCloseTableCell: () => {},
|
||||||
onMoveSoftFocusToCell: () => {},
|
onMoveSoftFocusToCell: () => {},
|
||||||
onContextMenu: () => {},
|
onActionMenuDropdownOpened: () => {},
|
||||||
onCellMouseEnter: () => {},
|
onCellMouseEnter: () => {},
|
||||||
visibleTableColumns: mockPerformance.visibleTableColumns as any,
|
visibleTableColumns: mockPerformance.visibleTableColumns as any,
|
||||||
objectNameSingular:
|
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;
|
onMoveFocus: (direction: MoveFocusDirection) => void;
|
||||||
onCloseTableCell: () => void;
|
onCloseTableCell: () => void;
|
||||||
onMoveSoftFocusToCell: (cellPosition: TableCellPosition) => void;
|
onMoveSoftFocusToCell: (cellPosition: TableCellPosition) => void;
|
||||||
onContextMenu: (event: React.MouseEvent, recordId: string) => void;
|
onActionMenuDropdownOpened: (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
recordId: string,
|
||||||
|
) => void;
|
||||||
onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void;
|
onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void;
|
||||||
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
|
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
|
||||||
recordTableId: string;
|
recordTableId: string;
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useRecoilCallback } from 'recoil';
|
import { useRecoilCallback } from 'recoil';
|
||||||
|
|
||||||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
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 { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
|
||||||
|
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
|
||||||
|
|
||||||
export const useResetTableRowSelection = (recordTableId?: string) => {
|
export const useResetTableRowSelection = (recordTableId?: string) => {
|
||||||
const {
|
const {
|
||||||
@ -20,7 +22,19 @@ export const useResetTableRowSelection = (recordTableId?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(hasUserSelectedAllRowsState, false);
|
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) => {
|
const handleActionMenuDropdown = (event: React.MouseEvent) => {
|
||||||
onContextMenu(event, recordId);
|
onActionMenuDropdownOpened(event, recordId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { hotkeyScope } = useContext(FieldContext);
|
const { hotkeyScope } = useContext(FieldContext);
|
||||||
@ -87,7 +87,7 @@ export const RecordTableCellBaseContainer = ({
|
|||||||
onMouseLeave={handleContainerMouseLeave}
|
onMouseLeave={handleContainerMouseLeave}
|
||||||
onMouseMove={handleContainerMouseMove}
|
onMouseMove={handleContainerMouseMove}
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleActionMenuDropdown}
|
||||||
backgroundColorTransparentSecondary={
|
backgroundColorTransparentSecondary={
|
||||||
theme.background.transparent.secondary
|
theme.background.transparent.secondary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
|
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
|
||||||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
|
||||||
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
|
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 { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected';
|
||||||
import { Checkbox } from '@/ui/input/components/Checkbox';
|
import { Checkbox } from '@/ui/input/components/Checkbox';
|
||||||
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -24,14 +23,12 @@ export const RecordTableCellCheckbox = () => {
|
|||||||
|
|
||||||
const { recordId } = useContext(RecordTableRowContext);
|
const { recordId } = useContext(RecordTableRowContext);
|
||||||
const { isRowSelectedFamilyState } = useRecordTableStates();
|
const { isRowSelectedFamilyState } = useRecordTableStates();
|
||||||
const setActionBarOpenState = useSetRecoilState(actionBarOpenState);
|
|
||||||
const { setCurrentRowSelected } = useSetCurrentRowSelected();
|
const { setCurrentRowSelected } = useSetCurrentRowSelected();
|
||||||
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
|
const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId));
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
setCurrentRowSelected(!currentRowSelected);
|
setCurrentRowSelected(!currentRowSelected);
|
||||||
setActionBarOpenState(true);
|
}, [currentRowSelected, setCurrentRowSelected]);
|
||||||
}, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecordTableTd isSelected={isSelected} hasRightBorder={false}>
|
<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 { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||||
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
|
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 { 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_COLUMN_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions';
|
||||||
import { SIGN_IN_BACKGROUND_MOCK_FILTER_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockFilterDefinitions';
|
import { SIGN_IN_BACKGROUND_MOCK_FILTER_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockFilterDefinitions';
|
||||||
@ -23,14 +22,10 @@ export const SignInBackgroundMockContainerEffect = ({
|
|||||||
recordTableId,
|
recordTableId,
|
||||||
viewId,
|
viewId,
|
||||||
}: SignInBackgroundMockContainerEffectProps) => {
|
}: SignInBackgroundMockContainerEffectProps) => {
|
||||||
const {
|
const { setAvailableTableColumns, setOnEntityCountChange, setTableColumns } =
|
||||||
setAvailableTableColumns,
|
useRecordTable({
|
||||||
setOnEntityCountChange,
|
recordTableId,
|
||||||
setTableColumns,
|
});
|
||||||
resetTableRowSelection,
|
|
||||||
} = useRecordTable({
|
|
||||||
recordTableId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { objectNameSingular } = useObjectNameSingularFromPlural({
|
const { objectNameSingular } = useObjectNameSingularFromPlural({
|
||||||
objectNamePlural,
|
objectNamePlural,
|
||||||
@ -75,17 +70,6 @@ export const SignInBackgroundMockContainerEffect = ({
|
|||||||
setTableColumns,
|
setTableColumns,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
|
|
||||||
objectMetadataItem,
|
|
||||||
selectedRecordIds: [],
|
|
||||||
callback: resetTableRowSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActionBarEntries?.();
|
|
||||||
setContextMenuEntries?.();
|
|
||||||
}, [setActionBarEntries, setContextMenuEntries]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOnEntityCountChange(
|
setOnEntityCountChange(
|
||||||
() => (entityCount: number) => setRecordCountInCurrentView(entityCount),
|
() => (entityCount: number) => setRecordCountInCurrentView(entityCount),
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import styled from '@emotion/styled';
|
|||||||
import { IconBuildingSkyscraper } from 'twenty-ui';
|
import { IconBuildingSkyscraper } from 'twenty-ui';
|
||||||
|
|
||||||
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
|
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 { SignInBackgroundMockContainer } from '@/sign-in-background-mock/components/SignInBackgroundMockContainer';
|
||||||
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
|
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
|
||||||
import { PageBody } from '@/ui/layout/page/PageBody';
|
import { PageBody } from '@/ui/layout/page/PageBody';
|
||||||
@ -29,8 +27,6 @@ export const SignInBackgroundMockPage = () => {
|
|||||||
<StyledTableContainer>
|
<StyledTableContainer>
|
||||||
<SignInBackgroundMockContainer />
|
<SignInBackgroundMockContainer />
|
||||||
</StyledTableContainer>
|
</StyledTableContainer>
|
||||||
<RecordTableActionBar recordTableId="mock" />
|
|
||||||
<RecordTableContextMenu recordTableId="mock" />
|
|
||||||
</RecordFieldValueSelectorContextProvider>
|
</RecordFieldValueSelectorContextProvider>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</PageContainer>
|
</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',
|
'Delete',
|
||||||
'End',
|
'End',
|
||||||
'PageDown',
|
'PageDown',
|
||||||
'ContextMenu',
|
'ActionMenuDropdown',
|
||||||
'PrintScreen',
|
'PrintScreen',
|
||||||
'BrowserBack',
|
'BrowserBack',
|
||||||
'BrowserForward',
|
'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: () => {},
|
onCellMouseEnter: () => {},
|
||||||
onCloseTableCell: () => {},
|
onCloseTableCell: () => {},
|
||||||
onOpenTableCell: () => {},
|
onOpenTableCell: () => {},
|
||||||
onContextMenu: () => {},
|
onActionMenuDropdownOpened: () => {},
|
||||||
onMoveFocus: () => {},
|
onMoveFocus: () => {},
|
||||||
onMoveSoftFocusToCell: () => {},
|
onMoveSoftFocusToCell: () => {},
|
||||||
onUpsertRecord: () => {},
|
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 = (
|
export const turnIntoUndefinedIfWhitespacesOnly = (
|
||||||
value: string,
|
value: string,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
return value.trim() === '' ? undefined : value.trim();
|
return value.trim() === '' ? undefined : value;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user