feat: remove a link from a Links field (#5313)

Closes #5117

TO FIX in another PR: right now, the "Vertical Dots" LightIconButton
inside the Dropdown menu sometimes needs to be clicked twice to open the
nested dropdown, not sure why 🤔 Maybe an `event.preventDefault()` is
needed somewhere?

<img width="369" alt="image"
src="https://github.com/twentyhq/twenty/assets/3098428/dd0c771a-c18d-4eb2-8ed6-b107f56711e9">

---------

Co-authored-by: Jérémy Magrin <jeremy.magrin@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2024-05-22 09:39:21 +02:00
committed by GitHub
parent beaaf33544
commit 48003887ce
11 changed files with 160 additions and 64 deletions

View File

@ -4,8 +4,8 @@ import { Key } from 'ts-key-enum';
import { IconPlus } from 'twenty-ui'; import { IconPlus } from 'twenty-ui';
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
@ -14,6 +14,7 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { toSpliced } from '~/utils/array/toSpliced';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
const StyledDropdownMenu = styled(DropdownMenu)` const StyledDropdownMenu = styled(DropdownMenu)`
@ -35,7 +36,7 @@ export const LinksFieldInput = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const links = useMemo( const links = useMemo<{ url: string; label: string }[]>(
() => () =>
[ [
fieldValue.primaryLinkUrl fieldValue.primaryLinkUrl
@ -53,51 +54,47 @@ export const LinksFieldInput = ({
], ],
); );
const handleDropdownClose = () => {
onCancel?.();
};
useListenClickOutside({ useListenClickOutside({
refs: [containerRef], refs: [containerRef],
callback: (event) => { callback: handleDropdownClose,
event.stopImmediatePropagation();
const isTargetInput =
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT';
if (!isTargetInput) {
onCancel?.();
}
},
}); });
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);
const [isInputDisplayed, setIsInputDisplayed] = useState(false); const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
useScopedHotkeys(Key.Escape, onCancel ?? (() => {}), hotkeyScope); const handleAddLink = () => {
const handleSubmit = () => {
if (!inputValue) return; if (!inputValue) return;
setIsInputDisplayed(false); setIsInputDisplayed(false);
setInputValue(''); setInputValue('');
if (!links.length) { const nextLinks = [...links, { label: '', url: inputValue }];
onSubmit?.(() => const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
persistLinksField({
primaryLinkUrl: inputValue,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
return;
}
onSubmit?.(() =>
persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '',
secondaryLinks: nextSecondaryLinks,
}),
);
};
const handleDeleteLink = (index: number) => {
onSubmit?.(() => onSubmit?.(() =>
persistLinksField({ persistLinksField({
...fieldValue, ...fieldValue,
secondaryLinks: [ secondaryLinks: toSpliced(
...(fieldValue.secondaryLinks ?? []), fieldValue.secondaryLinks ?? [],
{ label: '', url: inputValue }, index - 1,
], 1,
),
}), }),
); );
}; };
@ -108,9 +105,13 @@ export const LinksFieldInput = ({
<> <>
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{links.map(({ label, url }, index) => ( {links.map(({ label, url }, index) => (
<MenuItem <LinksFieldMenuItem
key={index} key={index}
text={<LinkDisplay value={{ label, url }} />} dropdownId={`${hotkeyScope}-links-${index}`}
isPrimary={index === 0}
label={label}
onDelete={() => handleDeleteLink(index)}
url={url}
/> />
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
@ -124,9 +125,9 @@ export const LinksFieldInput = ({
value={inputValue} value={inputValue}
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)} onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmit} onEnter={handleAddLink}
rightComponent={ rightComponent={
<LightIconButton Icon={IconPlus} onClick={handleSubmit} /> <LightIconButton Icon={IconPlus} onClick={handleAddLink} />
} }
/> />
) : ( ) : (

View File

@ -0,0 +1,77 @@
import styled from '@emotion/styled';
import {
IconBookmark,
IconComponent,
IconDotsVertical,
IconTrash,
} from 'twenty-ui';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
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 { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type LinksFieldMenuItemProps = {
dropdownId: string;
isPrimary?: boolean;
label: string;
onDelete: () => void;
url: string;
};
const StyledIconBookmark = styled(IconBookmark)`
color: ${({ theme }) => theme.font.color.light};
height: ${({ theme }) => theme.icon.size.sm}px;
width: ${({ theme }) => theme.icon.size.sm}px;
`;
export const LinksFieldMenuItem = ({
dropdownId,
isPrimary,
label,
onDelete,
url,
}: LinksFieldMenuItemProps) => {
const { isDropdownOpen } = useDropdown(dropdownId);
return (
<MenuItem
text={<LinkDisplay value={{ label, url }} />}
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
iconButtons={[
{
Wrapper: isPrimary
? undefined
: ({ iconButton }) => (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
dropdownPlacement="right-start"
dropdownStrategy="fixed"
disableBlur
clickableComponent={iconButton}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={onDelete}
/>
</DropdownMenuItemsContainer>
}
/>
),
Icon: isPrimary
? (StyledIconBookmark as IconComponent)
: IconDotsVertical,
accent: 'tertiary',
onClick: isPrimary ? undefined : () => {},
},
]}
/>
);
};

View File

@ -1,4 +1,4 @@
import { MouseEvent } from 'react'; import { FunctionComponent, MouseEvent, ReactElement } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
@ -14,7 +14,9 @@ export type LightIconButtonGroupProps = Pick<
'className' | 'size' 'className' | 'size'
> & { > & {
iconButtons: { iconButtons: {
Wrapper?: FunctionComponent<{ iconButton: ReactElement }>;
Icon: IconComponent; Icon: IconComponent;
accent?: LightIconButtonProps['accent'];
onClick?: (event: MouseEvent<any>) => void; onClick?: (event: MouseEvent<any>) => void;
disabled?: boolean; disabled?: boolean;
}[]; }[];
@ -26,16 +28,26 @@ export const LightIconButtonGroup = ({
className, className,
}: LightIconButtonGroupProps) => ( }: LightIconButtonGroupProps) => (
<StyledLightIconButtonGroupContainer className={className}> <StyledLightIconButtonGroupContainer className={className}>
{iconButtons.map(({ Icon, onClick }, index) => { {iconButtons.map(({ Wrapper, Icon, accent, onClick }, index) => {
return ( const iconButton = (
<LightIconButton <LightIconButton
key={`light-icon-button-${index}`} key={`light-icon-button-${index}`}
Icon={Icon} Icon={Icon}
accent={accent}
disabled={!onClick} disabled={!onClick}
onClick={onClick} onClick={onClick}
size={size} size={size}
/> />
); );
return Wrapper ? (
<Wrapper
key={`light-icon-button-wrapper-${index}`}
iconButton={iconButton}
/>
) : (
iconButton
);
})} })}
</StyledLightIconButtonGroupContainer> </StyledLightIconButtonGroupContainer>
); );

View File

@ -460,7 +460,7 @@ export const InternalDatePicker = ({
/> />
</div> </div>
{clearable && ( {clearable && (
<StyledButtonContainer onClick={handleClear} isMenuOpen={false}> <StyledButtonContainer onClick={handleClear}>
<StyledButton LeftIcon={IconCalendarX} text="Clear" /> <StyledButton LeftIcon={IconCalendarX} text="Clear" />
</StyledButtonContainer> </StyledButtonContainer>
)} )}

View File

@ -36,6 +36,7 @@ type DropdownProps = {
dropdownPlacement?: Placement; dropdownPlacement?: Placement;
dropdownMenuWidth?: `${string}px` | `${number}%` | 'auto' | number; dropdownMenuWidth?: `${string}px` | `${number}%` | 'auto' | number;
dropdownOffset?: { x?: number; y?: number }; dropdownOffset?: { x?: number; y?: number };
dropdownStrategy?: 'fixed' | 'absolute';
disableBlur?: boolean; disableBlur?: boolean;
onClickOutside?: () => void; onClickOutside?: () => void;
onClose?: () => void; onClose?: () => void;
@ -51,6 +52,7 @@ export const Dropdown = ({
dropdownId, dropdownId,
dropdownHotkeyScope, dropdownHotkeyScope,
dropdownPlacement = 'bottom-end', dropdownPlacement = 'bottom-end',
dropdownStrategy = 'absolute',
dropdownOffset = { x: 0, y: 0 }, dropdownOffset = { x: 0, y: 0 },
disableBlur = false, disableBlur = false,
onClickOutside, onClickOutside,
@ -75,6 +77,7 @@ export const Dropdown = ({
placement: dropdownPlacement, placement: dropdownPlacement,
middleware: [flip(), ...offsetMiddlewares], middleware: [flip(), ...offsetMiddlewares],
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
strategy: dropdownStrategy,
}); });
const handleHotkeyTriggered = () => { const handleHotkeyTriggered = () => {

View File

@ -25,8 +25,8 @@ const StyledDropdownMenu = styled.div<{
flex-direction: column; flex-direction: column;
z-index: 1; z-index: 1;
width: ${({ width }) => width: ${({ width = 160 }) =>
width ? `${typeof width === 'number' ? `${width}px` : width}` : '160px'}; typeof width === 'number' ? `${width}px` : width};
`; `;
export const DropdownMenu = StyledDropdownMenu; export const DropdownMenu = StyledDropdownMenu;

View File

@ -52,7 +52,7 @@ export const useDropdown = (dropdownId?: string) => {
return { return {
scopeId, scopeId,
isDropdownOpen: isDropdownOpen, isDropdownOpen,
closeDropdown, closeDropdown,
toggleDropdown, toggleDropdown,
openDropdown, openDropdown,

View File

@ -1,6 +1,7 @@
import { MouseEvent, ReactNode } from 'react'; import { FunctionComponent, MouseEvent, ReactElement, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
import { LightIconButtonProps } from '@/ui/input/button/components/LightIconButton';
import { LightIconButtonGroup } from '@/ui/input/button/components/LightIconButtonGroup'; import { LightIconButtonGroup } from '@/ui/input/button/components/LightIconButtonGroup';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent'; import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
@ -11,7 +12,9 @@ import {
import { MenuItemAccent } from '../types/MenuItemAccent'; import { MenuItemAccent } from '../types/MenuItemAccent';
export type MenuItemIconButton = { export type MenuItemIconButton = {
Wrapper?: FunctionComponent<{ iconButton: ReactElement }>;
Icon: IconComponent; Icon: IconComponent;
accent?: LightIconButtonProps['accent'];
onClick?: (event: MouseEvent<any>) => void; onClick?: (event: MouseEvent<any>) => void;
}; };
@ -24,7 +27,7 @@ export type MenuItemProps = {
isTooltipOpen?: boolean; isTooltipOpen?: boolean;
className?: string; className?: string;
testId?: string; testId?: string;
onClick?: (event: MouseEvent<HTMLLIElement>) => void; onClick?: (event: MouseEvent<HTMLDivElement>) => void;
}; };
export const MenuItem = ({ export const MenuItem = ({
@ -32,7 +35,6 @@ export const MenuItem = ({
accent = 'default', accent = 'default',
text, text,
iconButtons, iconButtons,
isTooltipOpen,
isIconDisplayedOnHoverOnly = true, isIconDisplayedOnHoverOnly = true,
className, className,
testId, testId,
@ -40,7 +42,7 @@ export const MenuItem = ({
}: MenuItemProps) => { }: MenuItemProps) => {
const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0; const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0;
const handleMenuItemClick = (event: MouseEvent<HTMLLIElement>) => { const handleMenuItemClick = (event: MouseEvent<HTMLDivElement>) => {
if (!onClick) return; if (!onClick) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -54,7 +56,6 @@ export const MenuItem = ({
onClick={handleMenuItemClick} onClick={handleMenuItemClick}
className={className} className={className}
accent={accent} accent={accent}
isMenuOpen={!!isTooltipOpen}
isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly} isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly}
> >
<StyledMenuItemLeftContent> <StyledMenuItemLeftContent>

View File

@ -23,7 +23,6 @@ export const MenuItemDraggable = ({
LeftIcon, LeftIcon,
accent = 'default', accent = 'default',
iconButtons, iconButtons,
isTooltipOpen,
onClick, onClick,
text, text,
isDragDisabled = false, isDragDisabled = false,
@ -37,7 +36,6 @@ export const MenuItemDraggable = ({
onClick={onClick} onClick={onClick}
accent={accent} accent={accent}
className={className} className={className}
isMenuOpen={!!isTooltipOpen}
isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly} isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly}
> >
<MenuItemLeftContent <MenuItemLeftContent

View File

@ -10,7 +10,7 @@ export type MenuItemBaseProps = {
isKeySelected?: boolean; isKeySelected?: boolean;
}; };
export const StyledMenuItemBase = styled.li<MenuItemBaseProps>` export const StyledMenuItemBase = styled.div<MenuItemBaseProps>`
--horizontal-padding: ${({ theme }) => theme.spacing(1)}; --horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)}; --vertical-padding: ${({ theme }) => theme.spacing(2)};
@ -101,23 +101,26 @@ export const StyledMenuItemRightContent = styled.div`
`; `;
export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{ export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{
isMenuOpen: boolean;
isIconDisplayedOnHoverOnly?: boolean; isIconDisplayedOnHoverOnly?: boolean;
}>` }>`
${({ isIconDisplayedOnHoverOnly, theme }) =>
isIconDisplayedOnHoverOnly &&
css`
& .hoverable-buttons {
opacity: 0;
position: fixed;
right: ${theme.spacing(2)};
}
&:hover {
& .hoverable-buttons {
opacity: 1;
position: static;
}
}
`};
& .hoverable-buttons { & .hoverable-buttons {
pointer-events: none;
position: fixed;
right: ${({ theme }) => theme.spacing(2)};
opacity: ${({ isIconDisplayedOnHoverOnly }) =>
isIconDisplayedOnHoverOnly ? 0 : 1};
transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease; transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease;
} }
&:hover {
& .hoverable-buttons {
opacity: 1;
pointer-events: auto;
position: static;
}
}
`; `;

View File

@ -19,6 +19,7 @@ export {
IconBell, IconBell,
IconBolt, IconBolt,
IconBook2, IconBook2,
IconBookmark,
IconBox, IconBox,
IconBrandGithub, IconBrandGithub,
IconBrandGoogle, IconBrandGoogle,