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 { 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 { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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 { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { toSpliced } from '~/utils/array/toSpliced';
import { isDefined } from '~/utils/isDefined';
const StyledDropdownMenu = styled(DropdownMenu)`
@ -35,7 +36,7 @@ export const LinksFieldInput = ({
const containerRef = useRef<HTMLDivElement>(null);
const links = useMemo(
const links = useMemo<{ url: string; label: string }[]>(
() =>
[
fieldValue.primaryLinkUrl
@ -53,51 +54,47 @@ export const LinksFieldInput = ({
],
);
const handleDropdownClose = () => {
onCancel?.();
};
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const isTargetInput =
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT';
if (!isTargetInput) {
onCancel?.();
}
},
callback: handleDropdownClose,
});
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
useScopedHotkeys(Key.Escape, onCancel ?? (() => {}), hotkeyScope);
const handleSubmit = () => {
const handleAddLink = () => {
if (!inputValue) return;
setIsInputDisplayed(false);
setInputValue('');
if (!links.length) {
onSubmit?.(() =>
persistLinksField({
primaryLinkUrl: inputValue,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
return;
}
const nextLinks = [...links, { label: '', url: inputValue }];
const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
onSubmit?.(() =>
persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '',
secondaryLinks: nextSecondaryLinks,
}),
);
};
const handleDeleteLink = (index: number) => {
onSubmit?.(() =>
persistLinksField({
...fieldValue,
secondaryLinks: [
...(fieldValue.secondaryLinks ?? []),
{ label: '', url: inputValue },
],
secondaryLinks: toSpliced(
fieldValue.secondaryLinks ?? [],
index - 1,
1,
),
}),
);
};
@ -108,9 +105,13 @@ export const LinksFieldInput = ({
<>
<DropdownMenuItemsContainer>
{links.map(({ label, url }, index) => (
<MenuItem
<LinksFieldMenuItem
key={index}
text={<LinkDisplay value={{ label, url }} />}
dropdownId={`${hotkeyScope}-links-${index}`}
isPrimary={index === 0}
label={label}
onDelete={() => handleDeleteLink(index)}
url={url}
/>
))}
</DropdownMenuItemsContainer>
@ -124,9 +125,9 @@ export const LinksFieldInput = ({
value={inputValue}
hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmit}
onEnter={handleAddLink}
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 { IconComponent } from 'twenty-ui';
@ -14,7 +14,9 @@ export type LightIconButtonGroupProps = Pick<
'className' | 'size'
> & {
iconButtons: {
Wrapper?: FunctionComponent<{ iconButton: ReactElement }>;
Icon: IconComponent;
accent?: LightIconButtonProps['accent'];
onClick?: (event: MouseEvent<any>) => void;
disabled?: boolean;
}[];
@ -26,16 +28,26 @@ export const LightIconButtonGroup = ({
className,
}: LightIconButtonGroupProps) => (
<StyledLightIconButtonGroupContainer className={className}>
{iconButtons.map(({ Icon, onClick }, index) => {
return (
{iconButtons.map(({ Wrapper, Icon, accent, onClick }, index) => {
const iconButton = (
<LightIconButton
key={`light-icon-button-${index}`}
Icon={Icon}
accent={accent}
disabled={!onClick}
onClick={onClick}
size={size}
/>
);
return Wrapper ? (
<Wrapper
key={`light-icon-button-wrapper-${index}`}
iconButton={iconButton}
/>
) : (
iconButton
);
})}
</StyledLightIconButtonGroupContainer>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export type MenuItemBaseProps = {
isKeySelected?: boolean;
};
export const StyledMenuItemBase = styled.li<MenuItemBaseProps>`
export const StyledMenuItemBase = styled.div<MenuItemBaseProps>`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)};
@ -101,23 +101,26 @@ export const StyledMenuItemRightContent = styled.div`
`;
export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{
isMenuOpen: 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 {
pointer-events: none;
position: fixed;
right: ${({ theme }) => theme.spacing(2)};
opacity: ${({ isIconDisplayedOnHoverOnly }) =>
isIconDisplayedOnHoverOnly ? 0 : 1};
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,
IconBolt,
IconBook2,
IconBookmark,
IconBox,
IconBrandGithub,
IconBrandGoogle,