Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
@ -1,8 +1,8 @@
|
||||
import {
|
||||
DropdownButton,
|
||||
DropdownOptionType,
|
||||
} from '@/ui/components/buttons/DropdownButton';
|
||||
import { IconCheck, IconNotes } from '@/ui/icons/index';
|
||||
} from '@/ui/button/components/DropdownButton';
|
||||
import { IconCheck, IconNotes } from '@/ui/icon';
|
||||
import {
|
||||
ActivityType,
|
||||
CommentThread,
|
||||
@ -1,67 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { useBlockNote } from '@blocknote/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { GET_COMMENT_THREADS_BY_TARGETS } from '@/comments/services';
|
||||
import { BlockEditor } from '@/ui/components/editor/BlockEditor';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import {
|
||||
CommentThread,
|
||||
useUpdateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
const BlockNoteStyledContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type OwnProps = {
|
||||
commentThread: Pick<CommentThread, 'id' | 'body'>;
|
||||
onChange?: (commentThreadBody: string) => void;
|
||||
};
|
||||
|
||||
export function CommentThreadBodyEditor({ commentThread, onChange }: OwnProps) {
|
||||
const [updateCommentThreadMutation] = useUpdateCommentThreadMutation();
|
||||
|
||||
const [body, setBody] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (body) {
|
||||
onChange?.(body);
|
||||
}
|
||||
}, [body, onChange]);
|
||||
|
||||
const debounceOnChange = useMemo(() => {
|
||||
function onInternalChange(commentThreadBody: string) {
|
||||
setBody(commentThreadBody);
|
||||
updateCommentThreadMutation({
|
||||
variables: {
|
||||
id: commentThread.id,
|
||||
body: commentThreadBody,
|
||||
},
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return debounce(onInternalChange, 200);
|
||||
}, [commentThread, updateCommentThreadMutation, setBody]);
|
||||
|
||||
const editor: BlockNoteEditor | null = useBlockNote({
|
||||
initialContent: commentThread.body
|
||||
? JSON.parse(commentThread.body)
|
||||
: undefined,
|
||||
editorDOMAttributes: { class: 'editor' },
|
||||
onEditorContentChange: (editor) => {
|
||||
debounceOnChange(JSON.stringify(editor.topLevelBlocks) ?? '');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BlockNoteStyledContainer>
|
||||
<BlockEditor editor={editor} />
|
||||
</BlockNoteStyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { GET_COMMENT_THREAD } from '@/comments/services';
|
||||
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
|
||||
import { AutosizeTextInput } from '@/ui/components/inputs/AutosizeTextInput';
|
||||
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
|
||||
import { CommentThread, useCreateCommentMutation } from '~/generated/graphql';
|
||||
|
||||
import { CommentThreadItem } from '../comment/CommentThreadItem';
|
||||
|
||||
type OwnProps = {
|
||||
commentThread: Pick<CommentThread, 'id'> & {
|
||||
comments: Array<CommentForDrawer>;
|
||||
};
|
||||
};
|
||||
|
||||
const StyledThreadItemListContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
|
||||
justify-content: flex-start;
|
||||
padding: ${({ theme }) => theme.spacing(8)};
|
||||
padding-left: ${({ theme }) => theme.spacing(12)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledCommentActionBar = styled.div`
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
display: flex;
|
||||
padding: 16px 24px 16px 48px;
|
||||
width: calc(${({ theme }) => theme.rightDrawerWidth} - 48px - 24px);
|
||||
`;
|
||||
|
||||
const StyledThreadCommentTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export function CommentThreadComments({ commentThread }: OwnProps) {
|
||||
const [createCommentMutation] = useCreateCommentMutation();
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
|
||||
if (!currentUser) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function handleSendComment(commentText: string) {
|
||||
if (!isNonEmptyString(commentText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
createCommentMutation({
|
||||
variables: {
|
||||
commentId: v4(),
|
||||
authorId: currentUser?.id ?? '',
|
||||
commentThreadId: commentThread?.id ?? '',
|
||||
commentText: commentText,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
refetchQueries: [getOperationName(GET_COMMENT_THREAD) ?? ''],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{commentThread?.comments.length > 0 && (
|
||||
<>
|
||||
<StyledThreadItemListContainer>
|
||||
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
|
||||
{commentThread?.comments?.map((comment, index) => (
|
||||
<CommentThreadItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</StyledThreadItemListContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StyledCommentActionBar>
|
||||
{currentUser && <AutosizeTextInput onValidate={handleSendComment} />}
|
||||
</StyledCommentActionBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
import { Button } from '@/ui/components/buttons/Button';
|
||||
import { ButtonGroup } from '@/ui/components/buttons/ButtonGroup';
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icons/index';
|
||||
|
||||
type CommentThreadCreateButtonProps = {
|
||||
onNoteClick?: () => void;
|
||||
onTaskClick?: () => void;
|
||||
onActivityClick?: () => void;
|
||||
};
|
||||
|
||||
export function CommentThreadCreateButton({
|
||||
onNoteClick,
|
||||
onTaskClick,
|
||||
onActivityClick,
|
||||
}: CommentThreadCreateButtonProps) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<ButtonGroup variant="secondary">
|
||||
<Button
|
||||
icon={<IconNotes size={theme.icon.size.sm} />}
|
||||
title="Note"
|
||||
onClick={onNoteClick}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconCheckbox size={theme.icon.size.sm} />}
|
||||
title="Task"
|
||||
onClick={onTaskClick}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconTimelineEvent size={theme.icon.size.sm} />}
|
||||
title="Activity"
|
||||
soon={true}
|
||||
onClick={onActivityClick}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
@ -1,212 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react';
|
||||
|
||||
import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange';
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { useFilteredSearchCompanyQuery } from '@/companies/queries';
|
||||
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
|
||||
import { PersonChip } from '@/people/components/PersonChip';
|
||||
import { useFilteredSearchPeopleQuery } from '@/people/services';
|
||||
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
|
||||
import { MultipleEntitySelect } from '@/relation-picker/components/MultipleEntitySelect';
|
||||
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
|
||||
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
|
||||
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/ui/utils/flatMapAndSortEntityForSelectArrayByName';
|
||||
import {
|
||||
CommentableType,
|
||||
CommentThread,
|
||||
CommentThreadTarget,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
type OwnProps = {
|
||||
commentThread?: Pick<CommentThread, 'id'> & {
|
||||
commentThreadTargets: Array<
|
||||
Pick<CommentThreadTarget, 'id' | 'commentableId' | 'commentableType'>
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledRelationContainer = styled.div`
|
||||
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
|
||||
--vertical-padding: ${({ theme }) => theme.spacing(1.5)};
|
||||
|
||||
border: 1px solid transparent;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
|
||||
min-height: calc(32px - 2 * var(--vertical-padding));
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
padding: var(--vertical-padding) var(--horizontal-padding);
|
||||
width: calc(100% - 2 * var(--horizontal-padding));
|
||||
`;
|
||||
|
||||
const StyledMenuWrapper = styled.div`
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
const peopleIds =
|
||||
commentThread?.commentThreadTargets
|
||||
?.filter((relation) => relation.commentableType === 'Person')
|
||||
.map((relation) => relation.commentableId) ?? [];
|
||||
const companyIds =
|
||||
commentThread?.commentThreadTargets
|
||||
?.filter((relation) => relation.commentableType === 'Company')
|
||||
.map((relation) => relation.commentableId) ?? [];
|
||||
|
||||
const personsForMultiSelect = useFilteredSearchPeopleQuery({
|
||||
searchFilter,
|
||||
selectedIds: peopleIds,
|
||||
});
|
||||
|
||||
const companiesForMultiSelect = useFilteredSearchCompanyQuery({
|
||||
searchFilter,
|
||||
selectedIds: companyIds,
|
||||
});
|
||||
|
||||
const {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
} = usePreviousHotkeyScope();
|
||||
|
||||
function handleRelationContainerClick() {
|
||||
if (isMenuOpen) {
|
||||
exitEditMode();
|
||||
} else {
|
||||
setIsMenuOpen(true);
|
||||
setHotkeyScopeAndMemorizePreviousScope(
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Place in a scoped recoil atom family
|
||||
function handleFilterChange(newSearchFilter: string) {
|
||||
setSearchFilter(newSearchFilter);
|
||||
}
|
||||
|
||||
const handleCheckItemChange = useHandleCheckableCommentThreadTargetChange({
|
||||
commentThread,
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
goBackToPreviousHotkeyScope();
|
||||
setIsMenuOpen(false);
|
||||
setSearchFilter('');
|
||||
}
|
||||
|
||||
useScopedHotkeys(
|
||||
['esc', 'enter'],
|
||||
() => {
|
||||
exitEditMode();
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[exitEditMode],
|
||||
);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
strategy: 'absolute',
|
||||
middleware: [
|
||||
offset(({ rects }) => {
|
||||
return -rects.reference.height;
|
||||
}),
|
||||
flip(),
|
||||
size(),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
open: isMenuOpen,
|
||||
placement: 'bottom-start',
|
||||
});
|
||||
|
||||
useListenClickOutsideArrayOfRef([refs.floating, refs.domReference], () => {
|
||||
exitEditMode();
|
||||
});
|
||||
|
||||
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
|
||||
personsForMultiSelect.selectedEntities,
|
||||
companiesForMultiSelect.selectedEntities,
|
||||
]);
|
||||
|
||||
const filteredSelectedEntities =
|
||||
flatMapAndSortEntityForSelectArrayOfArrayByName([
|
||||
personsForMultiSelect.filteredSelectedEntities,
|
||||
companiesForMultiSelect.filteredSelectedEntities,
|
||||
]);
|
||||
|
||||
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
|
||||
personsForMultiSelect.entitiesToSelect,
|
||||
companiesForMultiSelect.entitiesToSelect,
|
||||
]);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledRelationContainer
|
||||
ref={refs.setReference}
|
||||
onClick={handleRelationContainerClick}
|
||||
>
|
||||
{selectedEntities?.map((entity) =>
|
||||
entity.entityType === CommentableType.Company ? (
|
||||
<CompanyChip
|
||||
key={entity.id}
|
||||
id={entity.id}
|
||||
name={entity.name}
|
||||
picture={entity.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonChip key={entity.id} name={entity.name} id={entity.id} />
|
||||
),
|
||||
)}
|
||||
</StyledRelationContainer>
|
||||
{isMenuOpen && (
|
||||
<RecoilScope>
|
||||
<StyledMenuWrapper ref={refs.setFloating} style={floatingStyles}>
|
||||
<MultipleEntitySelect
|
||||
entities={{
|
||||
entitiesToSelect,
|
||||
filteredSelectedEntities,
|
||||
selectedEntities,
|
||||
loading: false, // TODO implement skeleton loading
|
||||
}}
|
||||
onItemCheckChange={handleCheckItemChange}
|
||||
onSearchFilterChange={handleFilterChange}
|
||||
searchFilter={searchFilter}
|
||||
/>
|
||||
</StyledMenuWrapper>
|
||||
</RecoilScope>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
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: 'Modules/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(
|
||||
<MemoryRouter>
|
||||
<StyledContainer>
|
||||
<CommentThreadRelationPicker commentThread={mockedCommentThreads[0]} />
|
||||
</StyledContainer>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
@ -1,103 +0,0 @@
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '@/utils/datetime/date-utils';
|
||||
|
||||
type OwnProps = {
|
||||
comment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'>;
|
||||
actionBar?: React.ReactNode;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(1)});
|
||||
`;
|
||||
|
||||
const StyledLeftContainer = styled.div`
|
||||
align-items: end;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledName = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledDate = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
|
||||
box-shadow: 0px 2px 4px 3px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
box-shadow: 2px 4px 16px 6px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
|
||||
opacity: 1;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
export function CommentHeader({ comment, actionBar }: OwnProps) {
|
||||
const theme = useTheme();
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt);
|
||||
const exactCreatedAt = beautifyExactDate(comment.createdAt);
|
||||
const showDate = beautifiedCreatedAt !== '';
|
||||
|
||||
const author = comment.author;
|
||||
const authorName = author.displayName;
|
||||
const avatarUrl = author.avatarUrl;
|
||||
const commentId = comment.id;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLeftContainer>
|
||||
<Avatar
|
||||
avatarUrl={avatarUrl}
|
||||
size={theme.icon.size.md}
|
||||
colorId={author.id}
|
||||
placeholder={author.displayName}
|
||||
/>
|
||||
<StyledName>{authorName}</StyledName>
|
||||
{showDate && (
|
||||
<>
|
||||
<StyledDate id={`id-${commentId}`}>
|
||||
{beautifiedCreatedAt}
|
||||
</StyledDate>
|
||||
<StyledTooltip
|
||||
anchorSelect={`#id-${commentId}`}
|
||||
content={exactCreatedAt}
|
||||
clickable
|
||||
noArrow
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledLeftContainer>
|
||||
<div>{actionBar}</div>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
|
||||
|
||||
import { CommentHeader } from './CommentHeader';
|
||||
|
||||
type OwnProps = {
|
||||
comment: CommentForDrawer;
|
||||
actionBar?: React.ReactNode;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledCommentBody = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
padding-left: 24px;
|
||||
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
export function CommentThreadItem({ comment, actionBar }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<CommentHeader comment={comment} actionBar={actionBar} />
|
||||
<StyledCommentBody>{comment.body}</StyledCommentBody>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { CommentForDrawer } from '@/comments/types/CommentForDrawer';
|
||||
import { mockedUsersData } from '~/testing/mock-data/users';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { CommentThreadActionBar } from '../../right-drawer/CommentThreadActionBar';
|
||||
import { CommentHeader } from '../CommentHeader';
|
||||
|
||||
const meta: Meta<typeof CommentHeader> = {
|
||||
title: 'Modules/Comments/CommentHeader',
|
||||
component: CommentHeader,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CommentHeader>;
|
||||
|
||||
const mockUser = mockedUsersData[0];
|
||||
|
||||
const mockComment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'> = {
|
||||
id: 'fake_comment_1_uuid',
|
||||
author: {
|
||||
id: 'fake_comment_1_author_uuid',
|
||||
displayName: mockUser.displayName ?? '',
|
||||
firstName: mockUser.firstName ?? '',
|
||||
lastName: mockUser.lastName ?? '',
|
||||
avatarUrl: mockUser.avatarUrl,
|
||||
},
|
||||
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
|
||||
};
|
||||
|
||||
const mockCommentWithLongName: Pick<
|
||||
CommentForDrawer,
|
||||
'id' | 'author' | 'createdAt'
|
||||
> = {
|
||||
id: 'fake_comment_2_uuid',
|
||||
author: {
|
||||
id: 'fake_comment_2_author_uuid',
|
||||
displayName: mockUser.displayName + ' with a very long suffix' ?? '',
|
||||
firstName: mockUser.firstName ?? '',
|
||||
lastName: mockUser.lastName ?? '',
|
||||
avatarUrl: mockUser.avatarUrl,
|
||||
},
|
||||
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewDaysAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewMonthsAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
createdAt: DateTime.now().minus({ months: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const FewYearsAgo: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
createdAt: DateTime.now().minus({ years: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const WithoutAvatar: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
author: {
|
||||
...mockComment.author,
|
||||
avatarUrl: '',
|
||||
},
|
||||
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLongUserName: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockCommentWithLongName,
|
||||
author: {
|
||||
...mockCommentWithLongName.author,
|
||||
avatarUrl: '',
|
||||
},
|
||||
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
export const WithActionBar: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<CommentHeader
|
||||
comment={{
|
||||
...mockComment,
|
||||
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
|
||||
}}
|
||||
actionBar={<CommentThreadActionBar commentThreadId="test-id" />}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
@ -1,190 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { GET_COMMENT_THREAD } from '@/comments/services';
|
||||
import { PropertyBox } from '@/ui/components/property-box/PropertyBox';
|
||||
import { PropertyBoxItem } from '@/ui/components/property-box/PropertyBoxItem';
|
||||
import { IconArrowUpRight } from '@/ui/icons/index';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import {
|
||||
useGetCommentThreadQuery,
|
||||
useUpdateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { CommentThreadBodyEditor } from '../comment-thread/CommentThreadBodyEditor';
|
||||
import { CommentThreadComments } from '../comment-thread/CommentThreadComments';
|
||||
import { CommentThreadRelationPicker } from '../comment-thread/CommentThreadRelationPicker';
|
||||
import { CommentThreadTypeDropdown } from '../comment-thread/CommentThreadTypeDropdown';
|
||||
|
||||
import { CommentThreadActionBar } from './CommentThreadActionBar';
|
||||
|
||||
import '@blocknote/core/style.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledUpperPartContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
const StyledTopContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px 24px 24px 48px;
|
||||
`;
|
||||
|
||||
const StyledEditableTitleInput = styled.input`
|
||||
background: transparent;
|
||||
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
|
||||
flex-direction: column;
|
||||
font-family: Inter;
|
||||
font-size: ${({ theme }) => theme.font.size.xl};
|
||||
font-style: normal;
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
justify-content: center;
|
||||
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
outline: none;
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(2)});
|
||||
&::placeholder {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTopActionsContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type OwnProps = {
|
||||
commentThreadId: string;
|
||||
showComment?: boolean;
|
||||
autoFillTitle?: boolean;
|
||||
};
|
||||
|
||||
export function CommentThread({
|
||||
commentThreadId,
|
||||
showComment = true,
|
||||
autoFillTitle = false,
|
||||
}: OwnProps) {
|
||||
const { data } = useGetCommentThreadQuery({
|
||||
variables: {
|
||||
commentThreadId: commentThreadId ?? '',
|
||||
},
|
||||
skip: !commentThreadId,
|
||||
});
|
||||
const commentThread = data?.findManyCommentThreads[0];
|
||||
|
||||
const [title, setTitle] = useState<string | null | undefined>(undefined);
|
||||
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [updateCommentThreadMutation] = useUpdateCommentThreadMutation();
|
||||
|
||||
const debounceUpdateTitle = useMemo(() => {
|
||||
function updateTitle(title: string) {
|
||||
updateCommentThreadMutation({
|
||||
variables: {
|
||||
id: commentThreadId,
|
||||
title: title ?? '',
|
||||
},
|
||||
refetchQueries: [getOperationName(GET_COMMENT_THREAD) ?? ''],
|
||||
});
|
||||
}
|
||||
return debounce(updateTitle, 200);
|
||||
}, [commentThreadId, updateCommentThreadMutation]);
|
||||
|
||||
function updateTitleFromBody(body: string) {
|
||||
const parsedTitle = JSON.parse(body)[0]?.content[0]?.text;
|
||||
if (!hasUserManuallySetTitle && autoFillTitle) {
|
||||
setTitle(parsedTitle);
|
||||
debounceUpdateTitle(parsedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (commentThread && !title) {
|
||||
setTitle(commentThread?.title ?? '');
|
||||
}
|
||||
}, [commentThread, title]);
|
||||
|
||||
if (!commentThread) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledUpperPartContainer>
|
||||
<StyledTopContainer>
|
||||
<StyledTopActionsContainer>
|
||||
<CommentThreadTypeDropdown commentThread={commentThread} />
|
||||
<CommentThreadActionBar commentThreadId={commentThread?.id ?? ''} />
|
||||
</StyledTopActionsContainer>
|
||||
<StyledEditableTitleInput
|
||||
placeholder={`${commentThread.type} title (optional)`}
|
||||
onChange={(event) => {
|
||||
setHasUserManuallySetTitle(true);
|
||||
setTitle(event.target.value);
|
||||
debounceUpdateTitle(event.target.value);
|
||||
}}
|
||||
value={title ?? ''}
|
||||
/>
|
||||
<PropertyBox>
|
||||
<PropertyBoxItem
|
||||
icon={<IconArrowUpRight />}
|
||||
value={
|
||||
<CommentThreadRelationPicker
|
||||
commentThread={{
|
||||
id: commentThread.id,
|
||||
commentThreadTargets:
|
||||
commentThread.commentThreadTargets ?? [],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Relations"
|
||||
/>
|
||||
</PropertyBox>
|
||||
</StyledTopContainer>
|
||||
<CommentThreadBodyEditor
|
||||
commentThread={commentThread}
|
||||
onChange={updateTitleFromBody}
|
||||
/>
|
||||
</StyledUpperPartContainer>
|
||||
|
||||
{showComment && (
|
||||
<CommentThreadComments
|
||||
commentThread={{
|
||||
id: commentThread.id,
|
||||
comments: commentThread.comments ?? [],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { GET_COMMENT_THREADS_BY_TARGETS } from '@/comments/services';
|
||||
import { GET_COMPANIES } from '@/companies/queries';
|
||||
import { GET_PEOPLE } from '@/people/services';
|
||||
import { Button } from '@/ui/components/buttons/Button';
|
||||
import { IconTrash } from '@/ui/icons';
|
||||
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
|
||||
import { useDeleteCommentThreadMutation } from '~/generated/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
type OwnProps = {
|
||||
commentThreadId: string;
|
||||
};
|
||||
|
||||
export function CommentThreadActionBar({ commentThreadId }: OwnProps) {
|
||||
const theme = useTheme();
|
||||
const [createCommentMutation] = useDeleteCommentThreadMutation();
|
||||
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
|
||||
|
||||
function deleteCommentThread() {
|
||||
createCommentMutation({
|
||||
variables: { commentThreadId },
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMPANIES) ?? '',
|
||||
getOperationName(GET_PEOPLE) ?? '',
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
});
|
||||
setIsRightDrawerOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Button
|
||||
icon={
|
||||
<IconTrash size={theme.icon.size.sm} stroke={theme.icon.stroke.md} />
|
||||
}
|
||||
onClick={deleteCommentThread}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { commentableEntityArrayState } from '@/comments/states/commentableEntityArrayState';
|
||||
import { RightDrawerBody } from '@/ui/layout/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/layout/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
|
||||
|
||||
import { Timeline } from '../timeline/Timeline';
|
||||
|
||||
export function RightDrawerTimeline() {
|
||||
const [commentableEntityArray] = useRecoilState(commentableEntityArrayState);
|
||||
|
||||
return (
|
||||
<RightDrawerPage>
|
||||
<RightDrawerTopBar />
|
||||
<RightDrawerBody>
|
||||
{commentableEntityArray.map((commentableEntity) => (
|
||||
<Timeline
|
||||
key={commentableEntity.id}
|
||||
entity={{
|
||||
id: commentableEntity?.id ?? '',
|
||||
type: commentableEntity.type,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RightDrawerBody>
|
||||
</RightDrawerPage>
|
||||
);
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { viewableCommentThreadIdState } from '@/comments/states/viewableCommentThreadIdState';
|
||||
import { RightDrawerBody } from '@/ui/layout/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/layout/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
|
||||
|
||||
import { CommentThread } from '../CommentThread';
|
||||
|
||||
export function RightDrawerCreateCommentThread() {
|
||||
const commentThreadId = useRecoilValue(viewableCommentThreadIdState);
|
||||
|
||||
return (
|
||||
<RightDrawerPage>
|
||||
<RightDrawerTopBar />
|
||||
<RightDrawerBody>
|
||||
{commentThreadId && (
|
||||
<CommentThread
|
||||
commentThreadId={commentThreadId}
|
||||
showComment={false}
|
||||
autoFillTitle={true}
|
||||
/>
|
||||
)}
|
||||
</RightDrawerBody>
|
||||
</RightDrawerPage>
|
||||
);
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { viewableCommentThreadIdState } from '@/comments/states/viewableCommentThreadIdState';
|
||||
import { RightDrawerBody } from '@/ui/layout/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/layout/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
|
||||
|
||||
import { CommentThread } from '../CommentThread';
|
||||
|
||||
export function RightDrawerEditCommentThread() {
|
||||
const commentThreadId = useRecoilValue(viewableCommentThreadIdState);
|
||||
|
||||
return (
|
||||
<RightDrawerPage>
|
||||
<RightDrawerTopBar />
|
||||
<RightDrawerBody>
|
||||
{commentThreadId && <CommentThread commentThreadId={commentThreadId} />}
|
||||
</RightDrawerBody>
|
||||
</RightDrawerPage>
|
||||
);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentChip, CommentChipProps } from './CommentChip';
|
||||
|
||||
// TODO: tie those fixed values to the other components in the cell
|
||||
const StyledCellWrapper = styled.div``;
|
||||
|
||||
export function CellCommentChip(props: CommentChipProps) {
|
||||
if (props.count === 0) return null;
|
||||
|
||||
return (
|
||||
<StyledCellWrapper>
|
||||
<CommentChip {...props} />
|
||||
</StyledCellWrapper>
|
||||
);
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComment } from '@/ui/icons';
|
||||
|
||||
export type CommentChipProps = {
|
||||
count: number;
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
background: ${({ theme }) => theme.background.transparent.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
|
||||
height: 26px;
|
||||
justify-content: center;
|
||||
|
||||
max-width: 42px;
|
||||
|
||||
padding-left: 4px;
|
||||
|
||||
padding-right: 4px;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledCount = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export function CommentChip({ count, onClick }: CommentChipProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (count === 0) return null;
|
||||
const formattedCount = count > 99 ? '99+' : count;
|
||||
|
||||
return (
|
||||
<StyledChip data-testid="comment-chip" onClick={onClick}>
|
||||
<StyledCount>{formattedCount}</StyledCount>
|
||||
<IconComment size={theme.icon.size.md} />
|
||||
</StyledChip>
|
||||
);
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { CellCommentChip } from '../CellCommentChip';
|
||||
import { CommentChip } from '../CommentChip';
|
||||
|
||||
const meta: Meta<typeof CellCommentChip> = {
|
||||
title: 'Modules/Comments/CellCommentChip',
|
||||
component: CellCommentChip,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CellCommentChip>;
|
||||
|
||||
const TestCellContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
display: flex;
|
||||
|
||||
height: fit-content;
|
||||
justify-content: space-between;
|
||||
|
||||
max-width: 250px;
|
||||
|
||||
min-width: 250px;
|
||||
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
const StyledFakeCellText = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const OneComment: Story = {
|
||||
render: getRenderWrapperForComponent(<CommentChip count={1} />),
|
||||
};
|
||||
|
||||
export const TenComments: Story = {
|
||||
render: getRenderWrapperForComponent(<CommentChip count={10} />),
|
||||
};
|
||||
|
||||
export const TooManyComments: Story = {
|
||||
render: getRenderWrapperForComponent(<CommentChip count={1000} />),
|
||||
};
|
||||
|
||||
export const InCellDefault: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<StyledFakeCellText>Fake short text</StyledFakeCellText>
|
||||
<CellCommentChip count={12} />
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
|
||||
export const InCellOverlappingBlur: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<StyledFakeCellText>
|
||||
Fake long text to demonstrate ellipsis
|
||||
</StyledFakeCellText>
|
||||
<CellCommentChip count={12} />
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
@ -1,297 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useOpenCommentThreadRightDrawer } from '@/comments/hooks/useOpenCommentThreadRightDrawer';
|
||||
import { useOpenCreateCommentThreadDrawer } from '@/comments/hooks/useOpenCreateCommentThreadDrawer';
|
||||
import { CommentableEntity } from '@/comments/types/CommentableEntity';
|
||||
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
|
||||
import { IconNotes } from '@/ui/icons/index';
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '@/utils/datetime/date-utils';
|
||||
import {
|
||||
ActivityType,
|
||||
SortOrder,
|
||||
useGetCommentThreadsByTargetsQuery,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { CommentThreadCreateButton } from '../comment-thread/CommentThreadCreateButton';
|
||||
|
||||
const StyledMainContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledTimelineContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 12px 16px;
|
||||
`;
|
||||
|
||||
const StyledTimelineEmptyContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledEmptyTimelineTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.xxl};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
`;
|
||||
|
||||
const StyledEmptyTimelineSubTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.extraLight};
|
||||
font-size: ${({ theme }) => theme.font.size.xxl};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTimelineItemContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledItemTitleContainer = styled.div`
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
span {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledItemTitleDate = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const StyledVerticalLineContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledVerticalLine = styled.div`
|
||||
align-self: stretch;
|
||||
background: ${({ theme }) => theme.border.color.light};
|
||||
flex-shrink: 0;
|
||||
width: 2px;
|
||||
`;
|
||||
|
||||
const StyledCardContainer = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0px 20px 0px;
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const StyledCardTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
`;
|
||||
|
||||
const StyledCardContent = styled.div`
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
-webkit-line-clamp: 3;
|
||||
align-self: stretch;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
|
||||
box-shadow: 0px 2px 4px 3px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
box-shadow: 2px 4px 16px 6px
|
||||
${({ theme }) => theme.background.transparent.light};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
|
||||
opacity: 1;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const StyledTopActionBar = styled.div`
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
backdrop-filter: blur(5px);
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-top-right-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0px;
|
||||
padding: 12px 16px 12px 16px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
`;
|
||||
|
||||
export function Timeline({ entity }: { entity: CommentableEntity }) {
|
||||
const { data: queryResult, loading } = useGetCommentThreadsByTargetsQuery({
|
||||
variables: {
|
||||
commentThreadTargetIds: [entity.id],
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: SortOrder.Desc,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const openCommentThreadRightDrawer = useOpenCommentThreadRightDrawer();
|
||||
|
||||
const openCreateCommandThread = useOpenCreateCommentThreadDrawer();
|
||||
|
||||
const commentThreads: CommentThreadForDrawer[] =
|
||||
queryResult?.findManyCommentThreads ?? [];
|
||||
|
||||
if (loading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!commentThreads.length) {
|
||||
return (
|
||||
<StyledTimelineEmptyContainer>
|
||||
<StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle>
|
||||
<StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle>
|
||||
<CommentThreadCreateButton
|
||||
onNoteClick={() => openCreateCommandThread(entity, ActivityType.Note)}
|
||||
onTaskClick={() => openCreateCommandThread(entity, ActivityType.Task)}
|
||||
/>
|
||||
</StyledTimelineEmptyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledMainContainer>
|
||||
<StyledTopActionBar>
|
||||
<StyledTimelineItemContainer>
|
||||
<CommentThreadCreateButton
|
||||
onNoteClick={() =>
|
||||
openCreateCommandThread(entity, ActivityType.Note)
|
||||
}
|
||||
onTaskClick={() =>
|
||||
openCreateCommandThread(entity, ActivityType.Task)
|
||||
}
|
||||
/>
|
||||
</StyledTimelineItemContainer>
|
||||
</StyledTopActionBar>
|
||||
<StyledTimelineContainer>
|
||||
{commentThreads.map((commentThread) => {
|
||||
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(
|
||||
commentThread.createdAt,
|
||||
);
|
||||
const exactCreatedAt = beautifyExactDate(commentThread.createdAt);
|
||||
const body = JSON.parse(commentThread.body ?? '{}')[0]?.content[0]
|
||||
?.text;
|
||||
|
||||
return (
|
||||
<React.Fragment key={commentThread.id}>
|
||||
<StyledTimelineItemContainer>
|
||||
<StyledIconContainer>
|
||||
<IconNotes />
|
||||
</StyledIconContainer>
|
||||
<StyledItemTitleContainer>
|
||||
<span>
|
||||
{commentThread.author.firstName}{' '}
|
||||
{commentThread.author.lastName}
|
||||
</span>
|
||||
created a note
|
||||
</StyledItemTitleContainer>
|
||||
<StyledItemTitleDate id={`id-${commentThread.id}`}>
|
||||
{beautifiedCreatedAt} ago
|
||||
</StyledItemTitleDate>
|
||||
<StyledTooltip
|
||||
anchorSelect={`#id-${commentThread.id}`}
|
||||
content={exactCreatedAt}
|
||||
clickable
|
||||
noArrow
|
||||
/>
|
||||
</StyledTimelineItemContainer>
|
||||
<StyledTimelineItemContainer>
|
||||
<StyledVerticalLineContainer>
|
||||
<StyledVerticalLine></StyledVerticalLine>
|
||||
</StyledVerticalLineContainer>
|
||||
<StyledCardContainer>
|
||||
<StyledCard
|
||||
onClick={() =>
|
||||
openCommentThreadRightDrawer(commentThread.id)
|
||||
}
|
||||
>
|
||||
<StyledCardTitle>
|
||||
{commentThread.title ? commentThread.title : '(No title)'}
|
||||
</StyledCardTitle>
|
||||
<StyledCardContent>
|
||||
{body ? body : '(No content)'}
|
||||
</StyledCardContent>
|
||||
</StyledCard>
|
||||
</StyledCardContainer>
|
||||
</StyledTimelineItemContainer>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</StyledTimelineContainer>
|
||||
</StyledMainContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user