Implemented comment thread target picker with new dropdown components (#295)

* First draft of new relation picker and usage in comments
---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-06-14 18:48:26 +02:00
committed by GitHub
parent 2a1804c153
commit fdfb6f10e2
22 changed files with 421 additions and 47 deletions

View File

@ -80,7 +80,7 @@ export function CommentHeader({ comment }: OwnProps) {
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter={capitalizedFirstUsernameLetter}
placeholder={capitalizedFirstUsernameLetter}
/>
<StyledName>{authorName}</StyledName>
{showDate && (

View File

@ -11,6 +11,7 @@ import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { useCreateCommentMutation } from '~/generated/graphql';
import { CommentThreadItem } from './CommentThreadItem';
import { CommentThreadRelationPicker } from './CommentThreadRelationPicker';
type OwnProps = {
commentThread: CommentThreadForDrawer;
@ -88,6 +89,7 @@ export function CommentThread({ commentThread }: OwnProps) {
<CommentThreadItem key={comment.id} comment={comment} />
))}
</StyledThreadItemListContainer>
<CommentThreadRelationPicker commentThread={commentThread} />
<AutosizeTextInput onValidate={handleSendComment} />
</StyledContainer>
);

View File

@ -0,0 +1,206 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react';
import { debounce } from 'lodash';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import CompanyChip from '@/companies/components/CompanyChip';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/components/menu/DropdownMenuCheckableItem';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/components/menu/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/components/menu/DropdownMenuSeparator';
import { IconArrowUpRight } from '@/ui/icons';
import { Avatar } from '@/users/components/Avatar';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import { QueryMode, useSearchCompanyQueryQuery } from '~/generated/graphql';
type OwnProps = {
commentThread: CommentThreadForDrawer;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.spacing(2)};
justify-content: flex-start;
width: 100%;
`;
const StyledRelationLabel = styled.div`
color: ${(props) => props.theme.text60};
display: flex;
flex-direction: row;
`;
const StyledRelationContainer = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(1)};
--vertical-padding: ${(props) => props.theme.spacing(1.5)};
border: 1px solid transparent;
cursor: pointer;
display: flex;
gap: ${(props) => props.theme.spacing(2)};
height: calc(32px - 2 * var(--vertical-padding));
&:hover {
background-color: ${(props) => props.theme.secondaryBackground};
border: 1px solid ${(props) => props.theme.lightBorder};
}
overflow: hidden;
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
`;
// TODO: refactor icon button with new figma and merge
// const StyledAddButton = styled.div`
// align-items: center;
// background: ${(props) => props.theme.primaryBackgroundTransparent};
// border-radius: ${(props) => props.theme.borderRadius};
// box-shadow: ${(props) => props.theme.modalBoxShadow};
// cursor: pointer;
// display: flex;
// flex-direction: row;
// &:hover {
// background-color: ${(props) => props.theme.tertiaryBackground};
// }
// height: 20px;
// justify-content: center;
// width: 20px;
// `;
export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
const { refs, floatingStyles } = useFloating({
strategy: 'fixed',
middleware: [offset(), flip(), shift(), size()],
whileElementsMounted: autoUpdate,
open: isMenuOpen,
});
const theme = useTheme();
const companyIds = commentThread.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId);
// const personIds = commentThread.commentThreadTargets
// ?.filter((relation) => relation.commentableType === 'Person')
// .map((relation) => relation.commentableId);
const { data: dataForChips } = useSearchCompanyQueryQuery({
variables: {
where: {
id: {
in: companyIds,
},
},
},
});
const { data: dataForSelect } = useSearchCompanyQueryQuery({
variables: {
where: {
name: {
contains: `%${searchFilter}%`,
mode: QueryMode.Insensitive,
},
},
limit: 5,
},
});
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
debouncedSetSearchFilter(event.currentTarget.value);
}
function handleChangeRelationsClick() {
setIsMenuOpen((isOpen) => !isOpen);
}
const companiesForChips = dataForChips?.searchResults ?? [];
const companiesForSelect = dataForSelect?.searchResults ?? [];
return (
<StyledContainer>
<IconArrowUpRight size={20} color={theme.text40} />
<StyledRelationLabel>Relations</StyledRelationLabel>
<StyledRelationContainer
ref={refs.setReference}
onClick={handleChangeRelationsClick}
>
{companiesForChips?.map((company) => (
<CompanyChip
key={company.id}
name={company.name}
picture={getLogoUrlFromDomainName(company.domainName)}
/>
))}
</StyledRelationContainer>
{/* <StyledAddButton id="add-button" onClick={handleAddButtonClick}>
<IconPlus size={14} color={theme.text40} strokeWidth={1.5} />
</StyledAddButton> */}
{isMenuOpen && (
<DropdownMenu ref={refs.setFloating} style={floatingStyles}>
<DropdownMenuSearch
value={searchFilter}
onChange={handleFilterChange}
/>
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
{companiesForSelect?.slice(0, 5)?.map((company) => (
<DropdownMenuCheckableItem
checked={
companiesForChips
?.map((companyForChip) => companyForChip.id)
?.includes(company.id) ?? false
}
onChange={(newCheckedValue) => {
if (newCheckedValue) {
}
}}
>
<Avatar
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
placeholder={company.name}
size={16}
/>
{company.name}
</DropdownMenuCheckableItem>
))}
{companiesForSelect?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
)}
</StyledContainer>
);
}

View File

@ -0,0 +1,31 @@
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedCommentThreads } from '~/testing/mock-data/comment-threads';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CommentThreadRelationPicker } from '../CommentThreadRelationPicker';
const meta: Meta<typeof CommentThreadRelationPicker> = {
title: 'Comments/CommentThreadRelationPicker',
component: CommentThreadRelationPicker,
parameters: {
msw: graphqlMocks,
},
};
const StyledContainer = styled.div`
width: 400px;
`;
export default meta;
type Story = StoryObj<typeof CommentThreadRelationPicker>;
export const Default: Story = {
render: getRenderWrapperForComponent(
<StyledContainer>
<CommentThreadRelationPicker commentThread={mockedCommentThreads[0]} />
</StyledContainer>,
),
};

View File

@ -25,6 +25,10 @@ export const GET_COMMENT_THREADS_BY_TARGETS = gql`
avatarUrl
}
}
commentThreadTargets {
commentableId
commentableType
}
}
}
`;
@ -44,6 +48,10 @@ export const GET_COMMENT_THREAD = gql`
avatarUrl
}
}
commentThreadTargets {
commentableId
commentableType
}
}
}
`;

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { Avatar } from '@/users/components/Avatar';
export type CompanyChipPropsType = {
name: string;
picture?: string;
@ -15,6 +16,8 @@ const StyledContainer = styled.span`
gap: ${(props) => props.theme.spacing(1)};
padding: ${(props) => props.theme.spacing(1)};
user-select: none;
:hover {
filter: brightness(95%);
}
@ -30,10 +33,11 @@ function CompanyChip({ name, picture }: CompanyChipPropsType) {
return (
<StyledContainer data-testid="company-chip">
{picture && (
<img
data-testid="company-chip-image"
src={picture?.toString()}
alt={`${name}-company-logo`}
<Avatar
avatarUrl={picture?.toString()}
placeholder={name}
type="squared"
size={14}
/>
)}
{name}

View File

@ -2,8 +2,8 @@ import * as React from 'react';
import styled from '@emotion/styled';
type OwnProps = {
name: string;
id: string;
name?: string;
id?: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;

View File

@ -17,4 +17,6 @@ export const DropdownMenu = styled.div`
height: fit-content;
width: 200px;
z-index: ${(props) => props.theme.lastLayerZIndex};
`;

View File

@ -8,7 +8,7 @@ import { DropdownMenuButton } from './DropdownMenuButton';
type Props = {
checked: boolean;
onChange?: (newCheckedValue: boolean) => void;
id: string;
id?: string;
};
const DropdownMenuCheckableItemContainer = styled(DropdownMenuButton)`

View File

@ -25,8 +25,10 @@ type Story = StoryObj<typeof DropdownMenu>;
const FakeContentBelow = () => (
<div style={{ position: 'absolute' }}>
askjdlaksjdlaksjdlakjsdlkj lkajsldkjalskd jalksdj alksjd alskjd alksjd
alksjd laksjd askjdlaksjdlaksjdlakjsdlkj lkajsldkjalskd jalksdj alksjd
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</div>
);
@ -217,7 +219,7 @@ const FakeSelectableMenuItemWithAvatarList = () => {
onClick={() => setSelectedItem(item.id)}
>
<Avatar
placeholderLetter="A"
placeholder="A"
avatarUrl={item.avatarUrl}
size={16}
type="squared"
@ -303,7 +305,7 @@ const FakeCheckableMenuItemWithAvatarList = () => {
}}
>
<Avatar
placeholderLetter="A"
placeholder="A"
avatarUrl={item.avatarUrl}
size={16}
type="squared"

View File

@ -0,0 +1,3 @@
export function SelectSingleEntity() {
return <></>;
}

View File

@ -26,3 +26,4 @@ export { IconChevronDown } from '@tabler/icons-react';
export { IconArrowNarrowDown } from '@tabler/icons-react';
export { IconArrowNarrowUp } from '@tabler/icons-react';
export { IconArrowRight } from '@tabler/icons-react';
export { IconArrowUpRight } from '@tabler/icons-react';

View File

@ -54,7 +54,7 @@ const lightThemeSpecific = {
lighterBackgroundTransparent: 'rgba(0, 0, 0, 0.02)',
primaryBorder: 'rgba(0, 0, 0, 0.08)',
lightBorder: '#f5f5f5',
lightBorder: 'rgba(245, 245, 245, 1)',
mediumBorder: '#ebebeb',
clickableElementBackgroundTransition: 'background 0.1s ease',

View File

@ -5,26 +5,30 @@ import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
type OwnProps = {
avatarUrl: string | null | undefined;
size: number;
placeholderLetter: string;
placeholder: string;
type?: 'squared' | 'rounded';
};
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholderLetter'>>`
align-items: center;
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>`
background-color: ${(props) =>
!isNonEmptyString(props.avatarUrl)
? props.theme.tertiaryBackground
: 'none'};
background-image: url(${(props) =>
isNonEmptyString(props.avatarUrl) ? props.avatarUrl : 'none'});
background-image: url(${(props) =>
isNonEmptyString(props.avatarUrl) ? props.avatarUrl : 'none'});
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
display: flex;
height: ${(props) => props.size}px;
height: ${(props) => props.size}px;
justify-content: center;
width: ${(props) => props.size}px;
width: ${(props) => props.size}px;
`;
@ -50,7 +54,7 @@ export const StyledPlaceholderLetter = styled.div<StyledPlaceholderLetterProps>`
export function Avatar({
avatarUrl,
size,
placeholderLetter,
placeholder,
type = 'squared',
}: OwnProps) {
const noAvatarUrl = !isNonEmptyString(avatarUrl);
@ -59,7 +63,7 @@ export function Avatar({
<StyledAvatar avatarUrl={avatarUrl} size={size} type={type}>
{noAvatarUrl && (
<StyledPlaceholderLetter size={size}>
{placeholderLetter}
{placeholder[0]?.toLocaleUpperCase()}
</StyledPlaceholderLetter>
)}
</StyledAvatar>

View File

@ -17,34 +17,24 @@ const avatarUrl =
export const Rounded: Story = {
render: getRenderWrapperForComponent(
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter="L"
type="rounded"
/>,
<Avatar avatarUrl={avatarUrl} size={16} placeholder="L" type="rounded" />,
),
};
export const Squared: Story = {
render: getRenderWrapperForComponent(
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter="L"
type="squared"
/>,
<Avatar avatarUrl={avatarUrl} size={16} placeholder="L" type="squared" />,
),
};
export const NoAvatarPictureRounded: Story = {
render: getRenderWrapperForComponent(
<Avatar avatarUrl={''} size={16} placeholderLetter="L" type="rounded" />,
<Avatar avatarUrl={''} size={16} placeholder="L" type="rounded" />,
),
};
export const NoAvatarPictureSquared: Story = {
render: getRenderWrapperForComponent(
<Avatar avatarUrl={''} size={16} placeholderLetter="L" type="squared" />,
<Avatar avatarUrl={''} size={16} placeholder="L" type="squared" />,
),
};