Uniformize folder structure (#693)

* Uniformize folder structure

* Fix icons

* Fix icons

* Fix tests

* Fix tests
This commit is contained in:
Charles Bochet
2023-07-16 14:29:28 -07:00
committed by GitHub
parent 900ec5572f
commit 6ced8434bd
462 changed files with 931 additions and 960 deletions

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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()}
/>
);
}

View File

@ -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>,
),
};