Lucas/t 369 on comment drawer i can reply to a comment thread and it (#206)

* Added prisma to suggested extension in container

* Added comments and authors on drawer with proper resolving

* Fix lint

* Fix console log

* Fixed generated front graphql from rebase

* Fixed right drawer width and shared in theme

* Added date packages and tooltip

* Added date utils and tests

* Added comment thread components

* Fixed comment chip

* wip

* wip 2

* - Added string typing for DateTime scalar
- Refactored user in a recoil state and workspace using it
- Added comment creation

* Prepared EditableCell refactor

* Fixed line height and tooltip

* Fix lint
This commit is contained in:
Lucas Bordeau
2023-06-08 10:36:37 +02:00
committed by GitHub
parent 5e2673a2a4
commit ce4ba10f7b
31 changed files with 395 additions and 167 deletions

View File

@ -1,16 +1,16 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
import { UserAvatar } from '@/users/components/UserAvatar';
import {
beautifyExactDate,
beautifyPastDateRelativeToNow,
} from '@/utils/datetime/date-utils';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
type OwnProps = {
avatarUrl: string | null | undefined;
username: string;
createdAt: Date;
comment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'>;
};
const StyledContainer = styled.div`
@ -44,15 +44,30 @@ const StyledDate = styled.div`
const StyledTooltip = styled(Tooltip)`
padding: 8px;
opacity: 1;
background-color: ${(props) => props.theme.primaryBackground};
color: ${(props) => props.theme.text100};
box-shadow: 2px 4px 16px 6px rgba(0, 0, 0, 0.12);
box-shadow: 0px 2px 4px 3px rgba(0, 0, 0, 0.04);
`;
export function CommentHeader({ avatarUrl, username, createdAt }: OwnProps) {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(createdAt);
const exactCreatedAt = beautifyExactDate(createdAt);
export function CommentHeader({ comment }: OwnProps) {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt);
const exactCreatedAt = beautifyExactDate(comment.createdAt);
const showDate = beautifiedCreatedAt !== '';
const capitalizedFirstUsernameLetter =
username !== '' ? username.toLocaleUpperCase()[0] : '';
const author = comment.author;
const authorName = author.displayName;
const avatarUrl = author.avatarUrl;
const commentId = comment.id;
const capitalizedFirstUsernameLetter = isNonEmptyString(authorName)
? authorName.toLocaleUpperCase()[0]
: '';
return (
<StyledContainer>
@ -61,14 +76,12 @@ export function CommentHeader({ avatarUrl, username, createdAt }: OwnProps) {
size={16}
placeholderLetter={capitalizedFirstUsernameLetter}
/>
<StyledName>{username}</StyledName>
<StyledName>{authorName}</StyledName>
{showDate && (
<>
<StyledDate className="comment-created-at">
{beautifiedCreatedAt}
</StyledDate>
<StyledDate id={`id-${commentId}`}>{beautifiedCreatedAt}</StyledDate>
<StyledTooltip
anchorSelect=".comment-created-at"
anchorSelect={`#id-${commentId}`}
content={exactCreatedAt}
clickable
noArrow

View File

@ -1,123 +0,0 @@
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeysEvent } from 'react-hotkeys-hook/dist/types';
import { HiArrowSmRight } from 'react-icons/hi';
import TextareaAutosize from 'react-textarea-autosize';
import styled from '@emotion/styled';
import { IconButton } from '@/ui/components/buttons/IconButton';
type OwnProps = {
onSend?: (text: string) => void;
placeholder?: string;
};
const StyledContainer = styled.div`
display: flex;
min-height: 32px;
width: 100%;
`;
const StyledTextArea = styled(TextareaAutosize)`
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
font-weight: 400;
line-height: 16px;
border: none;
border-radius: 5px;
background: ${(props) => props.theme.tertiaryBackground};
color: ${(props) => props.theme.text80};
overflow: auto;
resize: none;
&:focus {
outline: none;
border: none;
}
&::placeholder {
color: ${(props) => props.theme.text30};
font-weight: 400;
}
`;
const StyledBottomRightIconButton = styled.div`
width: 0px;
position: relative;
top: calc(100% - 26.5px);
right: 26px;
`;
export function CommentTextInput({ placeholder, onSend }: OwnProps) {
const [text, setText] = useState('');
const isSendButtonDisabled = !text;
useHotkeys(
['shift+enter', 'enter'],
(event: KeyboardEvent, handler: HotkeysEvent) => {
if (handler.shift) {
return;
} else {
event.preventDefault();
onSend?.(text);
setText('');
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[onSend],
);
useHotkeys(
'esc',
(event: KeyboardEvent, handler: HotkeysEvent) => {
event.preventDefault();
setText('');
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[onSend],
);
function handleInputChange(event: React.FormEvent<HTMLTextAreaElement>) {
const newText = event.currentTarget.value;
setText(newText);
}
function handleOnClickSendButton() {
onSend?.(text);
setText('');
}
return (
<>
<StyledContainer>
<StyledTextArea
placeholder={placeholder || 'Write something...'}
maxRows={5}
onChange={handleInputChange}
value={text}
/>
<StyledBottomRightIconButton>
<IconButton
onClick={handleOnClickSendButton}
icon={<HiArrowSmRight size={15} />}
disabled={isSendButtonDisabled}
/>
</StyledBottomRightIconButton>
</StyledContainer>
</>
);
}

View File

@ -1,8 +1,15 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { currentUserState } from '@/auth/states/currentUserState';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { AutosizeTextInput } from '@/ui/components/inputs/AutosizeTextInput';
import { logError } from '@/utils/logs/logError';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { useCreateCommentMutation } from '~/generated/graphql';
import { CommentTextInput } from './CommentTextInput';
import { CommentThreadItem } from './CommentThreadItem';
type OwnProps = {
@ -21,17 +28,70 @@ const StyledContainer = styled.div`
padding: ${(props) => props.theme.spacing(2)};
`;
const StyledThreadItemListContainer = styled.div`
display: flex;
flex-direction: column-reverse;
align-items: flex-start;
justify-content: flex-start;
max-height: 400px;
overflow: auto;
gap: ${(props) => props.theme.spacing(4)};
`;
export function CommentThread({ commentThread }: OwnProps) {
function handleSendComment(text: string) {
console.log(text);
const [createCommentMutation] = useCreateCommentMutation();
const currentUser = useRecoilValue(currentUserState);
function handleSendComment(commentText: string) {
if (!isDefined(currentUser)) {
logError(
'In handleSendComment, currentUser is not defined, this should not happen.',
);
return;
}
if (!isNonEmptyString(commentText)) {
logError(
'In handleSendComment, trying to send empty text, this should not happen.',
);
return;
}
if (isDefined(currentUser)) {
createCommentMutation({
variables: {
commentId: v4(),
authorId: currentUser.id,
commentThreadId: commentThread.id,
commentText,
createdAt: new Date().toISOString(),
},
// TODO: find a way to have this configuration dynamic and typed
refetchQueries: [
'GetCommentThreadsByTargets',
'GetPeopleCommentsCount',
'GetCompanyCommentsCount',
],
onError: (error) => {
logError(
`In handleSendComment, createCommentMutation onError, error: ${error}`,
);
},
});
}
}
return (
<StyledContainer>
{commentThread.comments?.map((comment) => (
<CommentThreadItem key={comment.id} comment={comment} />
))}
<CommentTextInput onSend={handleSendComment} />
<StyledThreadItemListContainer>
{commentThread.comments?.map((comment) => (
<CommentThreadItem key={comment.id} comment={comment} />
))}
</StyledThreadItemListContainer>
<AutosizeTextInput onSend={handleSendComment} />
</StyledContainer>
);
}

View File

@ -18,22 +18,20 @@ const StyledContainer = styled.div`
const StyledCommentBody = styled.div`
font-size: ${(props) => props.theme.fontSizeMedium};
line-height: 19.5px;
line-height: ${(props) => props.theme.lineHeight};
text-align: left;
padding-left: 24px;
color: ${(props) => props.theme.text60};
overflow-wrap: anywhere;
`;
export function CommentThreadItem({ comment }: OwnProps) {
return (
<StyledContainer>
<CommentHeader
avatarUrl={comment.author.avatarUrl}
username={comment.author.displayName}
createdAt={comment.createdAt}
/>
<CommentHeader comment={comment} />
<StyledCommentBody>{comment.body}</StyledCommentBody>
</StyledContainer>
);

View File

@ -29,7 +29,7 @@ export function RightDrawerComments() {
<RightDrawerTopBar title="Comments" />
<RightDrawerBody>
{commentThreads.map((commentThread) => (
<CommentThread commentThread={commentThread} />
<CommentThread key={commentThread.id} commentThread={commentThread} />
))}
</RightDrawerBody>
</RightDrawerPage>

View File

@ -17,9 +17,14 @@ type Story = StoryObj<typeof CellCommentChip>;
const TestCellContainer = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
min-width: 250px;
max-width: 250px;
text-wrap: nowrap;
overflow: hidden;
height: fit-content;
background: ${(props) => props.theme.primaryBackground};

View File

@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { DateTime } from 'luxon';
import { v4 } from 'uuid';
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
import { mockedUsersData } from '~/testing/mock-data/users';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
@ -16,12 +18,23 @@ type Story = StoryObj<typeof CommentHeader>;
const mockUser = mockedUsersData[0];
const mockComment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'> = {
id: v4(),
author: {
id: v4(),
displayName: mockUser.displayName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
};
export const Default: Story = {
render: getRenderWrapperForComponent(
<CommentHeader
avatarUrl={mockUser.avatarUrl ?? ''}
username={mockUser.displayName ?? ''}
createdAt={DateTime.now().minus({ hours: 2 }).toJSDate()}
comment={{
...mockComment,
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
}}
/>,
),
};
@ -29,9 +42,10 @@ export const Default: Story = {
export const FewDaysAgo: Story = {
render: getRenderWrapperForComponent(
<CommentHeader
avatarUrl={mockUser.avatarUrl ?? ''}
username={mockUser.displayName ?? ''}
createdAt={DateTime.now().minus({ days: 2 }).toJSDate()}
comment={{
...mockComment,
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
}}
/>,
),
};
@ -39,9 +53,10 @@ export const FewDaysAgo: Story = {
export const FewMonthsAgo: Story = {
render: getRenderWrapperForComponent(
<CommentHeader
avatarUrl={mockUser.avatarUrl ?? ''}
username={mockUser.displayName ?? ''}
createdAt={DateTime.now().minus({ months: 2 }).toJSDate()}
comment={{
...mockComment,
createdAt: DateTime.now().minus({ months: 2 }).toISO() ?? '',
}}
/>,
),
};
@ -49,9 +64,10 @@ export const FewMonthsAgo: Story = {
export const FewYearsAgo: Story = {
render: getRenderWrapperForComponent(
<CommentHeader
avatarUrl={mockUser.avatarUrl ?? ''}
username={mockUser.displayName ?? ''}
createdAt={DateTime.now().minus({ years: 2 }).toJSDate()}
comment={{
...mockComment,
createdAt: DateTime.now().minus({ years: 2 }).toISO() ?? '',
}}
/>,
),
};
@ -59,9 +75,14 @@ export const FewYearsAgo: Story = {
export const WithoutAvatar: Story = {
render: getRenderWrapperForComponent(
<CommentHeader
avatarUrl={''}
username={mockUser.displayName ?? ''}
createdAt={DateTime.now().minus({ hours: 2 }).toJSDate()}
comment={{
...mockComment,
author: {
...mockComment.author,
avatarUrl: '',
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
}}
/>,
),
};

View File

@ -1,32 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CommentTextInput } from '../CommentTextInput';
const meta: Meta<typeof CommentTextInput> = {
title: 'Components/Comments/CommentTextInput',
component: CommentTextInput,
argTypes: {
onSend: {
action: 'onSend',
},
},
};
export default meta;
type Story = StoryObj<typeof CommentTextInput>;
export const Default: Story = {
render: getRenderWrapperForComponent(<CommentTextInput />),
parameters: {
msw: graphqlMocks,
actions: { argTypesRegex: '^on.*' },
},
args: {
onSend: (text: string) => {
console.log(text);
},
},
};