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
@@ -80,7 +80,7 @@ export function CommentHeader({ comment }: OwnProps) {
<Avatar
avatarUrl={avatarUrl}
size={16}
placeholderLetter={capitalizedFirstUsernameLetter}
placeholder={capitalizedFirstUsernameLetter}
/>
<StyledName>{authorName}</StyledName>
{showDate && (
@@ -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>
);
@@ -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>
);
}
@@ -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>,
),
};
@@ -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
}
}
}
`;
@@ -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}
@@ -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;
@@ -17,4 +17,6 @@ export const DropdownMenu = styled.div`
height: fit-content;
width: 200px;
z-index: ${(props) => props.theme.lastLayerZIndex};
`;
@@ -8,7 +8,7 @@ import { DropdownMenuButton } from './DropdownMenuButton';
type Props = {
checked: boolean;
onChange?: (newCheckedValue: boolean) => void;
id: string;
id?: string;
};
const DropdownMenuCheckableItemContainer = styled(DropdownMenuButton)`
@@ -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"
@@ -0,0 +1,3 @@
export function SelectSingleEntity() {
return <></>;
}
+1
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';
+1 -1
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',
+10 -6
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>
@@ -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" />,
),
};