Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
104
front/src/modules/activities/comment/CommentHeader.tsx
Normal file
104
front/src/modules/activities/comment/CommentHeader.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '~/utils/date-utils';
|
||||
|
||||
import { CommentForDrawer } from '../types/CommentForDrawer';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
40
front/src/modules/activities/comment/CommentThreadItem.tsx
Normal file
40
front/src/modules/activities/comment/CommentThreadItem.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentForDrawer } from '../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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { CommentThreadActionBar } from '@/activities/right-drawer/components/CommentThreadActionBar';
|
||||
import { CommentForDrawer } from '@/activities/types/CommentForDrawer';
|
||||
import { mockedUsersData } from '~/testing/mock-data/users';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
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" />}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,68 @@
|
||||
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 debounce from 'lodash.debounce';
|
||||
|
||||
import { BlockEditor } from '@/ui/editor/components/BlockEditor';
|
||||
import {
|
||||
CommentThread,
|
||||
useUpdateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { GET_COMMENT_THREADS_BY_TARGETS } from '../queries/select';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
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 { AutosizeTextInput } from '@/ui/input/components/AutosizeTextInput';
|
||||
import { CommentThread, useCreateCommentMutation } from '~/generated/graphql';
|
||||
import { isNonEmptyString } from '~/utils/isNonEmptyString';
|
||||
|
||||
import { CommentThreadItem } from '../comment/CommentThreadItem';
|
||||
import { GET_COMMENT_THREAD } from '../queries';
|
||||
import { CommentForDrawer } from '../types/CommentForDrawer';
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
import { Button } from '@/ui/button/components/Button';
|
||||
import { ButtonGroup } from '@/ui/button/components/ButtonGroup';
|
||||
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/icon/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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react';
|
||||
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { useFilteredSearchCompanyQuery } from '@/companies/queries';
|
||||
import { PersonChip } from '@/people/components/PersonChip';
|
||||
import { useFilteredSearchPeopleQuery } from '@/people/queries';
|
||||
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
|
||||
import { usePreviousHotkeyScope } from '@/ui/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
|
||||
import { MultipleEntitySelect } from '@/ui/relation-picker/components/MultipleEntitySelect';
|
||||
import { RelationPickerHotkeyScope } from '@/ui/relation-picker/types/RelationPickerHotkeyScope';
|
||||
import {
|
||||
CommentableType,
|
||||
CommentThread,
|
||||
CommentThreadTarget,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { useHandleCheckableCommentThreadTargetChange } from '../hooks/useHandleCheckableCommentThreadTargetChange';
|
||||
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '../utils/flatMapAndSortEntityForSelectArrayByName';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import {
|
||||
DropdownButton,
|
||||
DropdownOptionType,
|
||||
} from '@/ui/button/components/DropdownButton';
|
||||
import { IconCheck, IconNotes } from '@/ui/icon/index';
|
||||
import {
|
||||
ActivityType,
|
||||
CommentThread,
|
||||
useUpdateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
type OwnProps = {
|
||||
commentThread: Pick<CommentThread, 'id' | 'type'>;
|
||||
};
|
||||
|
||||
export function CommentThreadTypeDropdown({ commentThread }: OwnProps) {
|
||||
const [updateCommentThreadMutation] = useUpdateCommentThreadMutation();
|
||||
const options: DropdownOptionType[] = [
|
||||
{ label: 'Note', key: 'note', icon: <IconNotes /> },
|
||||
{ label: 'Task', key: 'task', icon: <IconCheck /> },
|
||||
];
|
||||
|
||||
function getSelectedOptionKey() {
|
||||
if (commentThread.type === ActivityType.Note) {
|
||||
return 'note';
|
||||
} else if (commentThread.type === ActivityType.Task) {
|
||||
return 'task';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const convertSelectionOptionKeyToActivityType = (key: string) => {
|
||||
switch (key) {
|
||||
case 'note':
|
||||
return ActivityType.Note;
|
||||
case 'task':
|
||||
return ActivityType.Task;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (selectedOption: DropdownOptionType) => {
|
||||
updateCommentThreadMutation({
|
||||
variables: {
|
||||
id: commentThread.id,
|
||||
type: convertSelectionOptionKeyToActivityType(selectedOption.key),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
options={options}
|
||||
onSelection={handleSelect}
|
||||
selectedOptionKey={getSelectedOptionKey()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
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>,
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { GET_COMPANIES } from '@/companies/queries';
|
||||
import { GET_PEOPLE } from '@/people/queries';
|
||||
import {
|
||||
CommentThread,
|
||||
CommentThreadTarget,
|
||||
useAddCommentThreadTargetOnCommentThreadMutation,
|
||||
useRemoveCommentThreadTargetOnCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { GET_COMMENT_THREADS_BY_TARGETS } from '../queries';
|
||||
import { CommentableEntityForSelect } from '../types/CommentableEntityForSelect';
|
||||
|
||||
export function useHandleCheckableCommentThreadTargetChange({
|
||||
commentThread,
|
||||
}: {
|
||||
commentThread?: Pick<CommentThread, 'id'> & {
|
||||
commentThreadTargets: Array<
|
||||
Pick<CommentThreadTarget, 'id' | 'commentableId'>
|
||||
>;
|
||||
};
|
||||
}) {
|
||||
const [addCommentThreadTargetOnCommentThread] =
|
||||
useAddCommentThreadTargetOnCommentThreadMutation({
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMPANIES) ?? '',
|
||||
getOperationName(GET_PEOPLE) ?? '',
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
});
|
||||
|
||||
const [removeCommentThreadTargetOnCommentThread] =
|
||||
useRemoveCommentThreadTargetOnCommentThreadMutation({
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMPANIES) ?? '',
|
||||
getOperationName(GET_PEOPLE) ?? '',
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
});
|
||||
|
||||
return function handleCheckItemChange(
|
||||
newCheckedValue: boolean,
|
||||
entity: CommentableEntityForSelect,
|
||||
) {
|
||||
if (!commentThread) {
|
||||
return;
|
||||
}
|
||||
if (newCheckedValue) {
|
||||
addCommentThreadTargetOnCommentThread({
|
||||
variables: {
|
||||
commentableEntityId: entity.id,
|
||||
commentableEntityType: entity.entityType,
|
||||
commentThreadId: commentThread.id,
|
||||
commentThreadTargetCreationDate: new Date().toISOString(),
|
||||
commentThreadTargetId: v4(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const foundCorrespondingTarget = commentThread.commentThreadTargets?.find(
|
||||
(target) => target.commentableId === entity.id,
|
||||
);
|
||||
|
||||
if (foundCorrespondingTarget) {
|
||||
removeCommentThreadTargetOnCommentThread({
|
||||
variables: {
|
||||
commentThreadId: commentThread.id,
|
||||
commentThreadTargetId: foundCorrespondingTarget.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useOpenRightDrawer } from '@/ui/right-drawer/hooks/useOpenRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/right-drawer/types/RightDrawerPages';
|
||||
|
||||
import { viewableCommentThreadIdState } from '../states/viewableCommentThreadIdState';
|
||||
|
||||
export function useOpenCommentThreadRightDrawer() {
|
||||
const openRightDrawer = useOpenRightDrawer();
|
||||
const [, setViewableCommentThreadId] = useRecoilState(
|
||||
viewableCommentThreadIdState,
|
||||
);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
return function openCommentThreadRightDrawer(commentThreadId: string) {
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
setViewableCommentThreadId(commentThreadId);
|
||||
openRightDrawer(RightDrawerPages.EditCommentThread);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { getOperationName } from '@apollo/client/utilities/graphql/getFromAST';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { GET_COMPANIES } from '@/companies/queries';
|
||||
import { GET_PEOPLE } from '@/people/queries';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useOpenRightDrawer } from '@/ui/right-drawer/hooks/useOpenRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/right-drawer/types/RightDrawerPages';
|
||||
import { selectedRowIdsSelector } from '@/ui/table/states/selectedRowIdsSelector';
|
||||
import {
|
||||
ActivityType,
|
||||
CommentableType,
|
||||
useCreateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { GET_COMMENT_THREAD, GET_COMMENT_THREADS_BY_TARGETS } from '../queries';
|
||||
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
|
||||
import { viewableCommentThreadIdState } from '../states/viewableCommentThreadIdState';
|
||||
import { CommentableEntity } from '../types/CommentableEntity';
|
||||
|
||||
export function useOpenCreateCommentThreadDrawerForSelectedRowIds() {
|
||||
const openRightDrawer = useOpenRightDrawer();
|
||||
const [createCommentThreadMutation] = useCreateCommentThreadMutation();
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const [, setViewableCommentThreadId] = useRecoilState(
|
||||
viewableCommentThreadIdState,
|
||||
);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const [, setCommentableEntityArray] = useRecoilState(
|
||||
commentableEntityArrayState,
|
||||
);
|
||||
|
||||
const selectedEntityIds = useRecoilValue(selectedRowIdsSelector);
|
||||
|
||||
return function openCreateCommentDrawerForSelectedRowIds(
|
||||
entityType: CommentableType,
|
||||
) {
|
||||
const commentableEntityArray: CommentableEntity[] = selectedEntityIds.map(
|
||||
(id) => ({
|
||||
type: entityType,
|
||||
id,
|
||||
}),
|
||||
);
|
||||
|
||||
createCommentThreadMutation({
|
||||
variables: {
|
||||
authorId: currentUser?.id ?? '',
|
||||
commentThreadId: v4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
type: ActivityType.Note,
|
||||
commentThreadTargetArray: commentableEntityArray.map((entity) => ({
|
||||
commentableId: entity.id,
|
||||
commentableType: entity.type,
|
||||
id: v4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
})),
|
||||
},
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMPANIES) ?? '',
|
||||
getOperationName(GET_PEOPLE) ?? '',
|
||||
getOperationName(GET_COMMENT_THREAD) ?? '',
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
onCompleted(data) {
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
setViewableCommentThreadId(data.createOneCommentThread.id);
|
||||
setCommentableEntityArray(commentableEntityArray);
|
||||
openRightDrawer(RightDrawerPages.CreateCommentThread);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { GET_COMPANIES } from '@/companies/queries';
|
||||
import { GET_PEOPLE } from '@/people/queries';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useOpenRightDrawer } from '@/ui/right-drawer/hooks/useOpenRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/right-drawer/types/RightDrawerPages';
|
||||
import {
|
||||
ActivityType,
|
||||
useCreateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import { GET_COMMENT_THREAD, GET_COMMENT_THREADS_BY_TARGETS } from '../queries';
|
||||
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
|
||||
import { viewableCommentThreadIdState } from '../states/viewableCommentThreadIdState';
|
||||
import { CommentableEntity } from '../types/CommentableEntity';
|
||||
|
||||
export function useOpenCreateCommentThreadDrawer() {
|
||||
const openRightDrawer = useOpenRightDrawer();
|
||||
const [createCommentThreadMutation] = useCreateCommentThreadMutation();
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const [, setCommentableEntityArray] = useRecoilState(
|
||||
commentableEntityArrayState,
|
||||
);
|
||||
const [, setViewableCommentThreadId] = useRecoilState(
|
||||
viewableCommentThreadIdState,
|
||||
);
|
||||
|
||||
return function openCreateCommentThreadDrawer(
|
||||
entity: CommentableEntity,
|
||||
type: ActivityType,
|
||||
) {
|
||||
createCommentThreadMutation({
|
||||
variables: {
|
||||
authorId: currentUser?.id ?? '',
|
||||
commentThreadId: v4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
type: type,
|
||||
commentThreadTargetArray: [
|
||||
{
|
||||
commentableId: entity.id,
|
||||
commentableType: entity.type,
|
||||
id: v4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
refetchQueries: [
|
||||
getOperationName(GET_COMPANIES) ?? '',
|
||||
getOperationName(GET_PEOPLE) ?? '',
|
||||
getOperationName(GET_COMMENT_THREAD) ?? '',
|
||||
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
|
||||
],
|
||||
onCompleted(data) {
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
setViewableCommentThreadId(data.createOneCommentThread.id);
|
||||
setCommentableEntityArray([entity]);
|
||||
openRightDrawer(RightDrawerPages.CreateCommentThread);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useOpenRightDrawer } from '@/ui/right-drawer/hooks/useOpenRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/right-drawer/types/RightDrawerPages';
|
||||
|
||||
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
|
||||
import { CommentableEntity } from '../types/CommentableEntity';
|
||||
|
||||
// TODO: refactor with recoil callback to avoid rerender
|
||||
export function useOpenTimelineRightDrawer() {
|
||||
const openRightDrawer = useOpenRightDrawer();
|
||||
const [, setCommentableEntityArray] = useRecoilState(
|
||||
commentableEntityArrayState,
|
||||
);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
return function openTimelineRightDrawer(
|
||||
commentableEntityArray: CommentableEntity[],
|
||||
) {
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
setCommentableEntityArray(commentableEntityArray);
|
||||
openRightDrawer(RightDrawerPages.Timeline);
|
||||
};
|
||||
}
|
||||
83
front/src/modules/activities/queries/create.ts
Normal file
83
front/src/modules/activities/queries/create.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_COMMENT = gql`
|
||||
mutation CreateComment(
|
||||
$commentId: String!
|
||||
$commentText: String!
|
||||
$authorId: String!
|
||||
$commentThreadId: String!
|
||||
$createdAt: DateTime!
|
||||
) {
|
||||
createOneComment(
|
||||
data: {
|
||||
id: $commentId
|
||||
createdAt: $createdAt
|
||||
body: $commentText
|
||||
author: { connect: { id: $authorId } }
|
||||
commentThread: { connect: { id: $commentThreadId } }
|
||||
}
|
||||
) {
|
||||
id
|
||||
createdAt
|
||||
body
|
||||
author {
|
||||
id
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
commentThreadId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CREATE_COMMENT_THREAD_WITH_COMMENT = gql`
|
||||
mutation CreateCommentThread(
|
||||
$commentThreadId: String!
|
||||
$body: String
|
||||
$title: String
|
||||
$type: ActivityType!
|
||||
$authorId: String!
|
||||
$createdAt: DateTime!
|
||||
$commentThreadTargetArray: [CommentThreadTargetCreateManyCommentThreadInput!]!
|
||||
) {
|
||||
createOneCommentThread(
|
||||
data: {
|
||||
id: $commentThreadId
|
||||
createdAt: $createdAt
|
||||
updatedAt: $createdAt
|
||||
author: { connect: { id: $authorId } }
|
||||
body: $body
|
||||
title: $title
|
||||
type: $type
|
||||
commentThreadTargets: {
|
||||
createMany: { data: $commentThreadTargetArray, skipDuplicates: true }
|
||||
}
|
||||
}
|
||||
) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
authorId
|
||||
type
|
||||
commentThreadTargets {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
commentThreadId
|
||||
commentableType
|
||||
commentableId
|
||||
}
|
||||
comments {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
body
|
||||
author {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
1
front/src/modules/activities/queries/index.ts
Normal file
1
front/src/modules/activities/queries/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './select';
|
||||
81
front/src/modules/activities/queries/select.ts
Normal file
81
front/src/modules/activities/queries/select.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_COMMENT_THREADS_BY_TARGETS = gql`
|
||||
query GetCommentThreadsByTargets(
|
||||
$commentThreadTargetIds: [String!]!
|
||||
$orderBy: [CommentThreadOrderByWithRelationInput!]
|
||||
) {
|
||||
findManyCommentThreads(
|
||||
orderBy: $orderBy
|
||||
where: {
|
||||
commentThreadTargets: {
|
||||
some: { commentableId: { in: $commentThreadTargetIds } }
|
||||
}
|
||||
}
|
||||
) {
|
||||
id
|
||||
createdAt
|
||||
title
|
||||
body
|
||||
type
|
||||
author {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
comments {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
author {
|
||||
id
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
commentThreadTargets {
|
||||
id
|
||||
commentableId
|
||||
commentableType
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_COMMENT_THREAD = gql`
|
||||
query GetCommentThread($commentThreadId: String!) {
|
||||
findManyCommentThreads(where: { id: { equals: $commentThreadId } }) {
|
||||
id
|
||||
createdAt
|
||||
body
|
||||
title
|
||||
type
|
||||
author {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
comments {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
author {
|
||||
id
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
commentThreadTargets {
|
||||
id
|
||||
commentableId
|
||||
commentableType
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
93
front/src/modules/activities/queries/update.ts
Normal file
93
front/src/modules/activities/queries/update.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ADD_COMMENT_THREAD_TARGET = gql`
|
||||
mutation AddCommentThreadTargetOnCommentThread(
|
||||
$commentThreadId: String!
|
||||
$commentThreadTargetCreationDate: DateTime!
|
||||
$commentThreadTargetId: String!
|
||||
$commentableEntityId: String!
|
||||
$commentableEntityType: CommentableType!
|
||||
) {
|
||||
updateOneCommentThread(
|
||||
where: { id: $commentThreadId }
|
||||
data: {
|
||||
commentThreadTargets: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
id: $commentThreadTargetId
|
||||
createdAt: $commentThreadTargetCreationDate
|
||||
commentableType: $commentableEntityType
|
||||
commentableId: $commentableEntityId
|
||||
}
|
||||
where: { id: $commentThreadTargetId }
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
commentThreadTargets {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
commentableType
|
||||
commentableId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REMOVE_COMMENT_THREAD_TARGET = gql`
|
||||
mutation RemoveCommentThreadTargetOnCommentThread(
|
||||
$commentThreadId: String!
|
||||
$commentThreadTargetId: String!
|
||||
) {
|
||||
updateOneCommentThread(
|
||||
where: { id: $commentThreadId }
|
||||
data: { commentThreadTargets: { delete: { id: $commentThreadTargetId } } }
|
||||
) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
commentThreadTargets {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
commentableType
|
||||
commentableId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_COMMENT_THREAD = gql`
|
||||
mutation DeleteCommentThread($commentThreadId: String!) {
|
||||
deleteManyCommentThreads(where: { id: { equals: $commentThreadId } }) {
|
||||
count
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_COMMENT_THREAD = gql`
|
||||
mutation UpdateCommentThread(
|
||||
$id: String!
|
||||
$body: String
|
||||
$title: String
|
||||
$type: ActivityType
|
||||
) {
|
||||
updateOneCommentThread(
|
||||
where: { id: $id }
|
||||
data: {
|
||||
body: { set: $body }
|
||||
title: { set: $title }
|
||||
type: { set: $type }
|
||||
}
|
||||
) {
|
||||
id
|
||||
body
|
||||
title
|
||||
type
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentThreadBodyEditor } from '@/activities/components/CommentThreadBodyEditor';
|
||||
import { CommentThreadComments } from '@/activities/components/CommentThreadComments';
|
||||
import { CommentThreadRelationPicker } from '@/activities/components/CommentThreadRelationPicker';
|
||||
import { CommentThreadTypeDropdown } from '@/activities/components/CommentThreadTypeDropdown';
|
||||
import { GET_COMMENT_THREAD } from '@/activities/queries';
|
||||
import { PropertyBox } from '@/ui/editable-field/property-box/components/PropertyBox';
|
||||
import { PropertyBoxItem } from '@/ui/editable-field/property-box/components/PropertyBoxItem';
|
||||
import { IconArrowUpRight } from '@/ui/icon/index';
|
||||
import {
|
||||
useGetCommentThreadQuery,
|
||||
useUpdateCommentThreadMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
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 '@/activities/queries';
|
||||
import { GET_COMPANIES } from '@/companies/queries';
|
||||
import { GET_PEOPLE } from '@/people/queries';
|
||||
import { Button } from '@/ui/button/components/Button';
|
||||
import { IconTrash } from '@/ui/icon';
|
||||
import { isRightDrawerOpenState } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { commentableEntityArrayState } from '@/activities/states/commentableEntityArrayState';
|
||||
import { Timeline } from '@/activities/timeline/components/Timeline';
|
||||
import { RightDrawerBody } from '@/ui/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/right-drawer/components/RightDrawerTopBar';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { viewableCommentThreadIdState } from '@/activities/states/viewableCommentThreadIdState';
|
||||
import { RightDrawerBody } from '@/ui/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { viewableCommentThreadIdState } from '@/activities/states/viewableCommentThreadIdState';
|
||||
import { RightDrawerBody } from '@/ui/right-drawer/components/RightDrawerBody';
|
||||
import { RightDrawerPage } from '@/ui/right-drawer/components/RightDrawerPage';
|
||||
import { RightDrawerTopBar } from '@/ui/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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { CommentableEntity } from '../types/CommentableEntity';
|
||||
|
||||
export const commentableEntityArrayState = atom<CommentableEntity[]>({
|
||||
key: 'comments/commentable-entity-array',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const viewableCommentThreadIdState = atom<string | null>({
|
||||
key: 'comments/viewable-comment-thread-id',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComment } from '@/ui/icon';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
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>,
|
||||
),
|
||||
};
|
||||
296
front/src/modules/activities/timeline/components/Timeline.tsx
Normal file
296
front/src/modules/activities/timeline/components/Timeline.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CommentThreadCreateButton } from '@/activities/components/CommentThreadCreateButton';
|
||||
import { useOpenCommentThreadRightDrawer } from '@/activities/hooks/useOpenCommentThreadRightDrawer';
|
||||
import { useOpenCreateCommentThreadDrawer } from '@/activities/hooks/useOpenCreateCommentThreadDrawer';
|
||||
import { CommentableEntity } from '@/activities/types/CommentableEntity';
|
||||
import { CommentThreadForDrawer } from '@/activities/types/CommentThreadForDrawer';
|
||||
import { IconNotes } from '@/ui/icon/index';
|
||||
import {
|
||||
ActivityType,
|
||||
SortOrder,
|
||||
useGetCommentThreadsByTargetsQuery,
|
||||
} from '~/generated/graphql';
|
||||
import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '~/utils/date-utils';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
front/src/modules/activities/types/CommentForDrawer.ts
Normal file
5
front/src/modules/activities/types/CommentForDrawer.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CommentThreadForDrawer } from './CommentThreadForDrawer';
|
||||
|
||||
export type CommentForDrawer = NonNullable<
|
||||
CommentThreadForDrawer['comments']
|
||||
>[0];
|
||||
@ -0,0 +1,4 @@
|
||||
import { GetCommentThreadsByTargetsQuery } from '~/generated/graphql';
|
||||
|
||||
export type CommentThreadForDrawer =
|
||||
GetCommentThreadsByTargetsQuery['findManyCommentThreads'][0];
|
||||
6
front/src/modules/activities/types/CommentableEntity.ts
Normal file
6
front/src/modules/activities/types/CommentableEntity.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { CommentableType } from '~/generated/graphql';
|
||||
|
||||
export type CommentableEntity = {
|
||||
id: string;
|
||||
type: CommentableType;
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
|
||||
import { CommentableType } from '~/generated/graphql';
|
||||
|
||||
export type CommentableEntityForSelect = EntityForSelect & {
|
||||
entityType: CommentableType;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
|
||||
|
||||
export function flatMapAndSortEntityForSelectArrayOfArrayByName<
|
||||
T extends EntityForSelect,
|
||||
>(entityForSelectArray: T[][]) {
|
||||
const sortByName = (a: T, b: T) => a.name.localeCompare(b.name);
|
||||
|
||||
return entityForSelectArray.flatMap((entity) => entity).sort(sortByName);
|
||||
}
|
||||
Reference in New Issue
Block a user