Add "show company / people" view and "Notes" concept (#528)

* Begin adding show view and refactoring threads to become notes

* Progress on design

* Progress redesign timeline

* Dropdown button, design improvement

* Open comment thread edit mode in drawer

* Autosave local storage and commentThreadcount

* Improve display and fix missing key issue

* Remove some hardcoded CSS properties

* Create button

* Split company show into ui/business + fix eslint

* Fix font weight

* Begin auto-save on edit mode

* Save server-side query result to Apollo cache

* Fix save behavior

* Refetch timeline after creating note

* Rename createCommentThreadWithComment

* Improve styling

* Revert "Improve styling"

This reverts commit 9fbbf2db006e529330edc64f3eb8ff9ecdde6bb0.

* Improve CSS styling

* Bring back border radius inadvertently removed

* padding adjustment

* Improve blocknote design

* Improve edit mode display

* Remove Comments.tsx

* Remove irrelevant comment stories

* Removed un-necessary panel component

* stop using fragment, move trash icon

* Add a basic story for CompanyShow

* Add a basic People show view

* Fix storybook tests

* Add very basic Person story

* Refactor PR1

* Refactor part 2

* Refactor part 3

* Refactor part 4

* Fix tests

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2023-07-10 07:25:34 +02:00
committed by GitHub
parent ca180acf9f
commit 94a913a41f
191 changed files with 5390 additions and 721 deletions

View File

@ -7,7 +7,7 @@ const StyledContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm}px;
font-size: ${({ theme }) => theme.font.size.sm};
text-align: center;
`;

View File

@ -15,6 +15,7 @@ export const StyledDialog = styled(Command.Dialog)`
top: 50%;
transform: translate(-50%, -50%);
width: 100%;
z-index: 1000;
`;
export const StyledInput = styled(Command.Input)`

View File

@ -1,108 +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 { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { GET_COMPANIES } from '@/companies/services';
import { GET_PEOPLE } from '@/people/services';
import { AutosizeTextInput } from '@/ui/components/inputs/AutosizeTextInput';
import { logError } from '@/utils/logs/logError';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { useCreateCommentMutation } from '~/generated/graphql';
import { GET_COMMENT_THREADS_BY_TARGETS } from '../services';
import { CommentThreadActionBar } from './CommentThreadActionBar';
import { CommentThreadItem } from './CommentThreadItem';
import { CommentThreadRelationPicker } from './CommentThreadRelationPicker';
type OwnProps = {
commentThread: CommentThreadForDrawer;
};
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledThreadItemListContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
width: 100%;
`;
export function CommentThread({ commentThread }: OwnProps) {
const [createCommentMutation] = useCreateCommentMutation();
const currentUser = useRecoilValue(currentUserState);
function handleSendComment(commentText: string) {
if (!isNonEmptyString(commentText)) {
return;
}
if (!isDefined(currentUser)) {
logError(
'In handleSendComment, currentUser is not defined, this should not happen.',
);
return;
}
createCommentMutation({
variables: {
commentId: v4(),
authorId: currentUser.id,
commentThreadId: commentThread.id,
commentText,
createdAt: new Date().toISOString(),
},
refetchQueries: [
getOperationName(GET_COMPANIES) ?? '',
getOperationName(GET_PEOPLE) ?? '',
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
],
onError: (error) => {
logError(
`In handleSendComment, createCommentMutation onError, error: ${error}`,
);
},
});
}
return (
<StyledContainer>
<StyledThreadItemListContainer>
{commentThread.comments?.map((comment, index) => (
<CommentThreadItem
key={comment.id}
comment={comment}
actionBar={
index === 0 ? (
<CommentThreadActionBar commentThreadId={commentThread.id} />
) : (
<></>
)
}
/>
))}
</StyledThreadItemListContainer>
<CommentThreadRelationPicker commentThread={commentThread} />
<AutosizeTextInput onValidate={handleSendComment} />
</StyledContainer>
);
}

View File

@ -1,148 +0,0 @@
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { currentUserState } from '@/auth/states/currentUserState';
import { commentableEntityArrayState } from '@/comments/states/commentableEntityArrayState';
import { createdCommentThreadIdState } from '@/comments/states/createdCommentThreadIdState';
import { GET_COMPANIES } from '@/companies/services';
import { GET_PEOPLE } from '@/people/services';
import { AutosizeTextInput } from '@/ui/components/inputs/AutosizeTextInput';
import { useOpenRightDrawer } from '@/ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { logError } from '@/utils/logs/logError';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import {
useCreateCommentMutation,
useCreateCommentThreadWithCommentMutation,
useGetCommentThreadQuery,
} from '~/generated/graphql';
import { GET_COMMENT_THREAD } from '../services';
import { CommentThreadItem } from './CommentThreadItem';
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
max-height: calc(100% - 16px);
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledThreadItemListContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column-reverse;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
overflow: auto;
width: 100%;
`;
export function CommentThreadCreateMode() {
const [commentableEntityArray] = useRecoilState(commentableEntityArrayState);
const [createdCommmentThreadId, setCreatedCommentThreadId] = useRecoilState(
createdCommentThreadIdState,
);
const openRightDrawer = useOpenRightDrawer();
const [createCommentMutation] = useCreateCommentMutation();
const [createCommentThreadWithComment] =
useCreateCommentThreadWithCommentMutation();
const { data } = useGetCommentThreadQuery({
variables: {
commentThreadId: createdCommmentThreadId ?? '',
},
skip: !createdCommmentThreadId,
});
const comments = data?.findManyCommentThreads[0]?.comments;
const displayCommentList = (comments?.length ?? 0) > 0;
const currentUser = useRecoilValue(currentUserState);
function handleNewComment(commentText: string) {
if (!isNonEmptyString(commentText)) {
return;
}
if (!isDefined(currentUser)) {
logError(
'In handleCreateCommentThread, currentUser is not defined, this should not happen.',
);
return;
}
if (!createdCommmentThreadId) {
createCommentThreadWithComment({
variables: {
authorId: currentUser.id,
commentId: v4(),
commentText: commentText,
commentThreadId: v4(),
createdAt: new Date().toISOString(),
commentThreadTargetArray: commentableEntityArray.map(
(commentableEntity) => ({
commentableId: commentableEntity.id,
commentableType: commentableEntity.type,
id: v4(),
createdAt: new Date().toISOString(),
}),
),
},
refetchQueries: [
getOperationName(GET_COMPANIES) ?? '',
getOperationName(GET_PEOPLE) ?? '',
getOperationName(GET_COMMENT_THREAD) ?? '',
],
onCompleted(data) {
setCreatedCommentThreadId(data.createOneCommentThread.id);
openRightDrawer('comments');
},
});
} else {
createCommentMutation({
variables: {
commentId: v4(),
authorId: currentUser.id,
commentThreadId: createdCommmentThreadId,
commentText,
createdAt: new Date().toISOString(),
},
refetchQueries: [getOperationName(GET_COMMENT_THREAD) ?? ''],
onError: (error) => {
logError(
`In handleCreateCommentThread, createCommentMutation onError, error: ${error}`,
);
},
});
}
}
return (
<StyledContainer>
{displayCommentList && (
<StyledThreadItemListContainer>
{comments?.map((comment) => (
<CommentThreadItem key={comment.id} comment={comment} />
))}
</StyledThreadItemListContainer>
)}
<AutosizeTextInput minRows={5} onValidate={handleNewComment} />
</StyledContainer>
);
}

View File

@ -1,51 +0,0 @@
import { useRecoilState } from 'recoil';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { useHotkeysScopeOnMountOnly } from '@/hotkeys/hooks/useHotkeysScopeOnMountOnly';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
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 {
SortOrder,
useGetCommentThreadsByTargetsQuery,
} from '~/generated/graphql';
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
import { CommentThread } from './CommentThread';
export function RightDrawerComments() {
const [commentableEntityArray] = useRecoilState(commentableEntityArrayState);
useHotkeysScopeOnMountOnly({
scope: InternalHotkeysScope.RightDrawer,
customScopes: { goto: false, 'command-menu': true },
});
const { data: queryResult } = useGetCommentThreadsByTargetsQuery({
variables: {
commentThreadTargetIds: commentableEntityArray.map(
(commentableEntity) => commentableEntity.id,
),
orderBy: [
{
createdAt: SortOrder.Desc,
},
],
},
});
const commentThreads: CommentThreadForDrawer[] =
queryResult?.findManyCommentThreads ?? [];
return (
<RightDrawerPage>
<RightDrawerTopBar title="Comments" />
<RightDrawerBody>
{commentThreads.map((commentThread) => (
<CommentThread key={commentThread.id} commentThread={commentThread} />
))}
</RightDrawerBody>
</RightDrawerPage>
);
}

View File

@ -0,0 +1,60 @@
import { useMemo } 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,
useUpdateCommentThreadBodyMutation,
} 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 [updateCommentThreadBodyMutation] =
useUpdateCommentThreadBodyMutation();
const debounceOnChange = useMemo(() => {
function onInternalChange(commentThreadBody: string) {
onChange?.(commentThreadBody);
updateCommentThreadBodyMutation({
variables: {
commentThreadId: commentThread.id,
commentThreadBody: commentThreadBody,
},
refetchQueries: [
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
],
});
}
return debounce(onInternalChange, 200);
}, [commentThread, updateCommentThreadBodyMutation, onChange]);
const editor: BlockNoteEditor | null = useBlockNote({
initialContent: commentThread.body
? JSON.parse(commentThread.body)
: undefined,
editorDOMAttributes: { class: 'editor-edit-mode' },
onEditorContentChange: (editor) => {
debounceOnChange(JSON.stringify(editor.topLevelBlocks) ?? '');
},
});
return (
<BlockNoteStyledContainer>
<BlockEditor editor={editor} />
</BlockNoteStyledContainer>
);
}

View File

@ -0,0 +1,84 @@
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_THREADS_BY_TARGETS } 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);
`;
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_THREADS_BY_TARGETS) ?? ''],
});
}
return (
<>
{commentThread?.comments.length > 0 && (
<StyledThreadItemListContainer>
{commentThread?.comments?.map((comment, index) => (
<CommentThreadItem key={comment.id} comment={comment} />
))}
</StyledThreadItemListContainer>
)}
<StyledCommentActionBar>
{currentUser && <AutosizeTextInput onValidate={handleSendComment} />}
</StyledCommentActionBar>
</>
);
}

View File

@ -1,5 +1,4 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
autoUpdate,
@ -8,30 +7,33 @@ import {
size,
useFloating,
} from '@floating-ui/react';
import { IconArrowUpRight } from '@tabler/icons-react';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange';
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import CompanyChip from '@/companies/components/CompanyChip';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { PersonChip } from '@/people/components/PersonChip';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { MultipleEntitySelect } from '@/relation-picker/components/MultipleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/ui/utils/flatMapAndSortEntityForSelectArrayByName';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
CommentThread,
CommentThreadTarget,
useSearchCompanyQuery,
useSearchPeopleQuery,
} from '~/generated/graphql';
import { MultipleEntitySelect } from '../../relation-picker/components/MultipleEntitySelect';
import { useHandleCheckableCommentThreadTargetChange } from '../hooks/useHandleCheckableCommentThreadTargetChange';
import { CommentableEntityForSelect } from '../types/CommentableEntityForSelect';
type OwnProps = {
commentThread: CommentThreadForDrawer;
commentThread?: Pick<CommentThread, 'id'> & {
commentThreadTargets: Array<
Pick<CommentThreadTarget, 'id' | 'commentableId' | 'commentableType'>
>;
};
};
const StyledContainer = styled.div`
@ -44,25 +46,6 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledLabelContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledRelationLabel = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
user-select: none;
`;
const StyledRelationContainer = styled.div`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(1.5)};
@ -97,15 +80,12 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const theme = useTheme();
const peopleIds =
commentThread.commentThreadTargets
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Person')
.map((relation) => relation.commentableId) ?? [];
const companyIds =
commentThread.commentThreadTargets
commentThread?.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId) ?? [];
@ -203,10 +183,6 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
return (
<StyledContainer>
<StyledLabelContainer>
<IconArrowUpRight size={16} color={theme.font.color.tertiary} />
<StyledRelationLabel>Relations</StyledRelationLabel>
</StyledLabelContainer>
<StyledRelationContainer
ref={refs.setReference}
onClick={handleRelationContainerClick}
@ -215,11 +191,12 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
entity.entityType === CommentableType.Company ? (
<CompanyChip
key={entity.id}
id={entity.id}
name={entity.name}
picture={entity.avatarUrl}
/>
) : (
<PersonChip key={entity.id} name={entity.name} />
<PersonChip key={entity.id} name={entity.name} id={entity.id} />
),
)}
</StyledRelationContainer>

View File

@ -0,0 +1,18 @@
import {
DropdownButton,
DropdownOptionType,
} from '@/ui/components/buttons/DropdownButton';
import { IconNotes } from '@/ui/icons/index';
export function CommentThreadTypeDropdown() {
const options: DropdownOptionType[] = [
{ label: 'Notes', icon: <IconNotes /> },
// { label: 'Call', icon: <IconPhone /> },
];
const handleSelect = (selectedOption: DropdownOptionType) => {
// console.log(`You selected: ${selectedOption.label}`);
};
return <DropdownButton options={options} onSelection={handleSelect} />;
}

View File

@ -1,3 +1,4 @@
import { MemoryRouter } from 'react-router-dom';
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
@ -24,8 +25,10 @@ type Story = StoryObj<typeof CommentThreadRelationPicker>;
export const Default: Story = {
render: getRenderWrapperForComponent(
<StyledContainer>
<CommentThreadRelationPicker commentThread={mockedCommentThreads[0]} />
</StyledContainer>,
<MemoryRouter>
<StyledContainer>
<CommentThreadRelationPicker commentThread={mockedCommentThreads[0]} />
</StyledContainer>
</MemoryRouter>,
),
};

View File

@ -22,7 +22,7 @@ const StyledCommentBody = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
line-height: ${({ theme }) => theme.text.lineHeight};
line-height: ${({ theme }) => theme.text.lineHeight.md};
overflow-wrap: anywhere;
padding-left: 24px;

View File

@ -6,8 +6,8 @@ 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';
import { CommentThreadActionBar } from '../CommentThreadActionBar';
const meta: Meta<typeof CommentHeader> = {
title: 'Modules/Comments/CommentHeader',

View File

@ -0,0 +1,164 @@
import React, { useMemo } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { GET_COMMENT_THREADS_BY_TARGETS } 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,
useUpdateCommentThreadTitleMutation,
} 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`
align-items: flex-start;
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;
};
export function CommentThread({
commentThreadId,
showComment = true,
}: OwnProps) {
const { data } = useGetCommentThreadQuery({
variables: {
commentThreadId: commentThreadId ?? '',
},
skip: !commentThreadId,
});
const [updateCommentThreadTitleMutation] =
useUpdateCommentThreadTitleMutation();
const debounceUpdateTitle = useMemo(() => {
function updateTitle(title: string) {
updateCommentThreadTitleMutation({
variables: {
commentThreadId: commentThreadId,
commentThreadTitle: title ?? '',
},
refetchQueries: [
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
],
});
}
return debounce(updateTitle, 200);
}, [commentThreadId, updateCommentThreadTitleMutation]);
function updateTitleFromBody(body: string) {
const title = JSON.parse(body)[0]?.content[0]?.text;
if (!commentThread?.title || commentThread?.title === '') {
debounceUpdateTitle(title);
}
}
const commentThread = data?.findManyCommentThreads[0];
if (!commentThread) {
return <></>;
}
return (
<StyledContainer>
<StyledTopContainer>
<StyledTopActionsContainer>
<CommentThreadTypeDropdown />
<CommentThreadActionBar commentThreadId={commentThread?.id ?? ''} />
</StyledTopActionsContainer>
<StyledEditableTitleInput
placeholder="Note title (optional)"
onChange={(event) => debounceUpdateTitle(event.target.value)}
value={commentThread?.title ?? ''}
/>
<PropertyBox>
<PropertyBoxItem
icon={<IconArrowUpRight />}
value={
<CommentThreadRelationPicker
commentThread={{
id: commentThread.id,
commentThreadTargets:
commentThread.commentThreadTargets ?? [],
}}
/>
}
label="Relations"
/>
</PropertyBox>
</StyledTopContainer>
<CommentThreadBodyEditor
commentThread={commentThread}
onChange={updateTitleFromBody}
/>
{showComment && (
<CommentThreadComments
commentThread={{
id: commentThread.id,
comments: commentThread.comments ?? [],
}}
/>
)}
</StyledContainer>
);
}

View File

@ -3,14 +3,13 @@ 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/services';
import { GET_PEOPLE } from '@/people/services';
import { IconTrash } from '@/ui/icons';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
import { useDeleteCommentThreadMutation } from '~/generated/graphql';
import { GET_COMMENT_THREADS_BY_TARGETS } from '../services';
const StyledContainer = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;

View File

@ -0,0 +1,36 @@
import { useRecoilState } from 'recoil';
import { commentableEntityArrayState } from '@/comments/states/commentableEntityArrayState';
import { useHotkeysScopeOnMountOnly } from '@/hotkeys/hooks/useHotkeysScopeOnMountOnly';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
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);
useHotkeysScopeOnMountOnly({
scope: InternalHotkeysScope.RightDrawer,
customScopes: { goto: false, 'command-menu': true },
});
return (
<RightDrawerPage>
<RightDrawerTopBar title="Timeline" />
<RightDrawerBody>
{commentableEntityArray.map((commentableEntity) => (
<Timeline
key={commentableEntity.id}
entity={{
id: commentableEntity?.id ?? '',
type: commentableEntity.type,
}}
/>
))}
</RightDrawerBody>
</RightDrawerPage>
);
}

View File

@ -0,0 +1,38 @@
import { useRecoilValue } from 'recoil';
import { viewableCommentThreadIdState } from '@/comments/states/viewableCommentThreadIdState';
import { useHotkeysScopeOnMountOnly } from '@/hotkeys/hooks/useHotkeysScopeOnMountOnly';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
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);
useHotkeysScopeOnMountOnly({
scope: InternalHotkeysScope.RightDrawer,
customScopes: { goto: false, 'command-menu': true },
});
return (
<RightDrawerPage>
<RightDrawerTopBar
title="New note"
onSave={() => {
return;
}}
/>
<RightDrawerBody>
{commentThreadId && (
<CommentThread
commentThreadId={commentThreadId}
showComment={false}
/>
)}
</RightDrawerBody>
</RightDrawerPage>
);
}

View File

@ -1,21 +1,25 @@
import { useRecoilValue } from 'recoil';
import { viewableCommentThreadIdState } from '@/comments/states/viewableCommentThreadIdState';
import { useHotkeysScopeOnMountOnly } from '@/hotkeys/hooks/useHotkeysScopeOnMountOnly';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
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 { CommentThreadCreateMode } from './CommentThreadCreateMode';
import { CommentThread } from '../CommentThread';
export function RightDrawerCreateCommentThread() {
export function RightDrawerEditCommentThread() {
useHotkeysScopeOnMountOnly({
scope: InternalHotkeysScope.RightDrawer,
customScopes: { goto: false, 'command-menu': true },
});
const commentThreadId = useRecoilValue(viewableCommentThreadIdState);
return (
<RightDrawerPage>
<RightDrawerTopBar title="New comment" />
<RightDrawerTopBar title="" />
<RightDrawerBody>
<CommentThreadCreateMode />
{commentThreadId && <CommentThread commentThreadId={commentThreadId} />}
</RightDrawerBody>
</RightDrawerPage>
);

View File

@ -41,9 +41,8 @@ const StyledChip = styled.div`
const StyledCount = styled.div`
align-items: center;
display: flex;
font-size: 12px;
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
justify-content: center;
`;

View File

@ -0,0 +1,288 @@
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 { TableActionBarButtonToggleComments } from '@/ui/components/table/action-bar/TableActionBarButtonOpenComments';
import { IconCirclePlus, IconNotes } from '@/ui/icons/index';
import {
beautifyExactDate,
beautifyPastDateRelativeToNow,
} from '@/utils/datetime/date-utils';
import {
SortOrder,
useGetCommentThreadsByTargetsQuery,
} from '~/generated/graphql';
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};
`;
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: flex-start;
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex: 1 0 0;
flex-wrap: wrap;
gap: 4px 8px;
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: 4px;
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 } = useGetCommentThreadsByTargetsQuery({
variables: {
commentThreadTargetIds: [entity.id],
orderBy: [
{
createdAt: SortOrder.Desc,
},
],
},
});
const openCommentThreadRightDrawer = useOpenCommentThreadRightDrawer();
const openCreateCommandThread = useOpenCreateCommentThreadDrawer();
const commentThreads: CommentThreadForDrawer[] =
queryResult?.findManyCommentThreads ?? [];
if (!commentThreads.length) {
return (
<StyledTimelineEmptyContainer>
<StyledEmptyTimelineTitle>No activity yet</StyledEmptyTimelineTitle>
<StyledEmptyTimelineSubTitle>Create one:</StyledEmptyTimelineSubTitle>
<TableActionBarButtonToggleComments
onClick={() => openCreateCommandThread(entity)}
/>
</StyledTimelineEmptyContainer>
);
}
return (
<StyledMainContainer>
<StyledTopActionBar>
<StyledTimelineItemContainer>
<StyledIconContainer>
<IconCirclePlus />
</StyledIconContainer>
<TableActionBarButtonToggleComments
onClick={() => openCreateCommandThread(entity)}
/>
</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>
);
}

View File

@ -4,18 +4,23 @@ import { v4 } from 'uuid';
import { GET_COMPANIES } from '@/companies/services';
import { GET_PEOPLE } from '@/people/services';
import {
CommentThread,
CommentThreadTarget,
useAddCommentThreadTargetOnCommentThreadMutation,
useRemoveCommentThreadTargetOnCommentThreadMutation,
} from '~/generated/graphql';
import { GET_COMMENT_THREADS_BY_TARGETS } from '../services';
import { CommentableEntityForSelect } from '../types/CommentableEntityForSelect';
import { CommentThreadForDrawer } from '../types/CommentThreadForDrawer';
export function useHandleCheckableCommentThreadTargetChange({
commentThread,
}: {
commentThread: CommentThreadForDrawer;
commentThread?: Pick<CommentThread, 'id'> & {
commentThreadTargets: Array<
Pick<CommentThreadTarget, 'id' | 'commentableId'>
>;
};
}) {
const [addCommentThreadTargetOnCommentThread] =
useAddCommentThreadTargetOnCommentThreadMutation({
@ -39,6 +44,9 @@ export function useHandleCheckableCommentThreadTargetChange({
newCheckedValue: boolean,
entity: CommentableEntityForSelect,
) {
if (!commentThread) {
return;
}
if (newCheckedValue) {
addCommentThreadTargetOnCommentThread({
variables: {

View File

@ -0,0 +1,18 @@
import { useRecoilState } from 'recoil';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useOpenRightDrawer } from '../../ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { viewableCommentThreadIdState } from '../states/viewableCommentThreadIdState';
export function useOpenCommentThreadRightDrawer() {
const openRightDrawer = useOpenRightDrawer();
const [, setViewableCommentThreadId] = useRecoilState(
viewableCommentThreadIdState,
);
return function openCommentThreadRightDrawer(commentThreadId: string) {
setViewableCommentThreadId(commentThreadId);
openRightDrawer(RightDrawerPages.EditCommentThread);
};
}

View File

@ -1,38 +1,73 @@
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/services';
import { GET_PEOPLE } from '@/people/services';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { selectedRowIdsState } from '@/ui/tables/states/selectedRowIdsState';
import { CommentableType } from '~/generated/graphql';
import {
CommentableType,
useCreateCommentThreadMutation,
} from '~/generated/graphql';
import { useOpenRightDrawer } from '../../ui/layout/right-drawer/hooks/useOpenRightDrawer';
import {
GET_COMMENT_THREAD,
GET_COMMENT_THREADS_BY_TARGETS,
} from '../services';
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
import { createdCommentThreadIdState } from '../states/createdCommentThreadIdState';
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 [, setCommentableEntityArray] = useRecoilState(
commentableEntityArrayState,
);
const [, setCreatedCommentThreadId] = useRecoilState(
createdCommentThreadIdState,
);
const selectedPeopleIds = useRecoilValue(selectedRowIdsState);
const selectedEntityIds = useRecoilValue(selectedRowIdsState);
return function openCreateCommentDrawerForSelectedRowIds(
entityType: CommentableType,
) {
const commentableEntityArray: CommentableEntity[] = selectedPeopleIds.map(
const commentableEntityArray: CommentableEntity[] = selectedEntityIds.map(
(id) => ({
type: entityType,
id,
}),
);
setCreatedCommentThreadId(null);
setCommentableEntityArray(commentableEntityArray);
openRightDrawer('create-comment-thread');
createCommentThreadMutation({
variables: {
authorId: currentUser?.id ?? '',
commentThreadId: v4(),
createdAt: new Date().toISOString(),
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) {
setViewableCommentThreadId(data.createOneCommentThread.id);
setCommentableEntityArray(commentableEntityArray);
openRightDrawer(RightDrawerPages.CreateCommentThread);
},
});
};
}

View File

@ -0,0 +1,60 @@
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/services';
import { GET_PEOPLE } from '@/people/services';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useCreateCommentThreadMutation } from '~/generated/graphql';
import { useOpenRightDrawer } from '../../ui/layout/right-drawer/hooks/useOpenRightDrawer';
import {
GET_COMMENT_THREAD,
GET_COMMENT_THREADS_BY_TARGETS,
} from '../services';
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 [, setCommentableEntityArray] = useRecoilState(
commentableEntityArrayState,
);
const [, setViewableCommentThreadId] = useRecoilState(
viewableCommentThreadIdState,
);
return function openCreateCommentThreadDrawer(entity: CommentableEntity) {
createCommentThreadMutation({
variables: {
authorId: currentUser?.id ?? '',
commentThreadId: v4(),
createdAt: new Date().toISOString(),
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) {
setViewableCommentThreadId(data.createOneCommentThread.id);
setCommentableEntityArray([entity]);
openRightDrawer(RightDrawerPages.CreateCommentThread);
},
});
};
}

View File

@ -1,19 +1,21 @@
import { useRecoilState } from 'recoil';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useOpenRightDrawer } from '../../ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
import { CommentableEntity } from '../types/CommentableEntity';
export function useOpenCommentRightDrawer() {
export function useOpenTimelineRightDrawer() {
const openRightDrawer = useOpenRightDrawer();
const [, setCommentableEntityArray] = useRecoilState(
commentableEntityArrayState,
);
return function openCommentRightDrawer(
return function openTimelineRightDrawer(
commentableEntityArray: CommentableEntity[],
) {
setCommentableEntityArray(commentableEntityArray);
openRightDrawer('comments');
openRightDrawer(RightDrawerPages.Timeline);
};
}

View File

@ -33,12 +33,12 @@ export const CREATE_COMMENT = gql`
`;
export const CREATE_COMMENT_THREAD_WITH_COMMENT = gql`
mutation CreateCommentThreadWithComment(
mutation CreateCommentThread(
$commentThreadId: String!
$commentText: String!
$body: String
$title: String
$authorId: String!
$createdAt: DateTime!
$commentId: String!
$commentThreadTargetArray: [CommentThreadTargetCreateManyCommentThreadInput!]!
) {
createOneCommentThread(
@ -46,16 +46,9 @@ export const CREATE_COMMENT_THREAD_WITH_COMMENT = gql`
id: $commentThreadId
createdAt: $createdAt
updatedAt: $createdAt
comments: {
createMany: {
data: {
authorId: $authorId
id: $commentId
createdAt: $createdAt
body: $commentText
}
}
}
author: { connect: { id: $authorId } }
body: $body
title: $title
commentThreadTargets: {
createMany: { data: $commentThreadTargetArray, skipDuplicates: true }
}
@ -64,6 +57,7 @@ export const CREATE_COMMENT_THREAD_WITH_COMMENT = gql`
id
createdAt
updatedAt
authorId
commentThreadTargets {
id
createdAt

View File

@ -14,6 +14,14 @@ export const GET_COMMENT_THREADS_BY_TARGETS = gql`
}
) {
id
createdAt
title
body
author {
id
firstName
lastName
}
comments {
id
body
@ -40,6 +48,14 @@ export const GET_COMMENT_THREAD = gql`
query GetCommentThread($commentThreadId: String!) {
findManyCommentThreads(where: { id: { equals: $commentThreadId } }) {
id
createdAt
body
title
author {
id
firstName
lastName
}
comments {
id
body
@ -54,6 +70,7 @@ export const GET_COMMENT_THREAD = gql`
}
}
commentThreadTargets {
id
commentableId
commentableType
}

View File

@ -68,3 +68,33 @@ export const DELETE_COMMENT_THREAD = gql`
}
}
`;
export const UPDATE_COMMENT_THREAD_TITLE = gql`
mutation UpdateCommentThreadTitle(
$commentThreadId: String!
$commentThreadTitle: String
) {
updateOneCommentThread(
where: { id: $commentThreadId }
data: { title: { set: $commentThreadTitle } }
) {
id
title
}
}
`;
export const UPDATE_COMMENT_THREAD_BODY = gql`
mutation UpdateCommentThreadBody(
$commentThreadId: String!
$commentThreadBody: String
) {
updateOneCommentThread(
where: { id: $commentThreadId }
data: { body: { set: $commentThreadBody } }
) {
id
body
}
}
`;

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const createdCommentThreadIdState = atom<string | null>({
key: 'comments/created-comment-thread-id',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const viewableCommentThreadIdState = atom<string | null>({
key: 'comments/viewable-comment-thread-id',
default: null,
});

View File

@ -1,23 +1,27 @@
import { Link } from 'react-router-dom';
import { Theme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar } from '@/users/components/Avatar';
export type CompanyChipPropsType = {
id?: string;
name: string;
picture?: string;
};
const StyledContainer = styled.span`
const baseStyle = ({ theme }: { theme: Theme }) => `
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.primary};
background-color: ${theme.background.tertiary};
border-radius: ${theme.spacing(1)};
color: ${theme.font.color.primary};
display: inline-flex;
gap: ${({ theme }) => theme.spacing(1)};
height: calc(20px - 2 * ${({ theme }) => theme.spacing(1)});
gap: ${theme.spacing(1)};
height: calc(20px - 2 * ${theme.spacing(1)});
overflow: hidden;
padding: ${theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
text-decoration: none;
user-select: none;
@ -38,9 +42,19 @@ const StyledName = styled.span`
white-space: nowrap;
`;
function CompanyChip({ name, picture }: CompanyChipPropsType) {
const StyledContainerLink = styled(Link)`
${baseStyle}
`;
const StyledContainerNoLink = styled.div`
${baseStyle}
`;
function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return (
<StyledContainer data-testid="company-chip">
<ContainerComponent data-testid="company-chip" to={`/companies/${id}`}>
{picture && (
<Avatar
avatarUrl={picture?.toString()}
@ -50,7 +64,7 @@ function CompanyChip({ name, picture }: CompanyChipPropsType) {
/>
)}
<StyledName>{name}</StyledName>
</StyledContainer>
</ContainerComponent>
);
}

View File

@ -1,5 +1,5 @@
import { CellCommentChip } from '@/comments/components/CellCommentChip';
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
import { CellCommentChip } from '@/comments/components/table/CellCommentChip';
import { useOpenTimelineRightDrawer } from '@/comments/hooks/useOpenTimelineRightDrawer';
import { EditableCellChip } from '@/ui/components/editable-cell/types/EditableChip';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
@ -13,12 +13,12 @@ import CompanyChip from './CompanyChip';
type OwnProps = {
company: Pick<
GetCompaniesQuery['companies'][0],
'id' | 'name' | 'domainName' | '_commentCount' | 'accountOwner'
'id' | 'name' | 'domainName' | '_commentThreadCount' | 'accountOwner'
>;
};
export function CompanyEditableNameChipCell({ company }: OwnProps) {
const openCommentRightDrawer = useOpenCommentRightDrawer();
const openCommentRightDrawer = useOpenTimelineRightDrawer();
const [updateCompany] = useUpdateCompanyMutation();
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
@ -38,6 +38,7 @@ export function CompanyEditableNameChipCell({ company }: OwnProps) {
value={company.name || ''}
placeholder="Name"
picture={getLogoUrlFromDomainName(company.domainName)}
id={company.id}
changeHandler={(value: string) => {
updateCompany({
variables: {
@ -50,7 +51,7 @@ export function CompanyEditableNameChipCell({ company }: OwnProps) {
ChipComponent={CompanyChip}
rightEndContents={[
<CellCommentChip
count={company._commentCount ?? 0}
count={company._commentThreadCount ?? 0}
onClick={handleCommentClick}
/>,
]}

View File

@ -33,8 +33,8 @@ export const SmallName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<CompanyChip
name="Instragram"
picture="https://api.faviconkit.com/instagram.com/144"
name="Airbnb"
picture="https://api.faviconkit.com/airbnb.com/144"
/>
</TestCellContainer>,
),

View File

@ -1,2 +1,3 @@
export * from './select';
export * from './show';
export * from './update';

View File

@ -22,7 +22,7 @@ export const GET_COMPANIES = gql`
createdAt
address
employees
_commentCount
_commentThreadCount
accountOwner {
id
email

View File

@ -0,0 +1,26 @@
import { gql } from '@apollo/client';
import { useGetCompanyQuery } from '~/generated/graphql';
export const GET_COMPANY = gql`
query GetCompany($id: String!) {
findUniqueCompany(id: $id) {
id
domainName
name
createdAt
address
employees
_commentThreadCount
accountOwner {
id
email
displayName
}
}
}
`;
export function useCompanyQuery(id: string) {
return useGetCompanyQuery({ variables: { id } });
}

View File

@ -16,4 +16,5 @@ export enum InternalHotkeysScope {
PasswordLogin = 'password-login',
AuthIndex = 'auth-index',
CreateProfile = 'create-profile',
ShowPage = 'show-page',
}

View File

@ -1,15 +1,15 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { CellCommentChip } from '@/comments/components/CellCommentChip';
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
import { CellCommentChip } from '@/comments/components/table/CellCommentChip';
import { useOpenTimelineRightDrawer } from '@/comments/hooks/useOpenTimelineRightDrawer';
import { EditableCellDoubleText } from '@/ui/components/editable-cell/types/EditableCellDoubleText';
import { CommentableType, Person } from '~/generated/graphql';
import { PersonChip } from './PersonChip';
type OwnProps = {
person: Pick<Person, 'id' | 'firstName' | 'lastName' | '_commentCount'>;
person: Pick<Person, 'id' | 'firstName' | 'lastName' | '_commentThreadCount'>;
onChange: (firstName: string, lastName: string) => void;
};
@ -27,7 +27,7 @@ const RightContainer = styled.div`
export function EditablePeopleFullName({ person, onChange }: OwnProps) {
const [firstNameValue, setFirstNameValue] = useState(person.firstName ?? '');
const [lastNameValue, setLastNameValue] = useState(person.lastName ?? '');
const openCommentRightDrawer = useOpenCommentRightDrawer();
const openCommentRightDrawer = useOpenTimelineRightDrawer();
function handleDoubleTextChange(
firstValue: string,
@ -60,10 +60,13 @@ export function EditablePeopleFullName({ person, onChange }: OwnProps) {
onChange={handleDoubleTextChange}
nonEditModeContent={
<NoEditModeContainer>
<PersonChip name={person.firstName + ' ' + person.lastName} />
<PersonChip
name={person.firstName + ' ' + person.lastName}
id={person.id}
/>
<RightContainer>
<CellCommentChip
count={person._commentCount ?? 0}
count={person._commentThreadCount ?? 0}
onClick={handleCommentClick}
/>
</RightContainer>

View File

@ -30,6 +30,7 @@ export function PeopleCompanyCell({ people }: OwnProps) {
}
nonEditModeContent={
<CompanyChip
id={people.company?.id ?? ''}
name={people.company?.name ?? ''}
picture={getLogoUrlFromDomainName(people.company?.domainName)}
/>

View File

@ -1,54 +1,63 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { Theme } from '@emotion/react';
import styled from '@emotion/styled';
import PersonPlaceholder from './person-placeholder.png';
export type PersonChipPropsType = {
id?: string;
name: string;
picture?: string;
};
const StyledContainer = styled.span`
const baseStyle = ({ theme }: { theme: Theme }) => `
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.primary};
background-color: ${theme.background.tertiary};
border-radius: ${theme.spacing(1)};
color: ${theme.font.color.primary};
display: inline-flex;
gap: ${({ theme }) => theme.spacing(1)};
gap: ${theme.spacing(1)};
height: 12px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(1)};
padding: ${theme.spacing(1)};
text-decoration: none;
:hover {
filter: brightness(95%);
}
img {
border-radius: 100%;
height: 14px;
object-fit: cover;
width: 14px;
}
white-space: nowrap;
`;
const StyledContainerLink = styled(Link)`
${baseStyle}
`;
const StyledContainerNoLink = styled.div`
${baseStyle}
`;
const StyledName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export function PersonChip({ name, picture }: PersonChipPropsType) {
export function PersonChip({ id, name, picture }: PersonChipPropsType) {
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
return (
<StyledContainer data-testid="person-chip">
<ContainerComponent data-testid="person-chip" to={`/person/${id}`}>
<img
data-testid="person-chip-image"
src={picture ? picture.toString() : PersonPlaceholder.toString()}
alt="person"
/>
<StyledName>{name}</StyledName>
</StyledContainer>
</ContainerComponent>
);
}

View File

@ -1,2 +1,3 @@
export * from './select';
export * from './show';
export * from './update';

View File

@ -24,7 +24,7 @@ export const GET_PEOPLE = gql`
firstName
lastName
createdAt
_commentCount
_commentThreadCount
company {
id
name

View File

@ -0,0 +1,19 @@
import { gql } from '@apollo/client';
import { useGetPersonQuery } from '~/generated/graphql';
export const GET_PERSON = gql`
query GetPerson($id: String!) {
findUniquePerson(id: $id) {
id
firstName
lastName
displayName
createdAt
}
}
`;
export function usePersonQuery(id: string) {
return useGetPersonQuery({ variables: { id } });
}

View File

@ -0,0 +1,94 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { IconChevronDown } from '@/ui/icons/index';
type ButtonProps = React.ComponentProps<'button'>;
export type DropdownOptionType = {
label: string;
icon: React.ReactNode;
};
type Props = {
options: DropdownOptionType[];
onSelection: (value: DropdownOptionType) => void;
} & ButtonProps;
const StyledButton = styled.button<ButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.tertiary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 4px;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: 8px;
height: 24px;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
padding: 3px 8px;
svg {
align-items: center;
display: flex;
height: 14px;
justify-content: center;
width: 14px;
}
`;
const DropdownContainer = styled.div`
position: relative;
`;
const DropdownMenu = styled.div`
display: flex;
flex-direction: column;
margin-top: -2px;
position: absolute;
`;
export function DropdownButton({
options,
onSelection,
...buttonProps
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState(options[0]);
if (!options.length) {
throw new Error('You must provide at least one option.');
}
const handleSelect =
(option: DropdownOptionType) =>
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
onSelection(option);
setSelectedOption(option);
setIsOpen(false);
};
return (
<DropdownContainer>
<StyledButton onClick={() => setIsOpen(!isOpen)} {...buttonProps}>
{selectedOption.icon}
{selectedOption.label}
{options.length > 1 && <IconChevronDown />}
</StyledButton>
{isOpen && (
<DropdownMenu>
{options
.filter((option) => option.label !== selectedOption.label)
.map((option, index) => (
<StyledButton key={index} onClick={handleSelect(option)}>
{option.icon}
{option.label}
</StyledButton>
))}
</DropdownMenu>
)}
</DropdownContainer>
);
}

View File

@ -6,17 +6,19 @@ import { textInputStyle } from '@/ui/themes/effects';
import { EditableCell } from '../EditableCell';
export type EditableChipProps = {
id: string;
placeholder?: string;
value: string;
picture: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<{
id: string;
name: string;
picture: string;
isOverlapped?: boolean;
}>;
commentCount?: number;
commentThreadCount?: number;
onCommentClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
rightEndContents?: ReactNode[];
};
@ -41,6 +43,7 @@ const RightContainer = styled.div`
// TODO: move right end content in EditableCell
export function EditableCellChip({
id,
value,
placeholder,
changeHandler,
@ -75,7 +78,7 @@ export function EditableCellChip({
}
nonEditModeContent={
<NoEditModeContainer>
<ChipComponent name={inputValue} picture={picture} />
<ChipComponent id={id} name={inputValue} picture={picture} />
<RightContainer>
{rightEndContents &&
rightEndContents.length > 0 &&

View File

@ -0,0 +1,28 @@
import { BlockNoteEditor } from '@blocknote/core';
import { BlockNoteView } from '@blocknote/react';
import styled from '@emotion/styled';
interface BlockEditorProps {
editor: BlockNoteEditor | null;
}
const StyledEditor = styled.div`
min-height: 200px;
width: 100%;
& .editor-create-mode,
.editor-edit-mode {
background: ${({ theme }) => theme.background.primary};
}
& .editor-create-mode [class^='_inlineContent']:before {
color: ${({ theme }) => theme.font.color.tertiary};
font-style: normal !important;
}
`;
export function BlockEditor({ editor }: BlockEditorProps) {
return (
<StyledEditor>
<BlockNoteView editor={editor} />
</StyledEditor>
);
}

View File

@ -22,7 +22,7 @@ const StyledContainer = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
border: none;
display: block;
font-weight: 400;
font-weight: ${({ theme }) => theme.font.weight.regular};
}
& .react-datepicker-popper {

View File

@ -27,8 +27,8 @@ const StyledTextArea = styled(TextareaAutosize)`
border-radius: 5px;
color: ${({ theme }) => theme.font.color.primary};
font-family: inherit;
font-size: 13px;
font-weight: 400;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: 16px;
overflow: auto;
padding: 8px;
@ -42,7 +42,7 @@ const StyledTextArea = styled(TextareaAutosize)`
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: 400;
font-weight: ${({ theme }) => theme.font.weight.regular};
}
`;
@ -121,7 +121,7 @@ export function AutosizeTextInput({
<>
<StyledContainer>
<StyledTextArea
placeholder={placeholder || 'Write something...'}
placeholder={placeholder || 'Write a comment'}
maxRows={MAX_ROWS}
minRows={computedMinRows}
onChange={handleInputChange}

View File

@ -0,0 +1,21 @@
import styled from '@emotion/styled';
const StyledPropertyBoxContainer = styled.div`
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 4px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
`;
interface PropertyBoxProps {
children: JSX.Element;
extraPadding?: boolean;
}
export function PropertyBox({ children }: PropertyBoxProps) {
return <StyledPropertyBoxContainer>{children}</StyledPropertyBoxContainer>;
}

View File

@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
const StyledPropertyBoxItem = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconContainer = styled.div`
align-items: center;
display: flex;
svg {
align-items: center;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
}
`;
const StyledValueContainer = styled.div`
align-content: flex-start;
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex: 1 0 0;
flex-wrap: wrap;
`;
const StyledLabelAndIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export function PropertyBoxItem({
icon,
label,
value,
}: {
icon: ReactNode;
label?: string;
value: ReactNode;
}) {
return (
<StyledPropertyBoxItem>
<StyledLabelAndIconContainer>
<StyledIconContainer>{icon}</StyledIconContainer>
{label}
</StyledLabelAndIconContainer>
<StyledValueContainer>{value}</StyledValueContainer>
</StyledPropertyBoxItem>
);
}

View File

@ -9,7 +9,7 @@ const StyledMainSectionTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: 1.5;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin: 0;
`;

View File

@ -10,7 +10,7 @@ const StyledTitle = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(8)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;

View File

@ -32,7 +32,7 @@ const StyledButton = styled.div<StyledButtonProps>`
`;
const StyledButtonLabel = styled.div`
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(2)};
`;

View File

@ -1,4 +1,4 @@
import { IconComment } from '@/ui/icons/index';
import { IconNotes } from '@/ui/icons/index';
import { EntityTableActionBarButton } from './EntityTableActionBarButton';
@ -9,8 +9,8 @@ type OwnProps = {
export function TableActionBarButtonToggleComments({ onClick }: OwnProps) {
return (
<EntityTableActionBarButton
label="Comment"
icon={<IconComment size={16} />}
label="Notes"
icon={<IconNotes size={16} />}
onClick={onClick}
/>
);

View File

@ -179,7 +179,7 @@ function DropdownButton({
};
const dropdownRef = useRef(null);
useOutsideAlerter(dropdownRef, onOutsideClick);
useOutsideAlerter({ ref: dropdownRef, callback: onOutsideClick });
return (
<StyledDropdownButtonContainer>

View File

@ -43,7 +43,7 @@ const StyledCancelButton = styled.button`
border: none;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: auto;
margin-right: ${({ theme }) => theme.spacing(2)};
padding: ${(props) => {

View File

@ -45,7 +45,7 @@ const StyledDelete = styled.div`
`;
const StyledLabelKey = styled.div`
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
function SortOrFilterChip({

View File

@ -27,7 +27,7 @@ const StyledTableHeader = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 40px;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(3)};
@ -49,7 +49,7 @@ const StyledViewSection = styled.div`
const StyledFilters = styled.div`
display: flex;
font-weight: 400;
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: 2px;
`;

View File

@ -7,7 +7,7 @@ const onOutsideClick = jest.fn();
function TestComponent() {
const buttonRef = useRef(null);
useOutsideAlerter(buttonRef, onOutsideClick);
useOutsideAlerter({ ref: buttonRef, callback: onOutsideClick });
return (
<div>

View File

@ -1,21 +1,52 @@
import { useEffect } from 'react';
declare type CallbackType = () => void;
export enum OutsideClickAlerterMode {
absolute = 'absolute',
dom = 'dom',
}
export function useOutsideAlerter(
ref: React.RefObject<HTMLInputElement>,
callback: CallbackType,
) {
type OwnProps = {
ref: React.RefObject<HTMLInputElement>;
callback: () => void;
mode?: OutsideClickAlerterMode;
};
export function useOutsideAlerter({
ref,
mode = OutsideClickAlerterMode.dom,
callback,
}: OwnProps) {
useEffect(() => {
function handleClickOutside(event: Event) {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLButtonElement;
if (ref.current && !ref.current.contains(target)) {
if (!ref.current) {
return;
}
if (
mode === OutsideClickAlerterMode.dom &&
!ref.current.contains(target)
) {
callback();
}
if (mode === OutsideClickAlerterMode.absolute) {
const { x, y, width, height } = ref.current.getBoundingClientRect();
const { clientX, clientY } = event;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
callback();
}
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, callback]);
}, [ref, callback, mode]);
}

View File

@ -30,3 +30,6 @@ export { IconArrowUpRight } from '@tabler/icons-react';
export { IconBrandGoogle } from '@tabler/icons-react';
export { IconUpload } from '@tabler/icons-react';
export { IconFileUpload } from '@tabler/icons-react';
export { IconChevronsRight } from '@tabler/icons-react';
export { IconNotes } from '@tabler/icons-react';
export { IconCirclePlus } from '@tabler/icons-react';

View File

@ -10,7 +10,7 @@ type OwnProps = {
topMargin?: number;
};
const MainContainer = styled.div<{ topMargin: number }>`
const StyledMainContainer = styled.div<{ topMargin: number }>`
background: ${({ theme }) => theme.background.noisy};
display: flex;
@ -27,27 +27,21 @@ type LeftContainerProps = {
isRightDrawerOpen?: boolean;
};
const LeftContainer = styled.div<LeftContainerProps>`
const StyledLeftContainer = styled.div<LeftContainerProps>`
display: flex;
position: relative;
width: calc(
100% -
${(props) =>
props.isRightDrawerOpen
? `${props.theme.rightDrawerWidth} - ${props.theme.spacing(2)}`
: '0px'}
);
width: 100%;
`;
export function ContentContainer({ children, topMargin }: OwnProps) {
const [isRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
return (
<MainContainer topMargin={topMargin ?? 0}>
<LeftContainer isRightDrawerOpen={isRightDrawerOpen}>
<StyledMainContainer topMargin={topMargin ?? 0}>
<StyledLeftContainer isRightDrawerOpen={isRightDrawerOpen}>
<Panel>{children}</Panel>
</LeftContainer>
</StyledLeftContainer>
<RightDrawer />
</MainContainer>
</StyledMainContainer>
);
}

View File

@ -8,7 +8,7 @@ const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: 600;
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(8)};

View File

@ -42,7 +42,7 @@ const StyledName = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-family: 'Inter';
font-size: ${({ theme }) => theme.font.size.md};
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(2)};
`;

View File

@ -1,33 +1,61 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import {
OutsideClickAlerterMode,
useOutsideAlerter,
} from '@/ui/hooks/useOutsideAlerter';
import { isDefined } from '@/utils/type-guards/isDefined';
import { Panel } from '../../Panel';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerRouter } from './RightDrawerRouter';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
height: 100%;
overflow-x: hidden;
position: fixed;
right: 0;
top: 0;
transition: width 0.5s;
width: ${({ theme }) => theme.rightDrawerWidth};
z-index: 2;
`;
const StyledRightDrawer = styled.div`
display: flex;
flex-direction: row;
width: ${({ theme }) => theme.rightDrawerWidth};
width: 100%;
`;
export function RightDrawer() {
const [isRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState(
isRightDrawerOpenState,
);
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
const rightDrawerRef = useRef(null);
useOutsideAlerter({
ref: rightDrawerRef,
callback: () => setIsRightDrawerOpen(false),
mode: OutsideClickAlerterMode.absolute,
});
if (!isRightDrawerOpen || !isDefined(rightDrawerPage)) {
return <></>;
}
return (
<StyledRightDrawer>
<Panel>
<RightDrawerRouter />
</Panel>
</StyledRightDrawer>
<>
<StyledContainer>
<StyledRightDrawer ref={rightDrawerRef}>
<RightDrawerRouter />
</StyledRightDrawer>
</StyledContainer>
</>
);
}

View File

@ -3,5 +3,7 @@ import styled from '@emotion/styled';
export const RightDrawerBody = styled.div`
display: flex;
flex-direction: column;
height: calc(100vh - ${({ theme }) => theme.spacing(10)});
overflow: auto;
position: relative;
`;

View File

@ -1,10 +1,12 @@
import { useRecoilState } from 'recoil';
import { RightDrawerComments } from '@/comments/components/RightDrawerComments';
import { RightDrawerCreateCommentThread } from '@/comments/components/RightDrawerCreateCommentThread';
import { RightDrawerCreateCommentThread } from '@/comments/components/right-drawer/create/RightDrawerCreateCommentThread';
import { RightDrawerEditCommentThread } from '@/comments/components/right-drawer/edit/RightDrawerEditCommentThread';
import { RightDrawerTimeline } from '@/comments/components/right-drawer/RightDrawerTimeline';
import { isDefined } from '@/utils/type-guards/isDefined';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
export function RightDrawerRouter() {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
@ -13,11 +15,14 @@ export function RightDrawerRouter() {
return <></>;
}
return rightDrawerPage === 'comments' ? (
<RightDrawerComments />
) : rightDrawerPage === 'create-comment-thread' ? (
<RightDrawerCreateCommentThread />
) : (
<></>
);
switch (rightDrawerPage) {
case RightDrawerPages.Timeline:
return <RightDrawerTimeline />;
case RightDrawerPages.CreateCommentThread:
return <RightDrawerCreateCommentThread />;
case RightDrawerPages.EditCommentThread:
return <RightDrawerEditCommentThread />;
default:
return <></>;
}
}

View File

@ -1,5 +1,7 @@
import styled from '@emotion/styled';
import { Button } from '@/ui/components/buttons/Button';
import { RightDrawerTopBarCloseButton } from './RightDrawerTopBarCloseButton';
const StyledRightDrawerTopBar = styled.div`
@ -8,7 +10,7 @@ const StyledRightDrawerTopBar = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-size: 13px;
font-size: ${({ theme }) => theme.font.size.md};
justify-content: space-between;
min-height: 40px;
padding-left: 8px;
@ -17,19 +19,24 @@ const StyledRightDrawerTopBar = styled.div`
const StyledTopBarTitle = styled.div`
align-items: center;
font-weight: 500;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-right: ${({ theme }) => theme.spacing(1)};
`;
export function RightDrawerTopBar({
title,
}: {
type OwnProps = {
title: string | null | undefined;
}) {
onSave?: () => void;
};
export function RightDrawerTopBar({ title, onSave }: OwnProps) {
function handleOnClick() {
onSave?.();
}
return (
<StyledRightDrawerTopBar>
<StyledTopBarTitle>{title}</StyledTopBarTitle>
<RightDrawerTopBarCloseButton />
<StyledTopBarTitle>{title}</StyledTopBarTitle>
{onSave && <Button title="Save" onClick={handleOnClick} />}
</StyledRightDrawerTopBar>
);
}

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { IconPlus } from '@/ui/icons/index';
import { IconChevronsRight } from '@/ui/icons/index';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
@ -24,7 +24,6 @@ const StyledButton = styled.button`
}
svg {
color: ${({ theme }) => theme.font.color.tertiary};
transform: rotate(45deg);
}
`;
@ -37,7 +36,7 @@ export function RightDrawerTopBarCloseButton() {
return (
<StyledButton onClick={handleButtonClick}>
<IconPlus size={16} />
<IconChevronsRight size={16} />
</StyledButton>
);
}

View File

@ -2,13 +2,13 @@ import { useRecoilState } from 'recoil';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPage } from '../types/RightDrawerPage';
import { RightDrawerPages } from '../types/RightDrawerPages';
export function useOpenRightDrawer() {
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const [, setRightDrawerPage] = useRecoilState(rightDrawerPageState);
return function openRightDrawer(rightDrawerPage: RightDrawerPage) {
return function openRightDrawer(rightDrawerPage: RightDrawerPages) {
setRightDrawerPage(rightDrawerPage);
setIsRightDrawerOpen(true);
};

View File

@ -1,8 +1,8 @@
import { atom } from 'recoil';
import { RightDrawerPage } from '../types/RightDrawerPage';
import { RightDrawerPages } from '../types/RightDrawerPages';
export const rightDrawerPageState = atom<RightDrawerPage | null>({
export const rightDrawerPageState = atom<RightDrawerPages | null>({
key: 'ui/layout/right-drawer-page',
default: null,
});

View File

@ -1 +0,0 @@
export type RightDrawerPage = 'comments' | 'create-comment-thread';

View File

@ -0,0 +1,5 @@
export enum RightDrawerPages {
Timeline = 'timeline',
CreateCommentThread = 'create-comment-thread',
EditCommentThread = 'edit-comment-thread',
}

View File

@ -0,0 +1,85 @@
import { Tooltip } from 'react-tooltip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { v4 as uuidV4 } from 'uuid';
import { Avatar } from '@/users/components/Avatar';
import {
beautifyExactDate,
beautifyPastDateRelativeToNow,
} from '@/utils/datetime/date-utils';
const StyledShowPageSummaryCard = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(3)}
${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(3)};
`;
const StyledInfoContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
`;
const StyledDate = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.font.color.primary};
padding: ${({ theme }) => theme.spacing(2)};
`;
export function ShowPageSummaryCard({
logoOrAvatar,
title,
date,
}: {
logoOrAvatar?: string;
title: string;
date: string;
}) {
const beautifiedCreatedAt =
date !== '' ? beautifyPastDateRelativeToNow(date) : '';
const exactCreatedAt = date !== '' ? beautifyExactDate(date) : '';
const theme = useTheme();
const dateElementId = `date-id-${uuidV4()}`;
return (
<StyledShowPageSummaryCard>
<Avatar
avatarUrl={logoOrAvatar}
size={theme.icon.size.xl}
placeholder={title}
/>
<StyledInfoContainer>
<StyledTitle>{title}</StyledTitle>
<StyledDate id={dateElementId}>
Added {beautifiedCreatedAt} ago
</StyledDate>
<StyledTooltip
anchorSelect={`#${dateElementId}`}
content={exactCreatedAt}
clickable
noArrow
place="right"
/>
</StyledInfoContainer>
</StyledShowPageSummaryCard>
);
}

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
export const ShowPageLeftContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border-radius: 8px;
border-right: 1px solid ${({ theme }) => theme.border.color.light};
padding: 0px ${({ theme }) => theme.spacing(3)};
width: 320px;
`;

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
export const ShowPageRightContainer = styled.div`
display: flex;
flex: 1 0 0;
flex-direction: column;
justify-content: center;
overflow: hidden;
`;

View File

@ -3,6 +3,7 @@ export const icon = {
sm: 14,
md: 16,
lg: 20,
xl: 40,
},
stroke: {
sm: 1.6,

View File

@ -18,7 +18,7 @@ const common = {
horizontalCellMargin: '8px',
checkboxColumnWidth: '32px',
},
rightDrawerWidth: '300px',
rightDrawerWidth: '500px',
clickableElementBackgroundTransition: 'background 0.1s ease',
lastLayerZIndex: 2147483647,
};
@ -32,6 +32,7 @@ export const lightTheme = {
selectedCardHover: color.blue20,
selectedCard: color.blue10,
font: fontLight,
name: 'light',
},
};
export type ThemeType = typeof lightTheme;
@ -45,6 +46,7 @@ export const darkTheme: ThemeType = {
selectedCardHover: color.blue70,
selectedCard: color.blue80,
font: fontDark,
name: 'dark',
},
};

View File

@ -17,8 +17,10 @@ export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>`
!isNonEmptyString(props.avatarUrl)
? props.theme.background.tertiary
: 'none'};
background-image: url(${(props) =>
isNonEmptyString(props.avatarUrl) ? props.avatarUrl : 'none'});
${(props) =>
isNonEmptyString(props.avatarUrl)
? `background-image: url(${props.avatarUrl});`
: ''}
background-size: cover;
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
color: ${({ theme }) => theme.font.color.primary};