feat: add links to Links field (#5223)

Closes #5115, Closes #5116

<img width="242" alt="image"
src="https://github.com/twentyhq/twenty/assets/3098428/ab78495a-4216-4243-8de3-53720818a09b">

---------

Co-authored-by: Jérémy Magrin <jeremy.magrin@gmail.com>
This commit is contained in:
Thaïs
2024-05-07 15:05:18 +02:00
committed by GitHub
parent 8074aae449
commit b0d1cc9dcb
12 changed files with 306 additions and 146 deletions

View File

@ -134,13 +134,7 @@ export const FieldInput = ({
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldLinks(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput <LinksFieldInput onCancel={onCancel} onSubmit={onSubmit} />
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput <CurrencyFieldInput
onEnter={onEnter} onEnter={onEnter}

View File

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { IconComponent, IconPencil } from 'twenty-ui'; import { IconComponent, IconPencil } from 'twenty-ui';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -19,17 +20,12 @@ export const useGetButtonIcon = (): IconComponent | undefined => {
isFieldLink(fieldDefinition) || isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) || isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition) || isFieldPhone(fieldDefinition) ||
isFieldMultiSelect(fieldDefinition) isFieldMultiSelect(fieldDefinition) ||
(isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition)
) { ) {
return IconPencil; return IconPencil;
} }
if (isFieldRelation(fieldDefinition)) {
if (
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember'
) {
return IconPencil;
}
}
}; };

View File

@ -27,7 +27,7 @@ export const LinkFieldInput = ({
onEnter?.(() => onEnter?.(() =>
persistLinkField({ persistLinkField({
url: newURL, url: newURL,
label: newURL, label: '',
}), }),
); );
}; };
@ -36,7 +36,7 @@ export const LinkFieldInput = ({
onEscape?.(() => onEscape?.(() =>
persistLinkField({ persistLinkField({
url: newURL, url: newURL,
label: newURL, label: '',
}), }),
); );
}; };
@ -48,7 +48,7 @@ export const LinkFieldInput = ({
onClickOutside?.(() => onClickOutside?.(() =>
persistLinkField({ persistLinkField({
url: newURL, url: newURL,
label: newURL, label: '',
}), }),
); );
}; };
@ -57,7 +57,7 @@ export const LinkFieldInput = ({
onTab?.(() => onTab?.(() =>
persistLinkField({ persistLinkField({
url: newURL, url: newURL,
label: newURL, label: '',
}), }),
); );
}; };
@ -66,7 +66,7 @@ export const LinkFieldInput = ({
onShiftTab?.(() => onShiftTab?.(() =>
persistLinkField({ persistLinkField({
url: newURL, url: newURL,
label: newURL, label: '',
}), }),
); );
}; };
@ -74,7 +74,7 @@ export const LinkFieldInput = ({
const handleChange = (newURL: string) => { const handleChange = (newURL: string) => {
setDraftValue({ setDraftValue({
url: newURL, url: newURL,
label: newURL, label: '',
}); });
}; };

View File

@ -1,99 +1,143 @@
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { useMemo, useRef, useState } from 'react';
import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay'; import styled from '@emotion/styled';
import { TextInput } from '@/ui/field/input/components/TextInput'; import { Key } from 'ts-key-enum';
import { IconPlus } from 'twenty-ui';
import { FieldInputEvent } from './DateTimeFieldInput'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
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';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
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 { isDefined } from '~/utils/isDefined';
const StyledDropdownMenu = styled(DropdownMenu)`
left: -1px;
position: absolute;
top: -1px;
`;
export type LinksFieldInputProps = { export type LinksFieldInputProps = {
onClickOutside?: FieldInputEvent; onCancel?: () => void;
onEnter?: FieldInputEvent; onSubmit?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
}; };
export const LinksFieldInput = ({ export const LinksFieldInput = ({
onEnter, onCancel,
onEscape, onSubmit,
onClickOutside,
onTab,
onShiftTab,
}: LinksFieldInputProps) => { }: LinksFieldInputProps) => {
const { draftValue, setDraftValue, hotkeyScope, persistLinksField } = const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
useLinksField();
const handleEnter = (url: string) => { const containerRef = useRef<HTMLDivElement>(null);
onEnter?.(() =>
const links = useMemo(
() =>
[
fieldValue.primaryLinkUrl
? {
url: fieldValue.primaryLinkUrl,
label: fieldValue.primaryLinkLabel,
}
: null,
...(fieldValue.secondaryLinks ?? []),
].filter(isDefined),
[
fieldValue.primaryLinkLabel,
fieldValue.primaryLinkUrl,
fieldValue.secondaryLinks,
],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const isTargetInput =
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT';
if (!isTargetInput) {
onCancel?.();
}
},
});
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
useScopedHotkeys(Key.Escape, onCancel ?? (() => {}), hotkeyScope);
const handleSubmit = () => {
if (!inputValue) return;
setIsInputDisplayed(false);
setInputValue('');
if (!links.length) {
onSubmit?.(() =>
persistLinksField({
primaryLinkUrl: inputValue,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
return;
}
onSubmit?.(() =>
persistLinksField({ persistLinksField({
primaryLinkUrl: url, ...fieldValue,
primaryLinkLabel: '', secondaryLinks: [
secondaryLinks: [], ...(fieldValue.secondaryLinks ?? []),
{ label: '', url: inputValue },
],
}), }),
); );
}; };
const handleEscape = (url: string) => {
onEscape?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
};
const handleClickOutside = (event: MouseEvent | TouchEvent, url: string) => {
onClickOutside?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
};
const handleTab = (url: string) => {
onTab?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
};
const handleShiftTab = (url: string) => {
onShiftTab?.(() =>
persistLinksField({
primaryLinkUrl: url,
primaryLinkLabel: '',
secondaryLinks: [],
}),
);
};
const handleChange = (url: string) => {
setDraftValue({
primaryLinkUrl: url,
primaryLinkLabel: '',
secondaryLinks: [],
});
};
return ( return (
<FieldInputOverlay> <StyledDropdownMenu ref={containerRef} width={200}>
<TextInput {!!links.length && (
value={draftValue?.primaryLinkUrl ?? ''} <>
autoFocus <DropdownMenuItemsContainer>
placeholder="Links" {links.map(({ label, url }, index) => (
hotkeyScope={hotkeyScope} <MenuItem
onClickOutside={handleClickOutside} key={index}
onEnter={handleEnter} text={<LinkDisplay value={{ label, url }} />}
onEscape={handleEscape} />
onTab={handleTab} ))}
onShiftTab={handleShiftTab} </DropdownMenuItemsContainer>
onChange={handleChange} <DropdownMenuSeparator />
/> </>
</FieldInputOverlay> )}
{isInputDisplayed ? (
<DropdownMenuInput
autoFocus
placeholder="URL"
value={inputValue}
hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmit}
rightComponent={
<LightIconButton Icon={IconPlus} onClick={handleSubmit} />
}
/>
) : (
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => setIsInputDisplayed(true)}
LeftIcon={IconPlus}
text="Add link"
/>
</DropdownMenuItemsContainer>
)}
</StyledDropdownMenu>
); );
}; };

View File

@ -8,15 +8,14 @@ import {
SocialLink, SocialLink,
} from '@/ui/navigation/link/components/SocialLink'; } from '@/ui/navigation/link/components/SocialLink';
import { checkUrlType } from '~/utils/checkUrlType'; import { checkUrlType } from '~/utils/checkUrlType';
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
import { getUrlHostName } from '~/utils/url/getUrlHostName';
import { EllipsisDisplay } from './EllipsisDisplay'; import { EllipsisDisplay } from './EllipsisDisplay';
const StyledRawLink = styled(RoundedLink)` const StyledRawLink = styled(RoundedLink)`
overflow: hidden;
a { a {
overflow: hidden; font-size: ${({ theme }) => theme.font.size.md};
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
`; `;
@ -30,14 +29,8 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => {
event.stopPropagation(); event.stopPropagation();
}; };
const absoluteUrl = value?.url const absoluteUrl = getAbsoluteUrl(value?.url || '');
? value.url.startsWith('http') const displayedValue = value?.label || getUrlHostName(absoluteUrl);
? value.url
: 'https://' + value.url
: '';
const displayedValue = value?.label || value?.url || '';
const type = checkUrlType(absoluteUrl); const type = checkUrlType(absoluteUrl);
if (type === LinkType.LinkedIn || type === LinkType.Twitter) { if (type === LinkType.LinkedIn || type === LinkType.Twitter) {

View File

@ -1,14 +1,66 @@
import { MouseEventHandler, useMemo } from 'react';
import styled from '@emotion/styled';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay'; import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
SocialLink,
} from '@/ui/navigation/link/components/SocialLink';
import { checkUrlType } from '~/utils/checkUrlType';
import { isDefined } from '~/utils/isDefined';
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
import { getUrlHostName } from '~/utils/url/getUrlHostName'; import { getUrlHostName } from '~/utils/url/getUrlHostName';
const StyledContainer = styled(EllipsisDisplay)`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
type LinksDisplayProps = { type LinksDisplayProps = {
value?: FieldLinksValue; value?: FieldLinksValue;
}; };
export const LinksDisplay = ({ value }: LinksDisplayProps) => { export const LinksDisplay = ({ value }: LinksDisplayProps) => {
const url = value?.primaryLinkUrl || ''; const links = useMemo(
const label = value?.primaryLinkLabel || getUrlHostName(url); () =>
[
value?.primaryLinkUrl
? {
url: value.primaryLinkUrl,
label: value.primaryLinkLabel,
}
: null,
...(value?.secondaryLinks ?? []),
]
.filter(isDefined)
.map(({ url, label }) => {
const absoluteUrl = getAbsoluteUrl(url);
return {
url: absoluteUrl,
label: label || getUrlHostName(absoluteUrl),
type: checkUrlType(absoluteUrl),
};
}),
[value?.primaryLinkLabel, value?.primaryLinkUrl, value?.secondaryLinks],
);
return <LinkDisplay value={{ url, label }} />; const handleClick: MouseEventHandler = (event) => event.stopPropagation();
return (
<StyledContainer>
{links.map(({ url, label, type }, index) =>
type === LinkType.LinkedIn || type === LinkType.Twitter ? (
<SocialLink key={index} href={url} onClick={handleClick} type={type}>
{label}
</SocialLink>
) : (
<RoundedLink key={index} href={url} onClick={handleClick}>
{label}
</RoundedLink>
),
)}
</StyledContainer>
);
}; };

View File

@ -1,10 +1,13 @@
import { forwardRef, InputHTMLAttributes } from 'react'; import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { RGBA } from '@/ui/theme/constants/Rgba'; import { RGBA } from '@/ui/theme/constants/Rgba';
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle'; import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
const StyledInput = styled.input` const StyledInput = styled.input<{ withRightComponent?: boolean }>`
${TEXT_INPUT_STYLE} ${TEXT_INPUT_STYLE}
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -19,27 +22,87 @@ const StyledInput = styled.input`
border-color: ${({ theme }) => theme.color.blue}; border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)}; box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)};
} }
${({ withRightComponent }) =>
withRightComponent &&
css`
padding-right: 32px;
`}
`; `;
const StyledInputContainer = styled.div` const StyledInputContainer = styled.div`
box-sizing: border-box; box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(1)}; padding: ${({ theme }) => theme.spacing(1)};
position: relative;
width: 100%; width: 100%;
`; `;
const StyledRightContainer = styled.div`
position: absolute;
right: ${({ theme }) => theme.spacing(2)};
top: 50%;
transform: translateY(-50%);
`;
type DropdownMenuInputProps = InputHTMLAttributes<HTMLInputElement> & {
hotkeyScope?: string;
onClickOutside?: () => void;
onEnter?: () => void;
onEscape?: () => void;
onShiftTab?: () => void;
onTab?: () => void;
rightComponent?: ReactNode;
};
export const DropdownMenuInput = forwardRef< export const DropdownMenuInput = forwardRef<
HTMLInputElement, HTMLInputElement,
InputHTMLAttributes<HTMLInputElement> DropdownMenuInputProps
>(({ autoFocus, value, placeholder, onChange }, ref) => { >(
return ( (
<StyledInputContainer> {
<StyledInput autoFocus,
autoFocus={autoFocus} className,
value={value} value,
placeholder={placeholder} placeholder,
onChange={onChange} hotkeyScope = 'dropdown-menu-input',
ref={ref} onChange,
/> onClickOutside,
</StyledInputContainer> onEnter = () => {},
); onEscape = () => {},
}); onShiftTab,
onTab,
rightComponent,
},
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
useRegisterInputEvents({
inputRef,
inputValue: value,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledInputContainer className={className}>
<StyledInput
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
onChange={onChange}
ref={combinedRef}
withRightComponent={!!rightComponent}
/>
{!!rightComponent && (
<StyledRightContainer>{rightComponent}</StyledRightContainer>
)}
</StyledInputContainer>
);
},
);

View File

@ -6,6 +6,7 @@ import { Chip, ChipSize, ChipVariant } from 'twenty-ui';
type RoundedLinkProps = { type RoundedLinkProps = {
href: string; href: string;
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
}; };
@ -22,14 +23,20 @@ const StyledClickable = styled.div`
`; `;
const StyledChip = styled(Chip)` const StyledChip = styled(Chip)`
border-color: ${({ theme }) => theme.border.color.strong};
box-sizing: border-box; box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
`; `;
export const RoundedLink = ({ children, href, onClick }: RoundedLinkProps) => ( export const RoundedLink = ({
children,
className,
href,
onClick,
}: RoundedLinkProps) => (
<div> <div>
{children !== '' ? ( {children !== '' ? (
<StyledClickable> <StyledClickable className={className}>
<ReactLink target="_blank" to={href} onClick={onClick}> <ReactLink target="_blank" to={href} onClick={onClick}>
<StyledChip <StyledChip
label={`${children}`} label={`${children}`}

View File

@ -1,4 +1,4 @@
import { MouseEvent } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
import { LightIconButtonGroup } from '@/ui/input/button/components/LightIconButtonGroup'; import { LightIconButtonGroup } from '@/ui/input/button/components/LightIconButtonGroup';
@ -18,7 +18,7 @@ export type MenuItemIconButton = {
export type MenuItemProps = { export type MenuItemProps = {
LeftIcon?: IconComponent | null; LeftIcon?: IconComponent | null;
accent?: MenuItemAccent; accent?: MenuItemAccent;
text: string; text: ReactNode;
iconButtons?: MenuItemIconButton[]; iconButtons?: MenuItemIconButton[];
isIconDisplayedOnHoverOnly?: boolean; isIconDisplayedOnHoverOnly?: boolean;
isTooltipOpen?: boolean; isTooltipOpen?: boolean;

View File

@ -1,4 +1,6 @@
import { ReactNode } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { isString } from '@sniptt/guards';
import { import {
IconComponent, IconComponent,
IconGripVertical, IconGripVertical,
@ -14,7 +16,7 @@ type MenuItemLeftContentProps = {
className?: string; className?: string;
LeftIcon: IconComponent | null | undefined; LeftIcon: IconComponent | null | undefined;
showGrip?: boolean; showGrip?: boolean;
text: string; text: ReactNode;
}; };
export const MenuItemLeftContent = ({ export const MenuItemLeftContent = ({
@ -38,7 +40,7 @@ export const MenuItemLeftContent = ({
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} /> <LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)} )}
<StyledMenuItemLabel hasLeftIcon={!!LeftIcon}> <StyledMenuItemLabel hasLeftIcon={!!LeftIcon}>
<OverflowingTextWithTooltip text={text} /> {isString(text) ? <OverflowingTextWithTooltip text={text} /> : text}
</StyledMenuItemLabel> </StyledMenuItemLabel>
</StyledMenuItemLeftContent> </StyledMenuItemLeftContent>
); );

View File

@ -0,0 +1,9 @@
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
export const getAbsoluteUrl = (url: string) => {
try {
return absoluteUrlSchema.parse(url);
} catch {
return '';
}
};

View File

@ -1,8 +1,8 @@
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
export const getUrlHostName = (url: string) => { export const getUrlHostName = (url: string) => {
try { try {
const absoluteUrl = absoluteUrlSchema.parse(url); const absoluteUrl = getAbsoluteUrl(url);
return new URL(absoluteUrl).hostname.replace(/^www\./i, ''); return new URL(absoluteUrl).hostname.replace(/^www\./i, '');
} catch { } catch {
return ''; return '';