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:
@ -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 <></>;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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" />,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user