Fix Tasks and Activities - Part 1 (#2624)

Fixed
This commit is contained in:
Lucas Bordeau
2023-11-21 23:29:40 +01:00
committed by GitHub
parent 77733f2bc8
commit a67199e0c3
18 changed files with 240 additions and 162 deletions

View File

@ -5,7 +5,7 @@ import { Comment as CommentType } from '@/activities/types/Comment';
import { CommentHeader } from './CommentHeader';
type CommentProps = {
comment: Omit<CommentType, 'activityId'>;
comment: CommentType;
actionBar?: React.ReactNode;
};

View File

@ -8,11 +8,6 @@ import {
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
type CommentHeaderProps = {
comment: Pick<Comment, 'id' | 'author' | 'createdAt'>;
actionBar?: React.ReactNode;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
@ -61,6 +56,11 @@ const StyledTooltip = styled(Tooltip)`
padding: 8px;
`;
type CommentHeaderProps = {
comment: Pick<Comment, 'id' | 'author' | 'createdAt'>;
actionBar?: React.ReactNode;
};
export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt);
const exactCreatedAt = beautifyExactDateTime(comment.createdAt);

View File

@ -4,7 +4,7 @@ import { Comment } from '@/activities/types/Comment';
export const mockComment: Pick<
Comment,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt'
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt' | 'activityId'
> = {
id: 'fake_comment_1_uuid',
body: 'Hello, this is a comment.',
@ -18,11 +18,12 @@ export const mockComment: Pick<
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
activityId: 'fake_activity_1_uuid',
};
export const mockCommentWithLongValues: Pick<
Comment,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt'
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt' | 'activityId'
> = {
id: 'fake_comment_2_uuid',
body: 'Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment.',
@ -36,4 +37,5 @@ export const mockCommentWithLongValues: Pick<
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
activityId: 'fake_activity_1_uuid',
};

View File

@ -23,7 +23,7 @@ export const ActivityBodyEditor = ({
}: ActivityBodyEditorProps) => {
const [body, setBody] = useState<string | null>(null);
const { updateOneObject } = useUpdateOneObjectRecord({
objectNameSingular: 'Activity',
objectNameSingular: 'activity',
});
useEffect(() => {

View File

@ -6,21 +6,15 @@ 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 { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateOneObjectRecord } from '@/object-record/hooks/useCreateOneObjectRecord';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import {
AutosizeTextInput,
AutosizeTextInputVariant,
} from '@/ui/input/components/AutosizeTextInput';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
type ActivityCommentsProps = {
activity: Pick<Activity, 'id'> & {
comments: Array<CommentType>;
};
scrollableContainerRef: React.RefObject<HTMLDivElement>;
};
const StyledThreadItemListContainer = styled.div`
align-items: flex-start;
border-top: 1px solid ${({ theme }) => theme.border.color.light};
@ -57,16 +51,31 @@ const StyledThreadCommentTitle = styled.div`
text-transform: uppercase;
`;
type ActivityCommentsProps = {
activity: Pick<Activity, 'id'>;
scrollableContainerRef: React.RefObject<HTMLDivElement>;
};
export const ActivityComments = ({
activity,
scrollableContainerRef,
}: ActivityCommentsProps) => {
const currentUser = useRecoilValue(currentUserState);
const { createOneObject } = useCreateOneObjectRecord({
objectNameSingular: 'comment',
});
if (!currentUser) {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objects: comments } = useFindManyObjectRecords({
objectNamePlural: 'comments',
filter: {
activityId: {
eq: activity?.id ?? '',
},
},
});
if (!currentWorkspaceMember) {
return <></>;
}
@ -76,10 +85,10 @@ export const ActivityComments = ({
}
createOneObject?.({
commentId: v4(),
authorId: currentUser?.id ?? '',
id: v4(),
authorId: currentWorkspaceMember?.id ?? '',
activityId: activity?.id ?? '',
commentText: commentText,
body: commentText,
createdAt: new Date().toISOString(),
});
};
@ -93,26 +102,28 @@ export const ActivityComments = ({
});
};
console.log('asd', { activity, comments });
return (
<>
{activity?.comments.length > 0 && (
{comments.length > 0 && (
<>
<StyledThreadItemListContainer>
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
{activity?.comments?.map((comment) => (
<Comment key={comment.id} comment={comment} />
{comments?.map((comment) => (
<Comment key={comment.id} comment={comment as CommentType} />
))}
</StyledThreadItemListContainer>
</>
)}
<StyledCommentActionBar>
{currentUser && (
{currentWorkspaceMember && (
<AutosizeTextInput
onValidate={handleSendComment}
onFocus={handleFocus}
variant={AutosizeTextInputVariant.Button}
placeholder={activity?.comments.length > 0 ? 'Reply...' : undefined}
placeholder={comments.length > 0 ? 'Reply...' : undefined}
/>
)}
</StyledCommentActionBar>

View File

@ -13,8 +13,6 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { debounce } from '~/utils/debounce';
import { ActivityRelationEditableField } from '../editable-fields/components/ActivityRelationEditableField';
import { ActivityTitle } from './ActivityTitle';
import '@blocknote/core/style.css';
@ -75,6 +73,10 @@ export const ActivityEditor = ({
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
console.log({
activity,
});
const [title, setTitle] = useState<string | null>(activity.title ?? '');
const [completedAt, setCompletedAt] = useState<string | null>(
activity.completedAt ?? '',
@ -149,7 +151,7 @@ export const ActivityEditor = ({
</RecoilScope> */}
</>
)}
<ActivityRelationEditableField activity={activity} />
{/* <ActivityRelationEditableField activity={activity} /> */}
</PropertyBox>
</StyledTopContainer>
<ActivityBodyEditor
@ -159,10 +161,7 @@ export const ActivityEditor = ({
</StyledUpperPartContainer>
{showComment && (
<ActivityComments
activity={{
id: activity.id,
comments: activity.comments ?? [],
}}
activity={activity}
scrollableContainerRef={containerRef}
/>
)}

View File

@ -1,10 +1,7 @@
import styled from '@emotion/styled';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { Company } from '@/companies/types/Company';
import { PersonChip } from '@/people/components/PersonChip';
import { Person } from '@/people/types/Person';
import { getLogoUrlFromDomainName } from '~/utils';
const StyledContainer = styled.div`
@ -13,23 +10,15 @@ const StyledContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
export const ActivityTargetChips = ({
targets,
}: {
targets?: Array<
Pick<ActivityTarget, 'id'> & {
person?: Pick<Person, 'id' | 'name' | 'avatarUrl'> | null;
company?: Pick<Company, 'id' | 'domainName' | 'name'> | null;
}
> | null;
}) => {
// TODO: fix edges pagination formatting on n+N
export const ActivityTargetChips = ({ targets }: { targets?: any }) => {
if (!targets) {
return null;
}
return (
<StyledContainer>
{targets.map(({ company, person }) => {
{targets?.edges?.map(({ company, person }: any) => {
if (company) {
return (
<CompanyChip

View File

@ -1,21 +1,52 @@
import { Note } from '@/activities/types/Note';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity';
export const useNotes = (entity: ActivityTargetableEntity) => {
const { objects: notes } = useFindManyObjectRecords({
objectNamePlural: 'activities',
const { objects: activityTargets } = useFindManyObjectRecords({
objectNamePlural: 'activityTargets',
filter: {
type: { equals: 'None' },
activityTargets: {
some: {
OR: [
{ companyId: { equals: entity.id } },
{ personId: { equals: entity.id } },
],
},
},
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
});
const { objectMetadataItem: activityObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: 'activity',
});
const { registerOptimisticEffect } = useOptimisticEffect({
objectNameSingular: activityObjectMetadataItem?.nameSingular,
});
const filter = {
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
type: { eq: 'Note' },
};
const orderBy = {
createdAt: 'AscNullsFirst',
};
const { objects: notes } = useFindManyObjectRecords({
skip: !activityTargets?.length,
objectNamePlural: 'activities',
filter,
orderBy,
onCompleted: () => {
if (activityObjectMetadataItem) {
registerOptimisticEffect({
variables: { orderBy, filter },
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: activityObjectMetadataItem,
}),
});
}
},
});

View File

@ -35,7 +35,7 @@ export const RightDrawerActivity = ({
);
const { object: activity } = useFindOneObjectRecord({
objectNameSingular: 'activityId',
objectNameSingular: 'activity',
objectRecordId: activityId,
skip: !activityId,
onCompleted: (activity: Activity) => {

View File

@ -2,55 +2,108 @@ import { DateTime } from 'luxon';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { useFilter } from '@/ui/object/object-filter-dropdown/hooks/useFilter';
import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2';
import { parseDate } from '~/utils/date-utils';
export const useTasks = (entity?: ActivityTargetableEntity) => {
const { selectedFilter } = useFilter();
const whereFilters = entity
? {
activityTargets: {
some: {
OR: [
{ companyId: { equals: entity.id } },
{ personId: { equals: entity.id } },
],
},
},
}
: Object.assign({}, turnFiltersIntoWhereClauseV2([], []));
const { objects: activityTargets } = useFindManyObjectRecords({
objectNamePlural: 'activityTargets',
filter: {
[entity?.type === 'Company' ? 'companyId' : 'personId']: {
eq: entity?.id,
},
},
});
const { objectMetadataItem: activityObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: 'activity',
});
const { registerOptimisticEffect } = useOptimisticEffect({
objectNameSingular: activityObjectMetadataItem?.nameSingular,
});
const { objects: completeTasksData } = useFindManyObjectRecords({
objectNamePlural: 'activities',
skip: !entity && !selectedFilter,
filter: {
type: { equals: 'Task' },
completedAt: { is: 'NOT_NULL' },
...whereFilters,
},
orderBy: [
{
createdAt: 'AscNullIsFirst',
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
],
type: { eq: 'Task' },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
onCompleted: () => {
if (activityObjectMetadataItem) {
registerOptimisticEffect({
variables: {
filter: {
completedAt: { is: 'NOT_NULL' },
id: {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
},
type: { eq: 'Task' },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
},
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: activityObjectMetadataItem,
}),
});
}
},
});
const { objects: incompleteTaskData } = useFindManyObjectRecords({
objectNamePlural: 'activities',
skip: !entity && !selectedFilter,
filter: {
type: { equals: 'Task' },
completedAt: { is: 'NULL' },
...whereFilters,
},
orderBy: [
{
createdAt: 'DescNullIsFirst',
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
],
type: { eq: 'Task' },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
onCompleted: () => {
if (activityObjectMetadataItem) {
registerOptimisticEffect({
variables: {
filter: {
completedAt: { is: 'NULL' },
id: {
in: activityTargets?.map(
(activityTarget) => activityTarget.activityId,
),
},
type: { eq: 'Task' },
},
orderBy: {
createdAt: 'DescNullsFirst',
},
},
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: activityObjectMetadataItem,
}),
});
}
},
});
const todayOrPreviousTasks = incompleteTaskData?.filter((task) => {

View File

@ -51,10 +51,7 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => {
const { objects: activityTargets, loading } = useFindManyObjectRecords({
objectNamePlural: 'activityTargets',
filter: {
or: {
companyId: { eq: entity.id },
personId: { eq: entity.id },
},
[entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id },
},
});
@ -62,7 +59,9 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => {
skip: !activityTargets?.length,
objectNamePlural: 'activities',
filter: {
activityTargets: { in: activityTargets?.map((at) => at.id) },
id: {
in: activityTargets?.map((activityTarget) => activityTarget.activityId),
},
},
orderBy: {
createdAt: 'AscNullsFirst',

View File

@ -24,26 +24,25 @@ export const useCreateOneObjectRecord = <T>({
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(createOneMutation);
const createOneObject =
objectNameSingular && foundObjectMetadataItem
? async (input: Record<string, any>) => {
const createdObject = await mutate({
variables: {
input: { ...input, id: v4() },
},
});
const createOneObject = async (input: Record<string, any>) => {
if (!foundObjectMetadataItem || !objectNameSingular) {
return null;
}
triggerOptimisticEffects(
`${capitalize(foundObjectMetadataItem.nameSingular)}Edge`,
createdObject.data[
`create${capitalize(foundObjectMetadataItem.nameSingular)}`
],
);
return createdObject.data[
`create${capitalize(objectNameSingular)}`
] as T;
}
: undefined;
const createdObject = await mutate({
variables: {
input: { ...input, id: v4() },
},
});
triggerOptimisticEffects(
`${capitalize(foundObjectMetadataItem.nameSingular)}Edge`,
createdObject.data[
`create${capitalize(foundObjectMetadataItem.nameSingular)}`
],
);
return createdObject.data[`create${capitalize(objectNameSingular)}`] as T;
};
return {
createOneObject,

View File

@ -23,20 +23,18 @@ export const useDeleteOneObjectRecord = <T>({
const deleteOneObject = useCallback(
async (idToDelete: string) => {
if (objectNameSingular && foundObjectMetadataItem) {
const deletedObject = await mutate({
variables: {
idToDelete,
},
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
return deletedObject.data[
`create${capitalize(objectNameSingular)}`
] as T;
if (!foundObjectMetadataItem || !objectNameSingular) {
return null;
}
return null;
const deletedObject = await mutate({
variables: {
idToDelete,
},
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
return deletedObject.data[`create${capitalize(objectNameSingular)}`] as T;
},
[foundObjectMetadataItem, mutate, objectNameSingular, findManyQuery],
);

View File

@ -48,20 +48,17 @@ export const useFindManyObjectRecords = <
isFetchingMoreObjectsFamilyState(objectNamePlural),
);
const {
objectMetadataItem: foundObjectMetadataItem,
objectNotFoundInMetadata,
findManyQuery,
} = useObjectMetadataItem({
objectNamePlural,
});
const { objectMetadataItem, objectNotFoundInMetadata, findManyQuery } =
useObjectMetadataItem({
objectNamePlural,
});
const { enqueueSnackBar } = useSnackBar();
const { data, loading, error, fetchMore } = useQuery<
PaginatedObjectType<ObjectType>
>(findManyQuery, {
skip: skip || !foundObjectMetadataItem || !objectNamePlural,
skip: skip || !objectMetadataItem || !objectNamePlural,
variables: {
filter: filter ?? {},
orderBy: orderBy ?? {},
@ -130,7 +127,7 @@ export const useFindManyObjectRecords = <
return Object.assign({}, prev, {
[objectNamePlural]: {
__typename: `${capitalize(
foundObjectMetadataItem?.nameSingular ?? '',
objectMetadataItem?.nameSingular ?? '',
)}Connection`,
edges: newEdges,
pageInfo: fetchMoreResult?.[objectNamePlural].pageInfo,
@ -156,7 +153,7 @@ export const useFindManyObjectRecords = <
fetchMore,
filter,
orderBy,
foundObjectMetadataItem,
objectMetadataItem,
hasNextPage,
setIsFetchingMoreObjects,
enqueueSnackBar,
@ -174,6 +171,7 @@ export const useFindManyObjectRecords = <
);
return {
objectMetadataItem,
objects,
loading,
error,

View File

@ -18,29 +18,28 @@ export const useUpdateOneObjectRecord = <T>({
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(updateOneMutation);
const updateOneObject =
objectNameSingular && foundObjectMetadataItem
? async ({
idToUpdate,
input,
}: {
idToUpdate: string;
input: Record<string, any>;
}) => {
const updatedObject = await mutate({
variables: {
idToUpdate: idToUpdate,
input: {
...input,
},
},
});
const updateOneObject = async ({
idToUpdate,
input,
}: {
idToUpdate: string;
input: Record<string, any>;
}) => {
if (!foundObjectMetadataItem || !objectNameSingular) {
return null;
}
return updatedObject.data[
`update${capitalize(objectNameSingular)}`
] as T;
}
: undefined;
const updatedObject = await mutate({
variables: {
idToUpdate: idToUpdate,
input: {
...input,
},
},
});
return updatedObject.data[`update${capitalize(objectNameSingular)}`] as T;
};
return {
updateOneObject,

View File

@ -259,7 +259,7 @@ export const seedActivityFieldMetadata = async (
workspaceId: SeedWorkspaceId,
isActive: true,
type: FieldMetadataType.RELATION,
name: 'Comments',
name: 'comments',
label: 'Comments',
targetColumnMap: {},
description: 'Activity comments',

View File

@ -130,7 +130,7 @@ export const seedCommentFieldMetadata = async (
targetColumnMap: {},
description: 'Comment author',
icon: 'IconCircleUser',
isNullable: false,
isNullable: true,
isSystem: false,
defaultValue: undefined,
},
@ -146,7 +146,7 @@ export const seedCommentFieldMetadata = async (
targetColumnMap: {},
description: 'Comment author id foreign key',
icon: undefined,
isNullable: false,
isNullable: true,
isSystem: true,
defaultValue: undefined,
},
@ -162,7 +162,7 @@ export const seedCommentFieldMetadata = async (
targetColumnMap: {},
description: 'Comment activity',
icon: 'IconNotes',
isNullable: false,
isNullable: true,
isSystem: false,
defaultValue: undefined,
},
@ -178,7 +178,7 @@ export const seedCommentFieldMetadata = async (
targetColumnMap: {},
description: 'Activity id foreign key',
icon: undefined,
isNullable: false,
isNullable: true,
isSystem: true,
defaultValue: undefined,
},

View File

@ -37,7 +37,7 @@ const commentMetadata = {
},
description: 'Comment author',
icon: 'IconCircleUser',
isNullable: false,
isNullable: true,
},
{
isCustom: false,
@ -50,7 +50,7 @@ const commentMetadata = {
},
description: 'Comment activity',
icon: 'IconNotes',
isNullable: false,
isNullable: true,
},
],
};