Lucas/t 366 on comment drawer when i have comments on the selected (#201)
* 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 * Fix from rebase * Fix from rebase * Fix margin right * Fixed CSS and graphql
This commit is contained in:
@ -4,7 +4,7 @@ import { CommentChip, CommentChipProps } from './CommentChip';
|
||||
|
||||
const StyledCellWrapper = styled.div`
|
||||
position: relative;
|
||||
right: 38px;
|
||||
right: 34px;
|
||||
top: -13px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
@ -9,18 +9,18 @@ export type CommentChipProps = {
|
||||
|
||||
const StyledChip = styled.div`
|
||||
height: 26px;
|
||||
min-width: 34px;
|
||||
width: fit-content;
|
||||
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
|
||||
background: ${(props) => props.theme.secondaryBackgroundTransparent};
|
||||
background: ${(props) => props.theme.primaryBackgroundTransparent};
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
border-radius: ${(props) => props.theme.borderRadius};
|
||||
@ -53,7 +53,7 @@ export function CommentChip({ count, onClick }: CommentChipProps) {
|
||||
return (
|
||||
<StyledChip data-testid="comment-chip" onClick={onClick}>
|
||||
<StyledCount>{formattedCount}</StyledCount>
|
||||
<IconComment size={12} />
|
||||
<IconComment size={16} />
|
||||
</StyledChip>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { UserAvatar } from '@/users/components/UserAvatar';
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '@/utils/datetime/date-utils';
|
||||
|
||||
type OwnProps = {
|
||||
avatarUrl: string | null | undefined;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: flex-start;
|
||||
|
||||
padding: ${(props) => props.theme.spacing(1)};
|
||||
|
||||
gap: ${(props) => props.theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledName = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text80};
|
||||
`;
|
||||
|
||||
const StyledDate = styled.div`
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text30};
|
||||
|
||||
padding-top: 1.5px;
|
||||
|
||||
margin-left: ${(props) => props.theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
export function CommentHeader({ avatarUrl, username, createdAt }: OwnProps) {
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(createdAt);
|
||||
const exactCreatedAt = beautifyExactDate(createdAt);
|
||||
const showDate = beautifiedCreatedAt !== '';
|
||||
|
||||
const capitalizedFirstUsernameLetter =
|
||||
username !== '' ? username.toLocaleUpperCase()[0] : '';
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
size={16}
|
||||
placeholderLetter={capitalizedFirstUsernameLetter}
|
||||
/>
|
||||
<StyledName>{username}</StyledName>
|
||||
{showDate && (
|
||||
<>
|
||||
<StyledDate className="comment-created-at">
|
||||
{beautifiedCreatedAt}
|
||||
</StyledDate>
|
||||
<StyledTooltip
|
||||
anchorSelect=".comment-created-at"
|
||||
content={exactCreatedAt}
|
||||
clickable
|
||||
noArrow
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,37 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
|
||||
|
||||
import { CommentTextInput } from './CommentTextInput';
|
||||
import { CommentThreadItem } from './CommentThreadItem';
|
||||
|
||||
type OwnProps = {
|
||||
commentThread: CommentThreadForDrawer;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
justify-content: flex-start;
|
||||
|
||||
gap: ${(props) => props.theme.spacing(4)};
|
||||
padding: ${(props) => props.theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export function CommentThread({ commentThread }: OwnProps) {
|
||||
function handleSendComment(text: string) {
|
||||
console.log(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledContainer>
|
||||
{commentThread.comments?.map((comment) => (
|
||||
<div key={comment.id}>
|
||||
<div>
|
||||
{comment.author?.displayName} - {comment.createdAt}
|
||||
</div>
|
||||
<div>{comment.body}</div>
|
||||
</div>
|
||||
<CommentThreadItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
<CommentTextInput onSend={handleSendComment} />
|
||||
</div>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
|
||||
|
||||
import { CommentHeader } from './CommentHeader';
|
||||
|
||||
type OwnProps = {
|
||||
comment: CommentForDrawer;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: ${(props) => props.theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledCommentBody = styled.div`
|
||||
font-size: ${(props) => props.theme.fontSizeMedium};
|
||||
line-height: 19.5px;
|
||||
|
||||
text-align: left;
|
||||
padding-left: 24px;
|
||||
|
||||
color: ${(props) => props.theme.text60};
|
||||
`;
|
||||
|
||||
export function CommentThreadItem({ comment }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<CommentHeader
|
||||
avatarUrl={comment.author.avatarUrl}
|
||||
username={comment.author.displayName}
|
||||
createdAt={comment.createdAt}
|
||||
/>
|
||||
<StyledCommentBody>{comment.body}</StyledCommentBody>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ import { CellCommentChip } from '../CellCommentChip';
|
||||
import { CommentChip } from '../CommentChip';
|
||||
|
||||
const meta: Meta<typeof CellCommentChip> = {
|
||||
title: 'Components/CellCommentChip',
|
||||
title: 'Components/Comments/CellCommentChip',
|
||||
component: CellCommentChip,
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { mockedUsersData } from '~/testing/mock-data/users';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { CommentHeader } from '../CommentHeader';
|
||||
|
||||
const meta: Meta<typeof CommentHeader> = {
|
||||
title: 'Components/Comments/CommentHeader',
|
||||
component: CommentHeader,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommentHeader>;
|
||||
|
||||
const mockUser = mockedUsersData[0];
|
||||
|
||||
export const Default: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
avatarUrl={mockUser.avatarUrl ?? ''}
|
||||
username={mockUser.displayName ?? ''}
|
||||
createdAt={DateTime.now().minus({ hours: 2 }).toJSDate()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewDaysAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
avatarUrl={mockUser.avatarUrl ?? ''}
|
||||
username={mockUser.displayName ?? ''}
|
||||
createdAt={DateTime.now().minus({ days: 2 }).toJSDate()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewMonthsAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
avatarUrl={mockUser.avatarUrl ?? ''}
|
||||
username={mockUser.displayName ?? ''}
|
||||
createdAt={DateTime.now().minus({ months: 2 }).toJSDate()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewYearsAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
avatarUrl={mockUser.avatarUrl ?? ''}
|
||||
username={mockUser.displayName ?? ''}
|
||||
createdAt={DateTime.now().minus({ years: 2 }).toJSDate()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const WithoutAvatar: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
avatarUrl={''}
|
||||
username={mockUser.displayName ?? ''}
|
||||
createdAt={DateTime.now().minus({ hours: 2 }).toJSDate()}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
@ -6,7 +6,7 @@ import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
import { CommentTextInput } from '../CommentTextInput';
|
||||
|
||||
const meta: Meta<typeof CommentTextInput> = {
|
||||
title: 'Components/CommentTextInput',
|
||||
title: 'Components/Comments/CommentTextInput',
|
||||
component: CommentTextInput,
|
||||
argTypes: {
|
||||
onSend: {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
import {
|
||||
useGetCompanyCountsQuery,
|
||||
useGetPeopleCountsQuery,
|
||||
useGetCompanyCommentsCountQuery,
|
||||
useGetPeopleCommentsCountQuery,
|
||||
} from '../../../generated/graphql';
|
||||
|
||||
export const GET_COMPANY_COMMENT_COUNT = gql`
|
||||
query GetCompanyCounts($where: CompanyWhereInput) {
|
||||
query GetCompanyCommentsCount($where: CompanyWhereInput) {
|
||||
companies: findManyCompany(where: $where) {
|
||||
commentsCount: _commentCount
|
||||
}
|
||||
@ -14,14 +14,14 @@ export const GET_COMPANY_COMMENT_COUNT = gql`
|
||||
`;
|
||||
|
||||
export const useCompanyCommentsCountQuery = (companyId: string) => {
|
||||
const { data, ...rest } = useGetCompanyCountsQuery({
|
||||
const { data, ...rest } = useGetCompanyCommentsCountQuery({
|
||||
variables: { where: { id: { equals: companyId } } },
|
||||
});
|
||||
return { ...rest, data: data?.companies[0].commentsCount };
|
||||
};
|
||||
|
||||
export const GET_PEOPLE_COMMENT_COUNT = gql`
|
||||
query GetPeopleCounts($where: PersonWhereInput) {
|
||||
query GetPeopleCommentsCount($where: PersonWhereInput) {
|
||||
people: findManyPerson(where: $where) {
|
||||
commentsCount: _commentCount
|
||||
}
|
||||
@ -29,7 +29,7 @@ export const GET_PEOPLE_COMMENT_COUNT = gql`
|
||||
`;
|
||||
|
||||
export const usePeopleCommentsCountQuery = (personId: string) => {
|
||||
const { data, ...rest } = useGetPeopleCountsQuery({
|
||||
const { data, ...rest } = useGetPeopleCommentsCountQuery({
|
||||
variables: { where: { id: { equals: personId } } },
|
||||
});
|
||||
return { ...rest, data: data?.people[0].commentsCount };
|
||||
|
||||
5
front/src/modules/comments/types/CommentForDrawer.ts
Normal file
5
front/src/modules/comments/types/CommentForDrawer.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CommentThreadForDrawer } from './CommentThreadForDrawer';
|
||||
|
||||
export type CommentForDrawer = NonNullable<
|
||||
CommentThreadForDrawer['comments']
|
||||
>[0];
|
||||
@ -1,9 +1,9 @@
|
||||
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip';
|
||||
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
|
||||
import { useCompanyCommentsCountQuery } from '@/comments/services';
|
||||
import EditableChip from '@/ui/components/editable-cell/types/EditableChip';
|
||||
import { getLogoUrlFromDomainName } from '@/utils/utils';
|
||||
|
||||
import { CellCommentChip } from '../../comments/components/comments/CellCommentChip';
|
||||
import { useCompanyCommentsCountQuery } from '../../comments/services';
|
||||
import { Company } from '../interfaces/company.interface';
|
||||
import { updateCompany } from '../services';
|
||||
|
||||
@ -16,7 +16,10 @@ type OwnProps = {
|
||||
export function CompanyEditableNameChipCell({ company }: OwnProps) {
|
||||
const openCommentRightDrawer = useOpenCommentRightDrawer();
|
||||
|
||||
function handleCommentClick() {
|
||||
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openCommentRightDrawer([
|
||||
{
|
||||
type: 'Company',
|
||||
@ -27,6 +30,8 @@ export function CompanyEditableNameChipCell({ company }: OwnProps) {
|
||||
|
||||
const commentCount = useCompanyCommentsCountQuery(company.id);
|
||||
|
||||
const displayCommentCount = !commentCount.loading;
|
||||
|
||||
return (
|
||||
<EditableChip
|
||||
value={company.name || ''}
|
||||
@ -40,9 +45,9 @@ export function CompanyEditableNameChipCell({ company }: OwnProps) {
|
||||
}}
|
||||
ChipComponent={CompanyChip}
|
||||
rightEndContents={[
|
||||
commentCount.loading ? null : (
|
||||
displayCommentCount && (
|
||||
<CellCommentChip
|
||||
count={commentCount.data || 0}
|
||||
count={commentCount.data ?? 0}
|
||||
onClick={handleCommentClick}
|
||||
/>
|
||||
),
|
||||
|
||||
@ -21,6 +21,7 @@ describe('Company mappers', () => {
|
||||
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
|
||||
email: 'john@example.com',
|
||||
displayName: 'John Doe',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
__typename: 'User',
|
||||
},
|
||||
pipes: [
|
||||
@ -47,6 +48,7 @@ describe('Company mappers', () => {
|
||||
__typename: 'users',
|
||||
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
|
||||
email: 'john@example.com',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
displayName: 'John Doe',
|
||||
workspaceMember: undefined,
|
||||
},
|
||||
@ -67,6 +69,7 @@ describe('Company mappers', () => {
|
||||
accountOwner: {
|
||||
id: '522d4ec4-c46b-4360-a0a7-df8df170be81',
|
||||
email: 'john@example.com',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
displayName: 'John Doe',
|
||||
__typename: 'users',
|
||||
},
|
||||
|
||||
@ -2,9 +2,9 @@ import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip';
|
||||
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
|
||||
import { EditableDoubleText } from '@/ui/components/editable-cell/types/EditableDoubleText';
|
||||
|
||||
import { useOpenCommentRightDrawer } from '../../comments/hooks/useOpenCommentRightDrawer';
|
||||
import { usePeopleCommentsCountQuery } from '../../comments/services';
|
||||
|
||||
import { PersonChip } from './PersonChip';
|
||||
@ -31,6 +31,7 @@ export function EditablePeopleFullName({
|
||||
}: OwnProps) {
|
||||
const [firstnameValue, setFirstnameValue] = useState(firstname);
|
||||
const [lastnameValue, setLastnameValue] = useState(lastname);
|
||||
const openCommentRightDrawer = useOpenCommentRightDrawer();
|
||||
|
||||
function handleDoubleTextChange(
|
||||
firstValue: string,
|
||||
@ -42,14 +43,13 @@ export function EditablePeopleFullName({
|
||||
onChange(firstValue, secondValue);
|
||||
}
|
||||
|
||||
const openCommentRightDrawer = useOpenCommentRightDrawer();
|
||||
|
||||
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openCommentRightDrawer([
|
||||
{
|
||||
type: 'Company',
|
||||
type: 'Person',
|
||||
id: personId,
|
||||
},
|
||||
]);
|
||||
@ -57,6 +57,8 @@ export function EditablePeopleFullName({
|
||||
|
||||
const commentCount = usePeopleCommentsCountQuery(personId);
|
||||
|
||||
const displayCommentCount = !commentCount.loading;
|
||||
|
||||
return (
|
||||
<EditableDoubleText
|
||||
firstValue={firstnameValue}
|
||||
@ -69,9 +71,9 @@ export function EditablePeopleFullName({
|
||||
<StyledDiv>
|
||||
<PersonChip name={firstname + ' ' + lastname} />
|
||||
</StyledDiv>
|
||||
{commentCount.loading ? null : (
|
||||
{displayCommentCount && (
|
||||
<CellCommentChip
|
||||
count={commentCount.data || 0}
|
||||
count={commentCount.data ?? 0}
|
||||
onClick={handleCommentClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -30,10 +30,9 @@ const MainContainer = styled.div`
|
||||
background: ${(props) => props.theme.noisyBackground};
|
||||
padding-right: ${(props) => props.theme.spacing(3)};
|
||||
padding-bottom: ${(props) => props.theme.spacing(3)};
|
||||
gap: ${(props) => props.theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const RIGHT_DRAWER_WIDTH = '300px';
|
||||
|
||||
type LeftContainerProps = {
|
||||
isRightDrawerOpen?: boolean;
|
||||
};
|
||||
@ -41,7 +40,11 @@ type LeftContainerProps = {
|
||||
const LeftContainer = styled.div<LeftContainerProps>`
|
||||
display: flex;
|
||||
width: calc(
|
||||
100% - ${(props) => (props.isRightDrawerOpen ? RIGHT_DRAWER_WIDTH : '0px')}
|
||||
100% -
|
||||
${(props) =>
|
||||
props.isRightDrawerOpen
|
||||
? `${props.theme.rightDrawerWidth} - ${props.theme.spacing(2)}`
|
||||
: '0px'}
|
||||
);
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
@ -12,8 +12,7 @@ import { RightDrawerRouter } from './RightDrawerRouter';
|
||||
const StyledRightDrawer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 300px;
|
||||
margin-left: ${(props) => props.theme.spacing(2)};
|
||||
width: ${(props) => props.theme.rightDrawerWidth};
|
||||
`;
|
||||
|
||||
export function RightDrawer() {
|
||||
|
||||
@ -3,6 +3,4 @@ import styled from '@emotion/styled';
|
||||
export const RightDrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
@ -23,6 +23,8 @@ const commonTheme = {
|
||||
},
|
||||
|
||||
borderRadius: '4px',
|
||||
|
||||
rightDrawerWidth: '300px',
|
||||
};
|
||||
|
||||
const lightThemeSpecific = {
|
||||
@ -38,6 +40,7 @@ const lightThemeSpecific = {
|
||||
purpleBackground: '#e0e0ff',
|
||||
yellowBackground: '#fff2e7',
|
||||
|
||||
primaryBackgroundTransparent: 'rgba(255, 255, 255, 0.8)',
|
||||
secondaryBackgroundTransparent: 'rgba(252, 252, 252, 0.8)',
|
||||
|
||||
primaryBorder: 'rgba(0, 0, 0, 0.08)',
|
||||
@ -77,6 +80,7 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
|
||||
purpleBackground: '#1111b7',
|
||||
yellowBackground: '#cc660a',
|
||||
|
||||
primaryBackgroundTransparent: 'rgba(20, 20, 20, 0.8)',
|
||||
secondaryBackgroundTransparent: 'rgba(23, 23, 23, 0.8)',
|
||||
|
||||
clickableElementBackgroundHover: 'rgba(0, 0, 0, 0.04)',
|
||||
|
||||
61
front/src/modules/users/components/UserAvatar.tsx
Normal file
61
front/src/modules/users/components/UserAvatar.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
|
||||
|
||||
type OwnProps = {
|
||||
avatarUrl: string | null | undefined;
|
||||
size: number;
|
||||
placeholderLetter: string;
|
||||
};
|
||||
|
||||
export const StyledUserAvatar = styled.div<Omit<OwnProps, 'placeholderLetter'>>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
background-image: url(${(props) =>
|
||||
isNonEmptyString(props.avatarUrl) ? props.avatarUrl : 'none'});
|
||||
background-color: ${(props) =>
|
||||
!isNonEmptyString(props.avatarUrl)
|
||||
? props.theme.tertiaryBackground
|
||||
: 'none'};
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
type StyledPlaceholderLetterProps = {
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const StyledPlaceholderLetter = styled.div<StyledPlaceholderLetterProps>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: ${(props) => props.theme.text80};
|
||||
`;
|
||||
|
||||
export function UserAvatar({ avatarUrl, size, placeholderLetter }: OwnProps) {
|
||||
const noAvatarUrl = !isNonEmptyString(avatarUrl);
|
||||
|
||||
return (
|
||||
<StyledUserAvatar avatarUrl={avatarUrl} size={size}>
|
||||
{noAvatarUrl && (
|
||||
<StyledPlaceholderLetter size={size}>
|
||||
{placeholderLetter}
|
||||
</StyledPlaceholderLetter>
|
||||
)}
|
||||
</StyledUserAvatar>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { UserAvatar } from '../UserAvatar';
|
||||
|
||||
const meta: Meta<typeof UserAvatar> = {
|
||||
title: 'Components/User/UserAvatar',
|
||||
component: UserAvatar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UserAvatar>;
|
||||
|
||||
const avatarUrl =
|
||||
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
|
||||
|
||||
export const Size40: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<UserAvatar avatarUrl={avatarUrl} size={40} placeholderLetter="L" />,
|
||||
),
|
||||
};
|
||||
|
||||
export const Size32: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<UserAvatar avatarUrl={avatarUrl} size={32} placeholderLetter="L" />,
|
||||
),
|
||||
};
|
||||
|
||||
export const Size16: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<UserAvatar avatarUrl={avatarUrl} size={16} placeholderLetter="L" />,
|
||||
),
|
||||
};
|
||||
|
||||
export const NoAvatarPicture: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<UserAvatar avatarUrl={''} size={16} placeholderLetter="L" />,
|
||||
),
|
||||
};
|
||||
@ -13,6 +13,7 @@ describe('User mappers', () => {
|
||||
const graphQLUser = {
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
|
||||
displayName: 'John Doe',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
email: 'john.doe@gmail.com',
|
||||
workspaceMember: {
|
||||
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e88',
|
||||
@ -31,6 +32,7 @@ describe('User mappers', () => {
|
||||
__typename: 'users',
|
||||
id: graphQLUser.id,
|
||||
displayName: graphQLUser.displayName,
|
||||
avatarUrl: graphQLUser.avatarUrl,
|
||||
email: graphQLUser.email,
|
||||
workspaceMember: {
|
||||
id: graphQLUser.workspaceMember.id,
|
||||
@ -51,6 +53,7 @@ describe('User mappers', () => {
|
||||
__typename: 'users',
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
|
||||
displayName: 'John Doe',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
email: 'john.doe@gmail.com',
|
||||
workspaceMember: {
|
||||
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e88',
|
||||
@ -65,6 +68,7 @@ describe('User mappers', () => {
|
||||
expect(graphQLUser).toStrictEqual({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email,
|
||||
workspaceMemberId: user.workspaceMember.id,
|
||||
__typename: 'users',
|
||||
|
||||
@ -9,6 +9,7 @@ export interface User {
|
||||
id: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
workspaceMember?: WorkspaceMember | null;
|
||||
}
|
||||
|
||||
@ -17,6 +18,7 @@ export type GraphqlQueryUser = {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
workspaceMember?: GraphqlQueryWorkspaceMember | null;
|
||||
avatarUrl?: string;
|
||||
__typename?: string;
|
||||
};
|
||||
|
||||
@ -24,6 +26,7 @@ export type GraphqlMutationUser = {
|
||||
id: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
workspaceMemberId?: string;
|
||||
__typename?: string;
|
||||
};
|
||||
@ -33,6 +36,7 @@ export const mapToUser = (user: GraphqlQueryUser): User => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
workspaceMember: user.workspaceMember
|
||||
? mapToWorkspaceMember(user.workspaceMember)
|
||||
: user.workspaceMember,
|
||||
@ -42,6 +46,7 @@ export const mapToGqlUser = (user: User): GraphqlMutationUser => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
workspaceMemberId: user.workspaceMember?.id,
|
||||
__typename: 'users',
|
||||
});
|
||||
|
||||
155
front/src/modules/utils/datetime/__tests__/date-utils.test.ts
Normal file
155
front/src/modules/utils/datetime/__tests__/date-utils.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { logError } from '@/utils/logs/logError';
|
||||
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateAbsolute,
|
||||
beautifyPastDateRelativeToNow,
|
||||
DEFAULT_DATE_LOCALE,
|
||||
parseDate,
|
||||
} from '../date-utils';
|
||||
|
||||
jest.mock('@/utils/logs/logError');
|
||||
|
||||
describe('beautifyExactDate', () => {
|
||||
it('should return the correct relative date', () => {
|
||||
const mockDate = '2023-01-01T12:13:24';
|
||||
const actualDate = new Date(mockDate);
|
||||
const expected = DateTime.fromJSDate(actualDate)
|
||||
.toUTC()
|
||||
.setLocale(DEFAULT_DATE_LOCALE)
|
||||
.toFormat('DD · TT');
|
||||
|
||||
const result = beautifyExactDate(mockDate);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDate', () => {
|
||||
it('should log an error and return empty string when passed an invalid date string', () => {
|
||||
expect(() => {
|
||||
parseDate('invalid-date-string');
|
||||
}).toThrow(
|
||||
Error('Invalid date passed to formatPastDate: "invalid-date-string"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed NaN', () => {
|
||||
expect(() => {
|
||||
parseDate(NaN);
|
||||
}).toThrow(Error('Invalid date passed to formatPastDate: "NaN"'));
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed invalid Date object', () => {
|
||||
expect(() => {
|
||||
parseDate(new Date(NaN));
|
||||
}).toThrow(Error('Invalid date passed to formatPastDate: "Invalid Date"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyPastDateRelativeToNow', () => {
|
||||
it('should return the correct relative date', () => {
|
||||
const mockDate = '2023-01-01';
|
||||
const actualDate = new Date(mockDate);
|
||||
const expected = formatDistanceToNow(actualDate);
|
||||
|
||||
const result = beautifyPastDateRelativeToNow(mockDate);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed an invalid date string', () => {
|
||||
const result = beautifyPastDateRelativeToNow('invalid-date-string');
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "invalid-date-string"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed NaN', () => {
|
||||
const result = beautifyPastDateRelativeToNow(NaN);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "NaN"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed invalid Date object', () => {
|
||||
const result = beautifyPastDateRelativeToNow(
|
||||
new Date('invalid-date-asdasd'),
|
||||
);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "Invalid Date"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyPastDateAbsolute', () => {
|
||||
it('should log an error and return empty string when passed an invalid date string', () => {
|
||||
const result = beautifyPastDateAbsolute('invalid-date-string');
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "invalid-date-string"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed NaN', () => {
|
||||
const result = beautifyPastDateAbsolute(NaN);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "NaN"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should log an error and return empty string when passed invalid Date object', () => {
|
||||
const result = beautifyPastDateAbsolute(new Date(NaN));
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
Error('Invalid date passed to formatPastDate: "Invalid Date"'),
|
||||
);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 24 hours', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ hours: 23 });
|
||||
const expected = pastDate.toFormat('HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 7 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 6 });
|
||||
const expected = pastDate.toFormat('cccc - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is less than 365 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 364 });
|
||||
const expected = pastDate.toFormat('MMMM d - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return the correct format when the date difference is more than 365 days', () => {
|
||||
const now = DateTime.local();
|
||||
const pastDate = now.minus({ days: 366 });
|
||||
const expected = pastDate.toFormat('dd/MM/yyyy - HH:mm');
|
||||
|
||||
const result = beautifyPastDateAbsolute(pastDate.toJSDate());
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
75
front/src/modules/utils/datetime/date-utils.ts
Normal file
75
front/src/modules/utils/datetime/date-utils.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { logError } from '../logs/logError';
|
||||
|
||||
export const DEFAULT_DATE_LOCALE = 'en-EN';
|
||||
|
||||
export function parseDate(dateToParse: Date | string | number) {
|
||||
let formattedDate: DateTime | null = null;
|
||||
|
||||
if (!dateToParse) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
} else if (typeof dateToParse === 'string') {
|
||||
formattedDate = DateTime.fromISO(dateToParse);
|
||||
} else if (dateToParse instanceof Date) {
|
||||
formattedDate = DateTime.fromJSDate(dateToParse);
|
||||
} else if (typeof dateToParse === 'number') {
|
||||
formattedDate = DateTime.fromMillis(dateToParse);
|
||||
}
|
||||
|
||||
if (!formattedDate) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
}
|
||||
|
||||
if (!formattedDate.isValid) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
}
|
||||
|
||||
return formattedDate.setLocale(DEFAULT_DATE_LOCALE);
|
||||
}
|
||||
|
||||
export function beautifyExactDate(dateToBeautify: Date | string | number) {
|
||||
try {
|
||||
const parsedDate = parseDate(dateToBeautify);
|
||||
|
||||
return parsedDate.toFormat('DD · TT');
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function beautifyPastDateRelativeToNow(
|
||||
pastDate: Date | string | number,
|
||||
) {
|
||||
try {
|
||||
const parsedDate = parseDate(pastDate);
|
||||
|
||||
return formatDistanceToNow(parsedDate.toJSDate());
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function beautifyPastDateAbsolute(pastDate: Date | string | number) {
|
||||
try {
|
||||
const parsedPastDate = parseDate(pastDate);
|
||||
|
||||
const hoursDiff = parsedPastDate.diffNow('hours').negate().hours;
|
||||
|
||||
if (hoursDiff <= 24) {
|
||||
return parsedPastDate.toFormat('HH:mm');
|
||||
} else if (hoursDiff <= 7 * 24) {
|
||||
return parsedPastDate.toFormat('cccc - HH:mm');
|
||||
} else if (hoursDiff <= 365 * 24) {
|
||||
return parsedPastDate.toFormat('MMMM d - HH:mm');
|
||||
} else {
|
||||
return parsedPastDate.toFormat('dd/MM/yyyy - HH:mm');
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
3
front/src/modules/utils/logs/logError.ts
Normal file
3
front/src/modules/utils/logs/logError.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function logError(message: any) {
|
||||
console.error(message);
|
||||
}
|
||||
Reference in New Issue
Block a user