Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
@ -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>,
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user