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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
export type ActionBarItemAccent = 'standard' | 'danger';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
export type ContextMenuEntry = {
label: string;
Icon: IconComponent;
accent?: MenuItemAccent;
onClick?: (event?: MouseEvent<HTMLElement>) => void;
ConfirmationModal?: ReactNode;
};

View File

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

View File

@ -1,4 +0,0 @@
export type PositionType = {
x: number | null;
y: number | null;
};

View File

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

View File

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

View File

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