feat: add Relation field form (#2572)

* feat: add useCreateOneRelationMetadata and useRelationMetadata

Closes #2423

* feat: add Relation field form

Closes #2003

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-11-17 23:38:39 +01:00
committed by GitHub
parent fea0bbeb2a
commit 18dac1a2b6
34 changed files with 1285 additions and 643 deletions

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import { MouseEvent, ReactNode } from 'react';
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '../../tooltip/OverflowingTextWithTooltip';
@ -28,9 +28,10 @@ type ChipProps = {
maxWidth?: string;
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: React.ReactNode;
rightComponent?: React.ReactNode;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
className?: string;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const StyledContainer = styled.div<Partial<ChipProps>>`
@ -125,6 +126,7 @@ export const Chip = ({
accent = ChipAccent.TextPrimary,
maxWidth,
className,
onClick,
}: ChipProps) => (
<StyledContainer
data-testid="chip"
@ -135,6 +137,7 @@ export const Chip = ({
disabled={disabled}
className={className}
maxWidth={maxWidth}
onClick={onClick}
>
{leftComponent}
<StyledLabel>

View File

@ -45,31 +45,31 @@ export const EntityChip = ({
};
return isNonEmptyString(name) ? (
<div onClick={handleLinkClick}>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
/>
</div>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
clickable={!!linkToEntity}
onClick={handleLinkClick}
/>
) : (
<></>
);

View File

@ -69,6 +69,9 @@ export {
IconPlug,
IconPlus,
IconProgressCheck,
IconRelationManyToMany,
IconRelationOneToMany,
IconRelationOneToOne,
IconRepeat,
IconRobot,
IconSearch,

View File

@ -19,6 +19,7 @@ import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
type IconPickerProps = {
disabled?: boolean;
dropdownScopeId?: string;
onChange: (params: { iconKey: string; Icon: IconComponent }) => void;
selectedIconKey?: string;
onClickOutside?: () => void;
@ -44,6 +45,7 @@ const convertIconKeyToLabel = (iconKey: string) =>
export const IconPicker = ({
disabled,
dropdownScopeId = 'icon-picker',
onChange,
selectedIconKey,
onClickOutside,
@ -53,7 +55,7 @@ export const IconPicker = ({
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
const { closeDropdown } = useDropdown({ dropdownScopeId: 'icon-picker' });
const { closeDropdown } = useDropdown({ dropdownScopeId });
const { icons, isLoadingIcons: isLoading } = useLazyLoadIcons();
@ -75,7 +77,7 @@ export const IconPicker = ({
}, [icons, searchString, selectedIconKey]);
return (
<DropdownScope dropdownScopeId="icon-picker">
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
clickableComponent={

View File

@ -12,14 +12,16 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string | number | null> = {
className?: string;
disabled?: boolean;
dropdownScopeId: string;
label?: string;
onChange?: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
value?: Value;
};
const StyledContainer = styled.div<{ disabled?: boolean }>`
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -34,7 +36,16 @@ const StyledContainer = styled.div<{ disabled?: boolean }>`
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.div`
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
@ -46,8 +57,10 @@ const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
`;
export const Select = <Value extends string | number | null>({
className,
disabled,
dropdownScopeId,
label,
onChange,
options,
value,
@ -59,46 +72,49 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown({ dropdownScopeId });
const selectControl = (
<StyledContainer disabled={disabled}>
<StyledLabel>
{!!selectedOption.Icon && (
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={disabled ? theme.font.color.light : theme.font.color.primary}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption.label}
</StyledLabel>
{selectedOption?.label}
</StyledControlLabel>
<StyledIconChevronDown disabled={disabled} size={theme.icon.size.md} />
</StyledContainer>
</StyledControlContainer>
);
return disabled ? (
selectControl
) : (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
<div className={className}>
{!!label && <StyledLabel>{label}</StyledLabel>}
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
</DropdownScope>
);
};

View File

@ -5,8 +5,6 @@ const StyledPanel = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: row;
height: 100%;
overflow: auto;
width: 100%;

View File

@ -7,9 +7,10 @@ export const getEntityChipFromFieldMetadata = (
fieldDefinition: FieldDefinition<FieldRelationMetadata>,
fieldValue: any,
) => {
const { entityChipDisplayMapper } = fieldDefinition;
const { fieldName } = fieldDefinition.metadata;
const chipValue: Pick<
const defaultChipValue: Pick<
EntityChipProps,
'name' | 'pictureUrl' | 'avatarType' | 'entityId'
> = {
@ -19,15 +20,23 @@ export const getEntityChipFromFieldMetadata = (
entityId: fieldValue?.id,
};
// TODO: use every
if (fieldName === 'accountOwner' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
} else if (fieldName === 'company' && fieldValue) {
chipValue.name = fieldValue.name;
chipValue.pictureUrl = getLogoUrlFromDomainName(fieldValue.domainName);
} else if (fieldName === 'person' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
if (['accountOwner', 'person'].includes(fieldName) && fieldValue) {
return {
...defaultChipValue,
name: `${fieldValue.firstName} ${fieldValue.lastName}`,
};
}
return chipValue;
if (fieldName === 'company' && fieldValue) {
return {
...defaultChipValue,
name: fieldValue.name,
pictureUrl: getLogoUrlFromDomainName(fieldValue.domainName),
};
}
return {
...defaultChipValue,
...entityChipDisplayMapper?.(fieldValue),
};
};

View File

@ -5,9 +5,10 @@ import { FieldMetadata } from './FieldMetadata';
import { FieldType } from './FieldType';
export type FieldDefinitionRelationType =
| 'TO_ONE_OBJECT'
| 'FROM_NAMY_OBJECTS'
| 'TO_MANY_OBJECTS';
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type FieldDefinition<T extends FieldMetadata> = {
fieldMetadataId: string;

View File

@ -1,8 +1,20 @@
import { isNull, isString } from '@sniptt/guards';
import { formatToHumanReadableDate } from '~/utils';
import { FieldDateValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldDateValue = (
fieldValue: unknown,
): fieldValue is FieldDateValue => isNull(fieldValue) || isString(fieldValue);
): fieldValue is FieldDateValue => {
try {
if (isNull(fieldValue)) return true;
if (isString(fieldValue)) {
formatToHumanReadableDate(fieldValue);
return true;
}
} catch {}
return false;
};