Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,76 @@
import { useEffect, useMemo, useState } from 'react';
import { BlockNoteEditor } from '@blocknote/core';
import { getDefaultReactSlashMenuItems, useBlockNote } from '@blocknote/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import debounce from 'lodash.debounce';
import { Activity } from '@/activities/types/Activity';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
const StyledBlockNoteStyledContainer = styled.div`
width: 100%;
`;
type ActivityBodyEditorProps = {
activity: Pick<Activity, 'id' | 'body'>;
onChange?: (activityBody: string) => void;
};
export const ActivityBodyEditor = ({
activity,
onChange,
}: ActivityBodyEditorProps) => {
const [body, setBody] = useState<string | null>(null);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
useEffect(() => {
if (body) {
onChange?.(body);
}
}, [body, onChange]);
const debounceOnChange = useMemo(() => {
const onInternalChange = (activityBody: string) => {
setBody(activityBody);
updateOneRecord?.({
idToUpdate: activity.id,
input: {
body: activityBody,
},
});
};
return debounce(onInternalChange, 200);
}, [updateOneRecord, activity.id]);
let slashMenuItems = [...getDefaultReactSlashMenuItems()];
const imagesActivated = useIsFeatureEnabled('IS_NOTE_CREATE_IMAGES_ENABLED');
if (!imagesActivated) {
slashMenuItems = slashMenuItems.filter((x) => x.name != 'Image');
}
const editor: BlockNoteEditor | null = useBlockNote({
initialContent:
isNonEmptyString(activity.body) && activity.body !== '{}'
? JSON.parse(activity.body)
: undefined,
domAttributes: { editor: { class: 'editor' } },
onEditorContentChange: (editor) => {
debounceOnChange(JSON.stringify(editor.topLevelBlocks) ?? '');
},
slashMenuItems,
});
return (
<StyledBlockNoteStyledContainer>
<BlockEditor editor={editor} />
</StyledBlockNoteStyledContainer>
);
};

View File

@ -0,0 +1,130 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { Comment } from '@/activities/comment/Comment';
import { Activity } from '@/activities/types/Activity';
import { Comment as CommentType } from '@/activities/types/Comment';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import {
AutosizeTextInput,
AutosizeTextInputVariant,
} from '@/ui/input/components/AutosizeTextInput';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
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-bottom: ${({ theme }) => theme.spacing(32)};
padding-left: ${({ theme }) => theme.spacing(12)};
width: 100%;
`;
const StyledCommentActionBar = styled.div`
background: ${({ theme }) => theme.background.primary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
bottom: 0;
display: flex;
padding: 16px 24px 16px 48px;
position: absolute;
width: calc(
${({ theme }) => (useIsMobile() ? '100%' : theme.rightDrawerWidth)} - 72px
);
`;
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;
`;
type ActivityCommentsProps = {
activity: Pick<Activity, 'id'>;
scrollableContainerRef: React.RefObject<HTMLDivElement>;
};
export const ActivityComments = ({
activity,
scrollableContainerRef,
}: ActivityCommentsProps) => {
const { createOneRecord: createOneComment } = useCreateOneRecord({
objectNameSingular: 'comment',
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: comments } = useFindManyRecords({
objectNameSingular: 'comment',
filter: {
activityId: {
eq: activity?.id ?? '',
},
},
});
if (!currentWorkspaceMember) {
return <></>;
}
const handleSendComment = (commentText: string) => {
if (!isNonEmptyString(commentText)) {
return;
}
createOneComment?.({
id: v4(),
authorId: currentWorkspaceMember?.id ?? '',
activityId: activity?.id ?? '',
body: commentText,
createdAt: new Date().toISOString(),
});
};
const handleFocus = () => {
const scrollableContainer = scrollableContainerRef.current;
scrollableContainer?.scrollTo({
top: scrollableContainer.scrollHeight,
behavior: 'smooth',
});
};
return (
<>
{comments.length > 0 && (
<>
<StyledThreadItemListContainer>
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
{comments?.map((comment) => (
<Comment key={comment.id} comment={comment as CommentType} />
))}
</StyledThreadItemListContainer>
</>
)}
<StyledCommentActionBar>
{currentWorkspaceMember && (
<AutosizeTextInput
onValidate={handleSendComment}
onFocus={handleFocus}
variant={AutosizeTextInputVariant.Button}
placeholder={comments.length > 0 ? 'Reply...' : undefined}
/>
)}
</StyledCommentActionBar>
</>
);
};

View File

@ -0,0 +1,30 @@
import {
IconCheckbox,
IconNotes,
IconTimelineEvent,
} from '@/ui/display/icon/index';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
type ActivityCreateButtonProps = {
onNoteClick?: () => void;
onTaskClick?: () => void;
onActivityClick?: () => void;
};
export const ActivityCreateButton = ({
onNoteClick,
onTaskClick,
onActivityClick,
}: ActivityCreateButtonProps) => (
<ButtonGroup variant={'secondary'}>
<Button Icon={IconNotes} title="Note" onClick={onNoteClick} />
<Button Icon={IconCheckbox} title="Task" onClick={onTaskClick} />
<Button
Icon={IconTimelineEvent}
title="Activity"
soon={true}
onClick={onActivityClick}
/>
</ButtonGroup>
);

View File

@ -0,0 +1,191 @@
import React, { useCallback, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { ActivityBodyEditor } from '@/activities/components/ActivityBodyEditor';
import { ActivityComments } from '@/activities/components/ActivityComments';
import { ActivityTypeDropdown } from '@/activities/components/ActivityTypeDropdown';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { debounce } from '~/utils/debounce';
import { ActivityTitle } from './ActivityTitle';
import '@blocknote/core/style.css';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
overflow-y: auto;
`;
const StyledUpperPartContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: flex-start;
`;
const StyledTopContainer = styled.div`
align-items: flex-start;
align-self: stretch;
background: ${({ theme }) => theme.background.secondary};
border-bottom: ${({ theme }) =>
useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`};
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 24px 24px 48px;
`;
type ActivityEditorProps = {
activity: Pick<
Activity,
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt'
> & {
comments?: Array<Comment> | null;
} & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
} & {
activityTargets?: Array<
Pick<ActivityTarget, 'id' | 'companyId' | 'personId'>
> | null;
};
showComment?: boolean;
autoFillTitle?: boolean;
};
export const ActivityEditor = ({
activity,
showComment = true,
autoFillTitle = false,
}: ActivityEditorProps) => {
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
const [title, setTitle] = useState<string | null>(activity.title ?? '');
const containerRef = useRef<HTMLDivElement>(null);
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const { FieldContextProvider: DueAtFieldContextProvider } = useFieldContext({
objectNameSingular: 'activity',
objectRecordId: activity.id,
fieldMetadataName: 'dueAt',
fieldPosition: 0,
forceRefetch: true,
});
const { FieldContextProvider: AssigneeFieldContextProvider } =
useFieldContext({
objectNameSingular: 'activity',
objectRecordId: activity.id,
fieldMetadataName: 'assignee',
fieldPosition: 1,
forceRefetch: true,
});
const updateTitle = useCallback(
(newTitle: string) => {
updateOneActivity?.({
idToUpdate: activity.id,
input: {
title: newTitle ?? '',
},
});
},
[activity.id, updateOneActivity],
);
const handleActivityCompletionChange = useCallback(
(value: boolean) => {
updateOneActivity?.({
idToUpdate: activity.id,
input: {
completedAt: value ? new Date().toISOString() : null,
},
forceRefetch: true,
});
},
[activity.id, updateOneActivity],
);
const debouncedUpdateTitle = debounce(updateTitle, 200);
const updateTitleFromBody = (body: string) => {
const blockBody = JSON.parse(body);
const parsedTitle = blockBody[0]?.content?.[0]?.text;
if (!hasUserManuallySetTitle && autoFillTitle) {
setTitle(parsedTitle);
debouncedUpdateTitle(parsedTitle);
}
};
if (!activity) {
return <></>;
}
return (
<StyledContainer ref={containerRef}>
<StyledUpperPartContainer>
<StyledTopContainer>
<ActivityTypeDropdown activity={activity} />
<ActivityTitle
title={title ?? ''}
completed={!!activity.completedAt}
type={activity.type}
onTitleChange={(newTitle) => {
setTitle(newTitle);
setHasUserManuallySetTitle(true);
debouncedUpdateTitle(newTitle);
}}
onCompletionChange={handleActivityCompletionChange}
/>
<PropertyBox>
{activity.type === 'Task' &&
DueAtFieldContextProvider &&
AssigneeFieldContextProvider && (
<>
<DueAtFieldContextProvider>
<RecordInlineCell />
</DueAtFieldContextProvider>
<AssigneeFieldContextProvider>
<RecordInlineCell />
</AssigneeFieldContextProvider>
</>
)}
<ActivityTargetsInlineCell
activity={activity as unknown as GraphQLActivity}
/>
</PropertyBox>
</StyledTopContainer>
<ActivityBodyEditor
activity={activity}
onChange={updateTitleFromBody}
/>
</StyledUpperPartContainer>
{showComment && (
<ActivityComments
activity={activity}
scrollableContainerRef={containerRef}
/>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { PersonChip } from '@/people/components/PersonChip';
import { getLogoUrlFromDomainName } from '~/utils';
const StyledContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
`;
// TODO: fix edges pagination formatting on n+N
export const ActivityTargetChips = ({ targets }: { targets?: any }) => {
if (!targets) {
return null;
}
return (
<StyledContainer>
{targets?.map(({ company, person }: any) => {
if (company) {
return (
<CompanyChip
key={company.id}
id={company.id}
name={company.name}
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
/>
);
}
if (person) {
return (
<PersonChip
key={person.id}
id={person.id}
name={person.name.firstName + ' ' + person.name.lastName}
avatarUrl={person.avatarUrl ?? undefined}
/>
);
}
return <></>;
})}
</StyledContainer>
);
};

View File

@ -0,0 +1,74 @@
import styled from '@emotion/styled';
import { ActivityType } from '@/activities/types/Activity';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
} from '@/ui/input/components/Checkbox';
const StyledEditableTitleInput = styled.input<{
completed: boolean;
value: string;
}>`
background: transparent;
border: none;
color: ${({ theme, value }) =>
value ? theme.font.color.primary : theme.font.color.light};
display: flex;
flex-direction: column;
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
outline: none;
text-decoration: ${({ completed }) => (completed ? 'line-through' : 'none')};
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
width: calc(100% - ${({ theme }) => theme.spacing(2)});
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
type ActivityTitleProps = {
title: string;
type: ActivityType;
completed: boolean;
onTitleChange: (title: string) => void;
onCompletionChange: (value: boolean) => void;
};
export const ActivityTitle = ({
title,
completed,
type,
onTitleChange,
onCompletionChange,
}: ActivityTitleProps) => (
<StyledContainer>
{type === 'Task' && (
<Checkbox
size={CheckboxSize.Large}
shape={CheckboxShape.Rounded}
checked={completed}
onCheckedChange={(value) => onCompletionChange(value)}
/>
)}
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${type} title`}
onChange={(event) => onTitleChange(event.target.value)}
value={title}
completed={completed}
/>
</StyledContainer>
);

View File

@ -0,0 +1,35 @@
import { useTheme } from '@emotion/react';
import { Activity } from '@/activities/types/Activity';
import {
Chip,
ChipAccent,
ChipSize,
ChipVariant,
} from '@/ui/display/chip/components/Chip';
import { IconCheckbox, IconNotes } from '@/ui/display/icon';
type ActivityTypeDropdownProps = {
activity: Pick<Activity, 'type'>;
};
export const ActivityTypeDropdown = ({
activity,
}: ActivityTypeDropdownProps) => {
const theme = useTheme();
return (
<Chip
label={activity.type}
leftComponent={
activity.type === 'Note' ? (
<IconNotes size={theme.icon.size.md} />
) : (
<IconCheckbox size={theme.icon.size.md} />
)
}
size={ChipSize.Large}
accent={ChipAccent.TextSecondary}
variant={ChipVariant.Highlighted}
/>
);
};