Fixed various bugs in activity creation (#6208)

- Fixed activity creation in cache
- Fixed activity creation in DB, where the relation target was
disappearing after creation
- Added an option to match root query filter in creation optimistic
effect to avoid adding the newly created record in every mounted query
in Apollo cache on the same object (which was causing notes to be
duplicated on every object in the cache)
- Fixed tab list scope id
- Fixed various browser console warnings
This commit is contained in:
Lucas Bordeau
2024-07-10 19:43:52 +02:00
committed by GitHub
parent 34d13a7b58
commit 6bc36635eb
11 changed files with 135 additions and 127 deletions

View File

@ -3,35 +3,98 @@ import { isNonEmptyArray } from '@sniptt/guards';
import { CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE } from '@/activities/graphql/operation-signatures/CreateOneActivityOperationSignature';
import { ActivityForEditor } from '@/activities/types/ActivityForEditor';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateActivityInDB = () => {
const { createOneRecord: createOneActivity } = useCreateOneRecord({
objectNameSingular:
CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE.objectNameSingular,
recordGqlFields: CREATE_ONE_ACTIVITY_OPERATION_SIGNATURE.fields,
shouldMatchRootQueryFilter: true,
});
const { createManyRecords: createManyActivityTargets } =
useCreateManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
skipPostOptmisticEffect: true,
shouldMatchRootQueryFilter: true,
});
const createActivityInDB = async (activityToCreate: ActivityForEditor) => {
await createOneActivity?.({
...activityToCreate,
updatedAt: new Date().toISOString(),
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const activityTargetsToCreate = activityToCreate.activityTargets ?? [];
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
});
if (isNonEmptyArray(activityTargetsToCreate)) {
await createManyActivityTargets(activityTargetsToCreate);
}
};
const cache = useApolloClient().cache;
const createActivityInDB = useRecoilCallback(
({ set }) =>
async (activityToCreate: ActivityForEditor) => {
const createdActivity = await createOneActivity?.({
...activityToCreate,
updatedAt: new Date().toISOString(),
});
const activityTargetsToCreate = activityToCreate.activityTargets ?? [];
if (isNonEmptyArray(activityTargetsToCreate)) {
await createManyActivityTargets(activityTargetsToCreate);
}
const activityTargetsConnection = getRecordConnectionFromRecords({
objectMetadataItems,
objectMetadataItem: objectMetadataItemActivityTarget,
records: activityTargetsToCreate.map((activityTarget) => ({
...activityTarget,
__typename: capitalize(
objectMetadataItemActivityTarget.nameSingular,
),
})),
withPageInfo: false,
computeReferences: true,
isRootLevel: false,
});
modifyRecordFromCache({
recordId: createdActivity.id,
cache,
fieldModifiers: {
activityTargets: () => activityTargetsConnection,
},
objectMetadataItem: objectMetadataItemActivity,
});
set(recordStoreFamilyState(createdActivity.id), {
...createdActivity,
activityTargets: activityTargetsToCreate,
});
},
[
cache,
createManyActivityTargets,
createOneActivity,
objectMetadataItemActivity,
objectMetadataItemActivityTarget,
objectMetadataItems,
],
);
return {
createActivityInDB,

View File

@ -1,72 +0,0 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
import { timelineActivitiesForGroupState } from '@/activities/timeline/states/timelineActivitiesForGroupState';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { TimelineItemsContainer } from './TimelineItemsContainer';
const StyledMainContainer = styled.div`
align-items: flex-start;
align-self: stretch;
border-top: ${({ theme }) =>
useIsMobile() ? `1px solid ${theme.border.color.medium}` : 'none'};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
`;
export const Timeline = ({
targetableObject,
loading,
}: {
targetableObject: ActivityTargetableObject;
loading: boolean;
}) => {
const timelineActivitiesForGroup = useRecoilValue(
timelineActivitiesForGroupState,
);
if (loading) {
return <SkeletonLoader withSubSections />;
}
if (timelineActivitiesForGroup.length === 0) {
return (
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Add your first Activity
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no activities associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup targetableObject={targetableObject} />
</AnimatedPlaceholderEmptyContainer>
);
}
return (
<StyledMainContainer>
<TimelineItemsContainer />
</StyledMainContainer>
);
};

View File

@ -1,44 +1,30 @@
import { useSetRecoilState } from 'recoil';
import { IconCheckbox, IconNotes, IconPaperclip } from 'twenty-ui';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
export const TimelineCreateButtonGroup = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
export const TimelineCreateButtonGroup = () => {
const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
const setActiveTabId = useSetRecoilState(activeTabIdState);
const openCreateActivity = useOpenCreateActivityDrawer();
return (
<ButtonGroup variant={'secondary'}>
<Button
Icon={IconNotes}
title="Note"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
})
}
onClick={() => {
setActiveTabId('notes');
}}
/>
<Button
Icon={IconCheckbox}
title="Task"
onClick={() =>
openCreateActivity({
type: 'Task',
targetableObjects: [targetableObject],
})
}
onClick={() => {
setActiveTabId('tasks');
}}
/>
<Button
Icon={IconPaperclip}

View File

@ -63,7 +63,7 @@ export const TimelineActivities = ({
There are no activities associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup targetableObject={targetableObject} />
<TimelineCreateButtonGroup />
</AnimatedPlaceholderEmptyContainer>
);
}

View File

@ -7,7 +7,11 @@ import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
/*
TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are.
@ -19,11 +23,13 @@ export const triggerCreateRecordsOptimisticEffect = ({
objectMetadataItem,
recordsToCreate,
objectMetadataItems,
shouldMatchRootQueryFilter,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
recordsToCreate: RecordGqlNode[];
objectMetadataItems: ObjectMetadataItem[];
shouldMatchRootQueryFilter?: boolean;
}) => {
recordsToCreate.forEach((record) =>
triggerUpdateRelationsOptimisticEffect({
@ -39,12 +45,7 @@ export const triggerCreateRecordsOptimisticEffect = ({
fields: {
[objectMetadataItem.namePlural]: (
rootQueryCachedResponse,
{
DELETE: _DELETE,
readField,
storeFieldName: _storeFieldName,
toReference,
},
{ DELETE: _DELETE, readField, storeFieldName, toReference },
) => {
const shouldSkip = !isObjectRecordConnectionWithRefs(
objectMetadataItem.nameSingular,
@ -55,6 +56,13 @@ export const triggerCreateRecordsOptimisticEffect = ({
return rootQueryCachedResponse;
}
const { fieldVariables: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
const rootQueryCachedRecordEdges = readField<RecordGqlRefEdge[]>(
@ -74,6 +82,22 @@ export const triggerCreateRecordsOptimisticEffect = ({
const hasAddedRecords = recordsToCreate
.map((recordToCreate) => {
if (isNonEmptyString(recordToCreate.id)) {
if (
isDefined(rootQueryFilter) &&
shouldMatchRootQueryFilter === true
) {
const recordToCreateMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: recordToCreate,
filter: rootQueryFilter,
objectMetadataItem,
});
if (!recordToCreateMatchesThisRootQueryFilter) {
return false;
}
}
const recordToCreateReference = toReference(recordToCreate);
if (!recordToCreateReference) {

View File

@ -18,6 +18,7 @@ type useCreateManyRecordsProps = {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
skipPostOptmisticEffect?: boolean;
shouldMatchRootQueryFilter?: boolean;
};
export const useCreateManyRecords = <
@ -26,6 +27,7 @@ export const useCreateManyRecords = <
objectNameSingular,
recordGqlFields,
skipPostOptmisticEffect = false,
shouldMatchRootQueryFilter,
}: useCreateManyRecordsProps) => {
const apolloClient = useApolloClient();
@ -88,6 +90,7 @@ export const useCreateManyRecords = <
objectMetadataItem,
recordsToCreate: recordsCreatedInCache,
objectMetadataItems,
shouldMatchRootQueryFilter,
});
}
@ -111,6 +114,7 @@ export const useCreateManyRecords = <
objectMetadataItem,
recordsToCreate: records,
objectMetadataItems,
shouldMatchRootQueryFilter,
});
},
});

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useApolloClient } from '@apollo/client';
import { useState } from 'react';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
@ -19,6 +19,7 @@ type useCreateOneRecordProps = {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
skipPostOptmisticEffect?: boolean;
shouldMatchRootQueryFilter?: boolean;
};
export const useCreateOneRecord = <
@ -27,6 +28,7 @@ export const useCreateOneRecord = <
objectNameSingular,
recordGqlFields,
skipPostOptmisticEffect = false,
shouldMatchRootQueryFilter,
}: useCreateOneRecordProps) => {
const apolloClient = useApolloClient();
const [loading, setLoading] = useState(false);
@ -76,6 +78,7 @@ export const useCreateOneRecord = <
objectMetadataItem,
recordsToCreate: [recordCreatedInCache],
objectMetadataItems,
shouldMatchRootQueryFilter,
});
}
@ -97,7 +100,9 @@ export const useCreateOneRecord = <
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
shouldMatchRootQueryFilter,
});
setLoading(false);
},
});

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { RecordDetailRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsList';
import { RecordDetailRelationRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem';
@ -20,7 +20,7 @@ export const RecordDetailRelationRecordsList = ({
return (
<RecordDetailRecordsList>
{relationRecords.slice(0, 5).map((relationRecord) => (
<>
<Fragment key={relationRecord.id}>
<RecordDetailRelationRecordsListItemEffect
key={`${relationRecord.id}-effect`}
relationRecordId={relationRecord.id}
@ -31,7 +31,7 @@ export const RecordDetailRelationRecordsList = ({
onClick={handleItemClick}
relationRecord={relationRecord}
/>
</>
</Fragment>
))}
</RecordDetailRecordsList>
);

View File

@ -11,17 +11,17 @@ const StyledTbody = styled.tbody<{
overflow: hidden;
&.first-columns-sticky {
td:nth-child(1) {
td:nth-of-type(1) {
position: sticky;
left: 0;
z-index: 5;
}
td:nth-child(2) {
td:nth-of-type(2) {
position: sticky;
left: 9px;
z-index: 5;
}
td:nth-child(3) {
td:nth-of-type(3) {
position: sticky;
left: 39px;
z-index: 5;

View File

@ -25,17 +25,17 @@ const StyledTableHead = styled.thead<{
}
&.first-columns-sticky {
th:nth-child(1) {
th:nth-of-type(1) {
position: sticky;
left: 0;
z-index: 5;
}
th:nth-child(2) {
th:nth-of-type(2) {
position: sticky;
left: 9px;
z-index: 5;
}
th:nth-child(3) {
th:nth-of-type(3) {
position: sticky;
left: 39px;
z-index: 5;
@ -55,9 +55,9 @@ const StyledTableHead = styled.thead<{
}
&.header-sticky.first-columns-sticky {
th:nth-child(1),
th:nth-child(2),
th:nth-child(3) {
th:nth-of-type(1),
th:nth-of-type(2),
th:nth-of-type(3) {
z-index: 10;
}
}

View File

@ -66,9 +66,7 @@ export const ShowPageRightContainer = ({
summary,
isRightDrawer = false,
}: ShowPageRightContainerProps) => {
const { activeTabIdState } = useTabList(
TAB_LIST_COMPONENT_ID + isRightDrawer,
);
const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
const targetObjectNameSingular =
@ -147,7 +145,7 @@ export const ShowPageRightContainer = ({
<StyledTabListContainer>
<TabList
loading={loading}
tabListId={TAB_LIST_COMPONENT_ID + isRightDrawer}
tabListId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
/>
</StyledTabListContainer>