diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index f814ab66c..f7a9b6ff2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -134,13 +134,7 @@ export const FieldInput = ({ onShiftTab={onShiftTab} /> ) : isFieldLinks(fieldDefinition) ? ( - + ) : isFieldCurrency(fieldDefinition) ? ( { isFieldLink(fieldDefinition) || isFieldEmail(fieldDefinition) || isFieldPhone(fieldDefinition) || - isFieldMultiSelect(fieldDefinition) + isFieldMultiSelect(fieldDefinition) || + (isFieldRelation(fieldDefinition) && + fieldDefinition.metadata.relationObjectMetadataNameSingular !== + 'workspaceMember') || + isFieldLinks(fieldDefinition) ) { return IconPencil; } - - if (isFieldRelation(fieldDefinition)) { - if ( - fieldDefinition.metadata.relationObjectMetadataNameSingular !== - 'workspaceMember' - ) { - return IconPencil; - } - } }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx index 5bf5571f1..2c64fb936 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx @@ -27,7 +27,7 @@ export const LinkFieldInput = ({ onEnter?.(() => persistLinkField({ url: newURL, - label: newURL, + label: '', }), ); }; @@ -36,7 +36,7 @@ export const LinkFieldInput = ({ onEscape?.(() => persistLinkField({ url: newURL, - label: newURL, + label: '', }), ); }; @@ -48,7 +48,7 @@ export const LinkFieldInput = ({ onClickOutside?.(() => persistLinkField({ url: newURL, - label: newURL, + label: '', }), ); }; @@ -57,7 +57,7 @@ export const LinkFieldInput = ({ onTab?.(() => persistLinkField({ url: newURL, - label: newURL, + label: '', }), ); }; @@ -66,7 +66,7 @@ export const LinkFieldInput = ({ onShiftTab?.(() => persistLinkField({ url: newURL, - label: newURL, + label: '', }), ); }; @@ -74,7 +74,7 @@ export const LinkFieldInput = ({ const handleChange = (newURL: string) => { setDraftValue({ url: newURL, - label: newURL, + label: '', }); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx index 26dd57f20..46bfe68dc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx @@ -1,99 +1,143 @@ -import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; -import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay'; -import { TextInput } from '@/ui/field/input/components/TextInput'; +import { useMemo, useRef, useState } from 'react'; +import styled from '@emotion/styled'; +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 = { - onClickOutside?: FieldInputEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; + onCancel?: () => void; + onSubmit?: FieldInputEvent; }; export const LinksFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, + onCancel, + onSubmit, }: LinksFieldInputProps) => { - const { draftValue, setDraftValue, hotkeyScope, persistLinksField } = - useLinksField(); + const { persistLinksField, hotkeyScope, fieldValue } = useLinksField(); - const handleEnter = (url: string) => { - onEnter?.(() => + const containerRef = useRef(null); + + 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({ - primaryLinkUrl: url, - primaryLinkLabel: '', - secondaryLinks: [], + ...fieldValue, + 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 ( - - - + + {!!links.length && ( + <> + + {links.map(({ label, url }, index) => ( + } + /> + ))} + + + + )} + {isInputDisplayed ? ( + setInputValue(event.target.value)} + onEnter={handleSubmit} + rightComponent={ + + } + /> + ) : ( + + setIsInputDisplayed(true)} + LeftIcon={IconPlus} + text="Add link" + /> + + )} + ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx index d360b62e1..0131cb054 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinkDisplay.tsx @@ -8,15 +8,14 @@ import { SocialLink, } from '@/ui/navigation/link/components/SocialLink'; import { checkUrlType } from '~/utils/checkUrlType'; +import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; +import { getUrlHostName } from '~/utils/url/getUrlHostName'; import { EllipsisDisplay } from './EllipsisDisplay'; const StyledRawLink = styled(RoundedLink)` - overflow: hidden; - a { - overflow: hidden; - text-overflow: ellipsis; + font-size: ${({ theme }) => theme.font.size.md}; white-space: nowrap; } `; @@ -30,14 +29,8 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => { event.stopPropagation(); }; - const absoluteUrl = value?.url - ? value.url.startsWith('http') - ? value.url - : 'https://' + value.url - : ''; - - const displayedValue = value?.label || value?.url || ''; - + const absoluteUrl = getAbsoluteUrl(value?.url || ''); + const displayedValue = value?.label || getUrlHostName(absoluteUrl); const type = checkUrlType(absoluteUrl); if (type === LinkType.LinkedIn || type === LinkType.Twitter) { diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx index 1c6b1d1a2..c4fadbe05 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx @@ -1,14 +1,66 @@ +import { MouseEventHandler, useMemo } from 'react'; +import styled from '@emotion/styled'; + 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'; +const StyledContainer = styled(EllipsisDisplay)` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + type LinksDisplayProps = { value?: FieldLinksValue; }; export const LinksDisplay = ({ value }: LinksDisplayProps) => { - const url = value?.primaryLinkUrl || ''; - const label = value?.primaryLinkLabel || getUrlHostName(url); + const links = useMemo( + () => + [ + 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 ; + const handleClick: MouseEventHandler = (event) => event.stopPropagation(); + + return ( + + {links.map(({ url, label, type }, index) => + type === LinkType.LinkedIn || type === LinkType.Twitter ? ( + + {label} + + ) : ( + + {label} + + ), + )} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx index 424bcb1b3..13c6a9048 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx @@ -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 { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { RGBA } from '@/ui/theme/constants/Rgba'; 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} border: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -19,27 +22,87 @@ const StyledInput = styled.input` border-color: ${({ theme }) => theme.color.blue}; box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)}; } + + ${({ withRightComponent }) => + withRightComponent && + css` + padding-right: 32px; + `} `; const StyledInputContainer = styled.div` box-sizing: border-box; padding: ${({ theme }) => theme.spacing(1)}; + position: relative; width: 100%; `; +const StyledRightContainer = styled.div` + position: absolute; + right: ${({ theme }) => theme.spacing(2)}; + top: 50%; + transform: translateY(-50%); +`; + +type DropdownMenuInputProps = InputHTMLAttributes & { + hotkeyScope?: string; + onClickOutside?: () => void; + onEnter?: () => void; + onEscape?: () => void; + onShiftTab?: () => void; + onTab?: () => void; + rightComponent?: ReactNode; +}; + export const DropdownMenuInput = forwardRef< HTMLInputElement, - InputHTMLAttributes ->(({ autoFocus, value, placeholder, onChange }, ref) => { - return ( - - - - ); -}); + DropdownMenuInputProps +>( + ( + { + autoFocus, + className, + value, + placeholder, + hotkeyScope = 'dropdown-menu-input', + onChange, + onClickOutside, + onEnter = () => {}, + onEscape = () => {}, + onShiftTab, + onTab, + rightComponent, + }, + ref, + ) => { + const inputRef = useRef(null); + const combinedRef = useCombinedRefs(ref, inputRef); + + useRegisterInputEvents({ + inputRef, + inputValue: value, + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, + hotkeyScope, + }); + + return ( + + + {!!rightComponent && ( + {rightComponent} + )} + + ); + }, +); diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx index 32a2258c7..aced9c080 100644 --- a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx @@ -6,6 +6,7 @@ import { Chip, ChipSize, ChipVariant } from 'twenty-ui'; type RoundedLinkProps = { href: string; children?: React.ReactNode; + className?: string; onClick?: (event: React.MouseEvent) => void; }; @@ -22,14 +23,20 @@ const StyledClickable = styled.div` `; const StyledChip = styled(Chip)` + border-color: ${({ theme }) => theme.border.color.strong}; box-sizing: border-box; padding: ${({ theme }) => theme.spacing(2)}; `; -export const RoundedLink = ({ children, href, onClick }: RoundedLinkProps) => ( +export const RoundedLink = ({ + children, + className, + href, + onClick, +}: RoundedLinkProps) => (
{children !== '' ? ( - + )} - + {isString(text) ? : text} ); diff --git a/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts b/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts new file mode 100644 index 000000000..39be8641c --- /dev/null +++ b/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts @@ -0,0 +1,9 @@ +import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; + +export const getAbsoluteUrl = (url: string) => { + try { + return absoluteUrlSchema.parse(url); + } catch { + return ''; + } +}; diff --git a/packages/twenty-front/src/utils/url/getUrlHostName.ts b/packages/twenty-front/src/utils/url/getUrlHostName.ts index 41e8b8096..39de988ff 100644 --- a/packages/twenty-front/src/utils/url/getUrlHostName.ts +++ b/packages/twenty-front/src/utils/url/getUrlHostName.ts @@ -1,8 +1,8 @@ -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; +import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; export const getUrlHostName = (url: string) => { try { - const absoluteUrl = absoluteUrlSchema.parse(url); + const absoluteUrl = getAbsoluteUrl(url); return new URL(absoluteUrl).hostname.replace(/^www\./i, ''); } catch { return '';