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:
@ -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} />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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 : () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -460,7 +460,7 @@ export const InternalDatePicker = ({
|
||||
/>
|
||||
</div>
|
||||
{clearable && (
|
||||
<StyledButtonContainer onClick={handleClear} isMenuOpen={false}>
|
||||
<StyledButtonContainer onClick={handleClear}>
|
||||
<StyledButton LeftIcon={IconCalendarX} text="Clear" />
|
||||
</StyledButtonContainer>
|
||||
)}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -52,7 +52,7 @@ export const useDropdown = (dropdownId?: string) => {
|
||||
|
||||
return {
|
||||
scopeId,
|
||||
isDropdownOpen: isDropdownOpen,
|
||||
isDropdownOpen,
|
||||
closeDropdown,
|
||||
toggleDropdown,
|
||||
openDropdown,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -19,6 +19,7 @@ export {
|
||||
IconBell,
|
||||
IconBolt,
|
||||
IconBook2,
|
||||
IconBookmark,
|
||||
IconBox,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
|
||||
Reference in New Issue
Block a user