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:
@ -134,13 +134,7 @@ export const FieldInput = ({
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : isFieldLinks(fieldDefinition) ? (
|
||||
<LinksFieldInput
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
onClickOutside={onClickOutside}
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
<LinksFieldInput onCancel={onCancel} onSubmit={onSubmit} />
|
||||
) : isFieldCurrency(fieldDefinition) ? (
|
||||
<CurrencyFieldInput
|
||||
onEnter={onEnter}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
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 { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
@ -19,17 +20,12 @@ export const useGetButtonIcon = (): IconComponent | undefined => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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: '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<FieldInputOverlay>
|
||||
<TextInput
|
||||
value={draftValue?.primaryLinkUrl ?? ''}
|
||||
autoFocus
|
||||
placeholder="Links"
|
||||
hotkeyScope={hotkeyScope}
|
||||
onClickOutside={handleClickOutside}
|
||||
onEnter={handleEnter}
|
||||
onEscape={handleEscape}
|
||||
onTab={handleTab}
|
||||
onShiftTab={handleShiftTab}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</FieldInputOverlay>
|
||||
<StyledDropdownMenu ref={containerRef} width={200}>
|
||||
{!!links.length && (
|
||||
<>
|
||||
<DropdownMenuItemsContainer>
|
||||
{links.map(({ label, url }, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
text={<LinkDisplay value={{ label, url }} />}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user