diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/doesRecordBelongToGroup.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/doesRecordBelongToGroup.test.ts new file mode 100644 index 0000000000..b815c54bce --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/doesRecordBelongToGroup.test.ts @@ -0,0 +1,64 @@ +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; + +import { doesRecordBelongToGroup } from '../doesRecordBelongToGroup'; + +describe('doesRecordBelongToGroup', () => { + it('should return true when groupByConfig is undefined', () => { + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + }; + + const result = doesRecordBelongToGroup(record, ['value1'], undefined); + + expect(result).toBe(true); + }); + + it('should return true when groupByConfig is empty', () => { + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + }; + + const result = doesRecordBelongToGroup(record, ['value1'], []); + + expect(result).toBe(true); + }); + + it('should return true when record matches group dimension values', () => { + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + }; + + const result = doesRecordBelongToGroup(record, ['John'], [{ name: true }]); + + expect(result).toBe(true); + }); + + it('should return false when record does not match group dimension values', () => { + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + }; + + const result = doesRecordBelongToGroup(record, ['Jane'], [{ name: true }]); + + expect(result).toBe(false); + }); + + it('should return false when record field is undefined', () => { + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + }; + + const result = doesRecordBelongToGroup(record, ['John'], [{ name: true }]); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/normalizeGroupByDimensionValue.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/normalizeGroupByDimensionValue.test.ts new file mode 100644 index 0000000000..05a05467bb --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/normalizeGroupByDimensionValue.test.ts @@ -0,0 +1,47 @@ +import { normalizeGroupByDimensionValue } from '../normalizeGroupByDimensionValue'; + +describe('normalizeGroupByDimensionValue', () => { + it('should convert string to string', () => { + const result = normalizeGroupByDimensionValue('test', undefined); + expect(result).toBe('test'); + }); + + it('should convert number to string', () => { + const result = normalizeGroupByDimensionValue(123, undefined); + expect(result).toBe('123'); + }); + + it('should handle date with DAY granularity', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const result = normalizeGroupByDimensionValue(date, { granularity: 'DAY' }); + expect(result).toBe('2024-01-15'); + }); + + it('should handle date with MONTH granularity', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const result = normalizeGroupByDimensionValue(date, { + granularity: 'MONTH', + }); + expect(result).toBe('2024-01'); + }); + + it('should handle date with YEAR granularity', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const result = normalizeGroupByDimensionValue(date, { + granularity: 'YEAR', + }); + expect(result).toBe('2024'); + }); + + it('should handle object with id', () => { + const obj = { id: '123', name: 'test' }; + const result = normalizeGroupByDimensionValue(obj, undefined); + expect(result).toBe('123'); + }); + + it('should handle object without id', () => { + const obj = { name: 'test' }; + const result = normalizeGroupByDimensionValue(obj, undefined); + expect(result).toBe(JSON.stringify(obj)); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts new file mode 100644 index 0000000000..a440e4e539 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/processGroupByConnectionWithRecords.test.ts @@ -0,0 +1,121 @@ +import { type Reference } from '@apollo/client'; +import { + type ReadFieldFunction, + type ToReferenceFunction, +} from '@apollo/client/cache/core/types/common'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; + +import { processGroupByConnectionWithRecords } from '../processGroupByConnectionWithRecords'; + +describe('processGroupByConnectionWithRecords', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem; + + const mockRecord: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + deletedAt: null, + }; + + const mockReference: Reference = { + __ref: 'Person:123', + }; + + const mockReadField = jest.fn( + (fieldName: any, from: any, ..._args: any[]) => { + if (fieldName === 'id' && from === mockReference) { + return '123'; + } + return undefined; + }, + ) as unknown as ReadFieldFunction; + + const mockToReference: ToReferenceFunction = jest.fn(() => mockReference); + + it('should return cached data when no records match', () => { + const cachedEdges: RecordGqlRefEdge[] = []; + const cachedPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + }; + + const result = processGroupByConnectionWithRecords({ + cachedEdges, + cachedPageInfo, + records: [], + operation: 'create', + queryFilter: {}, + shouldMatchRootQueryFilter: false, + groupByDimensionValues: [], + groupByConfig: undefined, + objectMetadataItem: mockObjectMetadataItem, + readField: mockReadField, + toReference: mockToReference, + }); + + expect(result.nextEdges).toEqual([]); + expect(result.totalCountDelta).toBe(0); + }); + + it('should handle create operation', () => { + const cachedEdges: RecordGqlRefEdge[] = []; + const cachedPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + }; + + const result = processGroupByConnectionWithRecords({ + cachedEdges, + cachedPageInfo, + records: [mockRecord], + operation: 'create', + queryFilter: {}, + shouldMatchRootQueryFilter: false, + groupByDimensionValues: [], + groupByConfig: undefined, + objectMetadataItem: mockObjectMetadataItem, + readField: mockReadField, + toReference: mockToReference, + }); + + expect(result.totalCountDelta).toBe(1); + expect(result.nextEdges.length).toBe(1); + }); + + it('should handle delete operation', () => { + const mockEdge: RecordGqlRefEdge = { + __typename: 'PersonEdge', + node: mockReference, + cursor: 'cursor123', + }; + + const cachedEdges: RecordGqlRefEdge[] = [mockEdge]; + const cachedPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + }; + + const result = processGroupByConnectionWithRecords({ + cachedEdges, + cachedPageInfo, + records: [mockRecord], + operation: 'delete', + queryFilter: {}, + shouldMatchRootQueryFilter: false, + groupByDimensionValues: [], + groupByConfig: undefined, + objectMetadataItem: mockObjectMetadataItem, + readField: mockReadField, + toReference: mockToReference, + }); + + expect(result.totalCountDelta).toBe(-1); + expect(result.nextEdges.length).toBe(0); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts new file mode 100644 index 0000000000..90d6783ea6 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/__tests__/triggerUpdateGroupByQueriesOptimisticEffect.test.ts @@ -0,0 +1,92 @@ +import { type ApolloCache } from '@apollo/client'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; + +import { triggerUpdateGroupByQueriesOptimisticEffect } from '../triggerUpdateGroupByQueriesOptimisticEffect'; + +describe('triggerUpdateGroupByQueriesOptimisticEffect', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem; + + const mockRecord: RecordGqlNode = { + __typename: 'Person', + id: '123', + name: 'John', + }; + + it('should call cache.modify with correct field name', () => { + const mockModify = jest.fn(); + const mockCache = { + modify: mockModify, + } as unknown as ApolloCache; + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache: mockCache, + objectMetadataItem: mockObjectMetadataItem, + operation: 'create', + records: [mockRecord], + shouldMatchRootQueryFilter: false, + }); + + expect(mockModify).toHaveBeenCalledWith({ + broadcast: false, + fields: expect.objectContaining({ + peopleGroupBy: expect.any(Function), + }), + }); + }); + + it('should handle update operation', () => { + const mockModify = jest.fn(); + const mockCache = { + modify: mockModify, + } as unknown as ApolloCache; + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache: mockCache, + objectMetadataItem: mockObjectMetadataItem, + operation: 'update', + records: [mockRecord], + shouldMatchRootQueryFilter: false, + }); + + expect(mockModify).toHaveBeenCalled(); + }); + + it('should handle delete operation', () => { + const mockModify = jest.fn(); + const mockCache = { + modify: mockModify, + } as unknown as ApolloCache; + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache: mockCache, + objectMetadataItem: mockObjectMetadataItem, + operation: 'delete', + records: [mockRecord], + shouldMatchRootQueryFilter: false, + }); + + expect(mockModify).toHaveBeenCalled(); + }); + + it('should handle empty records array', () => { + const mockModify = jest.fn(); + const mockCache = { + modify: mockModify, + } as unknown as ApolloCache; + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache: mockCache, + objectMetadataItem: mockObjectMetadataItem, + operation: 'create', + records: [], + shouldMatchRootQueryFilter: false, + }); + + expect(mockModify).toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/doesRecordBelongToGroup.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/doesRecordBelongToGroup.ts new file mode 100644 index 0000000000..775f7a961e --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/doesRecordBelongToGroup.ts @@ -0,0 +1,48 @@ +import { normalizeGroupByDimensionValue } from '@/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { isDefined } from 'twenty-shared/utils'; + +export const doesRecordBelongToGroup = ( + record: RecordGqlNode, + groupByDimensionValues: readonly string[], + groupByConfig?: Array>>, +): boolean => { + if (!isDefined(groupByConfig) || groupByConfig.length === 0) { + return true; + } + + const groupByFieldNames = groupByConfig.map( + (groupByField) => Object.keys(groupByField)[0], + ); + + for (let i = 0; i < groupByFieldNames.length; i++) { + const fieldName = groupByFieldNames[i]; + const expectedValue = groupByDimensionValues[i]; + + if (!isDefined(expectedValue)) { + continue; + } + + let recordValue = record[fieldName]; + + if (!isDefined(recordValue)) { + return false; + } + + const fieldConfig = groupByConfig[i][fieldName]; + const recordValueStr = normalizeGroupByDimensionValue( + recordValue, + fieldConfig, + ); + const expectedValueStr = normalizeGroupByDimensionValue( + expectedValue, + fieldConfig, + ); + + if (recordValueStr !== expectedValueStr) { + return false; + } + } + + return true; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue.ts new file mode 100644 index 0000000000..9175485307 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue.ts @@ -0,0 +1,33 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const normalizeGroupByDimensionValue = ( + value: any, + fieldConfig: boolean | Record | undefined, +): string => { + if (typeof fieldConfig === 'object' && isDefined(fieldConfig.granularity)) { + const dateValue = new Date(value); + const granularity = fieldConfig.granularity; + + // TODO: to remove once backend properly returns DATE without time + switch (granularity) { + case 'DAY': + return dateValue.toISOString().split('T')[0]; + case 'MONTH': + return dateValue.toISOString().substring(0, 7); + case 'YEAR': + return dateValue.getFullYear().toString(); + case 'DAY_OF_THE_WEEK': + return dateValue.getDay().toString(); + case 'MONTH_OF_THE_YEAR': + return (dateValue.getMonth() + 1).toString(); + default: + return String(value); + } + } + + if (typeof value === 'object' && value !== null) { + return value.id ? String(value.id) : JSON.stringify(value); + } + + return String(value); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts new file mode 100644 index 0000000000..775a0f0773 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords.ts @@ -0,0 +1,130 @@ +import { doesRecordBelongToGroup } from '@/apollo/optimistic-effect/group-by/utils/doesRecordBelongToGroup'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { createCacheEdgeWithRecordRef } from '@/object-record/cache/utils/createCacheEdgeWithRecordRef'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { + type ReadFieldFunction, + type ToReferenceFunction, +} from '@apollo/client/cache/core/types/common'; +import { isDefined } from 'twenty-shared/utils'; + +type ProcessGroupByConnectionWithRecordsArgs = { + cachedEdges: readonly RecordGqlRefEdge[]; + cachedPageInfo: { + startCursor?: string; + endCursor?: string; + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; + records: RecordGqlNode[]; + operation: 'create' | 'update' | 'delete'; + queryFilter: any; + shouldMatchRootQueryFilter: boolean; + groupByDimensionValues: readonly string[]; + groupByConfig?: Array>>; + objectMetadataItem: ObjectMetadataItem; + readField: ReadFieldFunction; + toReference: ToReferenceFunction; +}; + +export const processGroupByConnectionWithRecords = ({ + cachedEdges, + cachedPageInfo, + records, + operation, + queryFilter, + shouldMatchRootQueryFilter, + groupByDimensionValues, + groupByConfig, + objectMetadataItem, + readField, + toReference, +}: ProcessGroupByConnectionWithRecordsArgs): { + nextEdges: RecordGqlRefEdge[]; + nextPageInfo: { + startCursor?: string; + endCursor?: string; + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; + totalCountDelta: number; +} => { + const nextEdges = [...cachedEdges]; + const nextPageInfo = isDefined(cachedPageInfo) ? { ...cachedPageInfo } : {}; + let totalCountDelta = 0; + + for (const record of records) { + const recordMatchesFilter = isRecordMatchingFilter({ + record, + filter: queryFilter ?? {}, + objectMetadataItem, + }); + + const belongsToGroup = doesRecordBelongToGroup( + record, + groupByDimensionValues, + groupByConfig, + ); + + const recordReference = toReference(record); + + if (!recordReference) { + continue; + } + + const recordIndexInEdges = cachedEdges.findIndex( + (cachedEdge) => readField('id', cachedEdge.node) === record.id, + ); + const recordExistsInEdges = recordIndexInEdges !== -1; + + if (operation === 'create') { + const shouldAdd = + (!shouldMatchRootQueryFilter || recordMatchesFilter) && + belongsToGroup && + !recordExistsInEdges; + + if (shouldAdd) { + const edge = createCacheEdgeWithRecordRef({ + record, + objectMetadataItem, + toReference, + }); + + if (isDefined(edge)) { + nextEdges.unshift(edge); + nextPageInfo.startCursor = edge.cursor; + totalCountDelta++; + } + } + } + + if (operation === 'update') { + const shouldBeInGroup = recordMatchesFilter && belongsToGroup; + + if (shouldBeInGroup && !recordExistsInEdges) { + const edge = createCacheEdgeWithRecordRef({ + record, + objectMetadataItem, + toReference, + }); + + if (isDefined(edge)) { + nextEdges.push(edge); + totalCountDelta++; + } + } else if (!shouldBeInGroup && recordExistsInEdges) { + nextEdges.splice(recordIndexInEdges, 1); + totalCountDelta--; + } + } + + if (operation === 'delete' && recordExistsInEdges) { + nextEdges.splice(recordIndexInEdges, 1); + totalCountDelta--; + } + } + + return { nextEdges, nextPageInfo, totalCountDelta }; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts new file mode 100644 index 0000000000..0359507749 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect.ts @@ -0,0 +1,214 @@ +import { type ApolloCache, type StoreObject } from '@apollo/client'; + +import { normalizeGroupByDimensionValue } from '@/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue'; +import { processGroupByConnectionWithRecords } from '@/apollo/optimistic-effect/group-by/utils/processGroupByConnectionWithRecords'; +import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { createCacheEdgeWithRecordRef } from '@/object-record/cache/utils/createCacheEdgeWithRecordRef'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { type RecordGqlGroupByConnection } from '@/object-record/graphql/types/RecordGqlOperationGroupByResult'; +import { type RecordGqlOperationGroupByVariables } from '@/object-record/graphql/types/RecordGqlOperationGroupByVariables'; +import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { isDefined } from 'twenty-shared/utils'; +import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; + +type TriggerUpdateGroupByQueriesOptimisticEffectArgs = { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + operation: 'create' | 'update' | 'delete'; + records: RecordGqlNode[]; + shouldMatchRootQueryFilter?: boolean; +}; + +export const triggerUpdateGroupByQueriesOptimisticEffect = ({ + cache, + objectMetadataItem, + operation, + records, + shouldMatchRootQueryFilter = false, +}: TriggerUpdateGroupByQueriesOptimisticEffectArgs) => { + const groupByQueryFieldName = `${objectMetadataItem.namePlural}GroupBy`; + + cache.modify({ + broadcast: false, + fields: { + [groupByQueryFieldName]: ( + cachedGroupByQueryResult, + { readField, toReference, storeFieldName }, + ) => { + const cachedGroupByConnections = cachedGroupByQueryResult as + | RecordGqlGroupByConnection[] + | undefined; + + if (!Array.isArray(cachedGroupByConnections)) { + return cachedGroupByQueryResult; + } + + const { fieldVariables: queryVariables } = parseApolloStoreFieldName< + CachedObjectRecordQueryVariables & RecordGqlOperationGroupByVariables + >(storeFieldName); + + const queryFilter = queryVariables?.filter; + const groupByConfig = queryVariables?.groupBy; + + const updatedGroupByConnections = cachedGroupByConnections.map( + (groupConnection) => { + const groupByDimensionValues = + readField('groupByDimensionValues', groupConnection) || []; + const cachedEdges = + readField('edges', groupConnection) || []; + const cachedTotalCount = readField( + 'totalCount', + groupConnection, + ); + const cachedPageInfo = readField<{ + startCursor?: string; + endCursor?: string; + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }>('pageInfo', groupConnection); + + const { nextEdges, nextPageInfo, totalCountDelta } = + processGroupByConnectionWithRecords({ + cachedEdges, + cachedPageInfo: cachedPageInfo || {}, + records, + operation, + queryFilter, + shouldMatchRootQueryFilter, + groupByDimensionValues: Array.isArray(groupByDimensionValues) + ? groupByDimensionValues + : [], + groupByConfig, + objectMetadataItem, + readField, + toReference, + }); + + if ( + totalCountDelta === 0 && + nextEdges.length === cachedEdges.length + ) { + return groupConnection; + } + + return { + ...groupConnection, + edges: nextEdges, + totalCount: isDefined(cachedTotalCount) + ? cachedTotalCount + totalCountDelta + : undefined, + pageInfo: nextPageInfo, + }; + }, + ); + + if (operation === 'create' || operation === 'update') { + const recordsToAddToNewGroups: Map< + string, + { + dimensionValues: string[]; + edges: RecordGqlRefEdge[]; + } + > = new Map(); + + for (const record of records) { + const recordMatchesFilter = isRecordMatchingFilter({ + record, + filter: queryFilter ?? {}, + objectMetadataItem, + }); + + if ( + shouldMatchRootQueryFilter && + !recordMatchesFilter && + operation === 'create' + ) { + continue; + } + + if (!isDefined(groupByConfig) || groupByConfig.length === 0) { + continue; + } + + const groupByFieldNames = groupByConfig.map( + (groupByField) => Object.keys(groupByField)[0], + ); + + const recordDimensionValues: string[] = []; + + for (let i = 0; i < groupByFieldNames.length; i++) { + const fieldName = groupByFieldNames[i]; + let recordValue = record[fieldName]; + + if (!isDefined(recordValue)) { + break; + } + + const fieldConfig = groupByConfig[i][fieldName]; + const normalizedValue = normalizeGroupByDimensionValue( + recordValue, + fieldConfig, + ); + recordDimensionValues.push(normalizedValue); + } + + const dimensionKey = recordDimensionValues.join('|'); + const dimensionExists = updatedGroupByConnections.some((conn) => { + const connDimensionValues = + readField('groupByDimensionValues', conn) || []; + return ( + Array.isArray(connDimensionValues) && + connDimensionValues.join('|') === dimensionKey + ); + }); + + if ( + !dimensionExists && + recordDimensionValues.length === groupByFieldNames.length + ) { + const edge = createCacheEdgeWithRecordRef({ + record, + objectMetadataItem, + toReference, + }); + + if (isDefined(edge)) { + if (!recordsToAddToNewGroups.has(dimensionKey)) { + recordsToAddToNewGroups.set(dimensionKey, { + dimensionValues: recordDimensionValues, + edges: [], + }); + } + + recordsToAddToNewGroups.get(dimensionKey)!.edges.push(edge); + } + } + } + + for (const [_, groupData] of recordsToAddToNewGroups) { + if (groupData.edges.length > 0) { + const newGroupConnection = { + __typename: `${objectMetadataItem.nameSingular}Connection`, + edges: groupData.edges, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: groupData.edges[0].cursor, + endCursor: groupData.edges[groupData.edges.length - 1].cursor, + }, + totalCount: groupData.edges.length, + groupByDimensionValues: groupData.dimensionValues, + }; + + updatedGroupByConnections.push(newGroupConnection); + } + } + } + + return updatedGroupByConnections; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 68d3200410..a5b611f8a7 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -9,6 +9,7 @@ import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/is import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect'; import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; import { encodeCursor } from '@/apollo/utils/encodeCursor'; import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; @@ -238,4 +239,12 @@ export const triggerCreateRecordsOptimisticEffect = ({ }, }, }); + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache, + objectMetadataItem, + operation: 'create', + records: recordsToCreate, + shouldMatchRootQueryFilter, + }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts index 627c1e4789..1d694a48ce 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts @@ -1,5 +1,6 @@ import { type ApolloCache, type StoreObject } from '@apollo/client'; +import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; @@ -90,4 +91,11 @@ export const triggerDestroyRecordsOptimisticEffect = ({ cache.evict({ id: cache.identify(recordToDestroy) }); }); + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache, + objectMetadataItem, + operation: 'delete', + records: recordsToDestroy, + }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index ce22f5d887..2ebbcad72c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -1,5 +1,6 @@ import { type ApolloCache, type StoreObject } from '@apollo/client'; +import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; @@ -134,4 +135,12 @@ export const triggerUpdateRecordOptimisticEffect = ({ }, }, }); + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache, + objectMetadataItem, + operation: 'update', + records: [updatedRecord], + shouldMatchRootQueryFilter: true, + }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts index 05bd68c67a..73658136b0 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -1,5 +1,6 @@ import { type ApolloCache, type StoreObject } from '@apollo/client'; +import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; @@ -141,4 +142,12 @@ export const triggerUpdateRecordOptimisticEffectByBatch = ({ }, }, }); + + triggerUpdateGroupByQueriesOptimisticEffect({ + cache, + objectMetadataItem, + operation: 'update', + records: updatedRecords, + shouldMatchRootQueryFilter: true, + }); }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/createCacheEdgeWithRecordRef.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/createCacheEdgeWithRecordRef.test.ts new file mode 100644 index 0000000000..d71c1154da --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/createCacheEdgeWithRecordRef.test.ts @@ -0,0 +1,69 @@ +import { type Reference } from '@apollo/client'; + +import { encodeCursor } from '@/apollo/utils/encodeCursor'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { type ToReferenceFunction } from '@apollo/client/cache/core/types/common'; + +import { createCacheEdgeWithRecordRef } from '../createCacheEdgeWithRecordRef'; + +describe('createCacheEdgeWithRecordRef', () => { + it('should create an edge with reference when toReference returns a reference', () => { + // Given + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + }; + + const objectMetadataItem = { + nameSingular: 'person', + } as ObjectMetadataItem; + + const mockReference: Reference = { + __ref: 'Person:123', + }; + + const toReference: ToReferenceFunction = jest.fn(() => mockReference); + + // When + const result = createCacheEdgeWithRecordRef({ + record, + objectMetadataItem, + toReference, + }); + + // Then + expect(result).not.toBeNull(); + expect(result).toEqual({ + __typename: 'PersonEdge', + node: mockReference, + cursor: encodeCursor(record), + }); + expect(toReference).toHaveBeenCalledWith(record); + }); + + it('should return null when toReference returns undefined', () => { + // Given + const record: RecordGqlNode = { + __typename: 'Person', + id: '123', + }; + + const objectMetadataItem = { + nameSingular: 'person', + } as ObjectMetadataItem; + + const toReference: ToReferenceFunction = jest.fn(() => undefined); + + // When + const result = createCacheEdgeWithRecordRef({ + record, + objectMetadataItem, + toReference, + }); + + // Then + expect(result).toBeNull(); + expect(toReference).toHaveBeenCalledWith(record); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/createCacheEdgeWithRecordRef.ts b/packages/twenty-front/src/modules/object-record/cache/utils/createCacheEdgeWithRecordRef.ts new file mode 100644 index 0000000000..ef8b4e5938 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/createCacheEdgeWithRecordRef.ts @@ -0,0 +1,30 @@ +import { encodeCursor } from '@/apollo/utils/encodeCursor'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { type ToReferenceFunction } from '@apollo/client/cache/core/types/common'; +import { isDefined } from 'twenty-shared/utils'; + +type CreateCacheEdgeWithRecordRefParams = { + record: RecordGqlNode; + objectMetadataItem: ObjectMetadataItem; + toReference: ToReferenceFunction; +}; + +export const createCacheEdgeWithRecordRef = ({ + record, + objectMetadataItem, + toReference, +}: CreateCacheEdgeWithRecordRefParams): RecordGqlRefEdge | null => { + const reference = toReference(record); + if (!isDefined(reference)) { + return null; + } + + return { + __typename: getEdgeTypename(objectMetadataItem.nameSingular), + node: reference, + cursor: encodeCursor(record), + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByResult.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByResult.ts new file mode 100644 index 0000000000..d3081aca03 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByResult.ts @@ -0,0 +1,9 @@ +import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; + +export type RecordGqlGroupByConnection = RecordGqlConnection & { + groupByDimensionValues: string[]; +}; + +export type RecordGqlOperationGroupByResult = { + [objectNamePlural: string]: RecordGqlGroupByConnection[]; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByVariables.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByVariables.ts new file mode 100644 index 0000000000..eaec29e6e2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGroupByVariables.ts @@ -0,0 +1,10 @@ +import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; + +export type RecordGqlOperationGroupByVariables = { + groupBy: Record[]; + filter?: RecordGqlOperationFilter; + orderBy?: Record[]; + orderByForRecords?: RecordGqlOperationOrderBy; + viewId?: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGroupByRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGroupByRecordsQuery.ts new file mode 100644 index 0000000000..af775369a7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useGroupByRecordsQuery.ts @@ -0,0 +1,37 @@ +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { generateGroupByRecordsQuery } from '@/object-record/utils/generateGroupByRecordsQuery'; + +export const useGroupByRecordsQuery = ({ + objectNameSingular, + recordGqlFields, + computeReferences, +}: { + objectNameSingular: string; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + computeReferences?: boolean; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const groupByRecordsQuery = generateGroupByRecordsQuery({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, + }); + + return { + groupByRecordsQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx index 9ec60dead7..f382ff28f6 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx @@ -1,18 +1,14 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; +import { useRecordCalendarGroupByRecords } from '@/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; +import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; import { recordCalendarSelectedRecordIdsComponentSelector } from '@/object-record/record-calendar/states/selectors/recordCalendarSelectedRecordIdsComponentSelector'; -import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; -import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; -import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; export const RecordIndexCalendarDataLoaderEffect = () => { const recordCalendarId = useAvailableComponentInstanceIdOrThrow( @@ -24,7 +20,9 @@ export const RecordIndexCalendarDataLoaderEffect = () => { recordCalendarId, ); - const { objectMetadataItem } = useRecordCalendarContextOrThrow(); + const recordCalendarSelectedDate = useRecoilComponentValue( + recordCalendarSelectedDateComponentState, + ); const { upsertRecordsInStore } = useUpsertRecordsInStore(); @@ -36,25 +34,10 @@ export const RecordIndexCalendarDataLoaderEffect = () => { contextStoreTargetedRecordsRuleComponentState, ); - const recordIndexCalendarFieldMetadataId = useRecoilValue( - recordIndexCalendarFieldMetadataIdState, + const { records } = useRecordCalendarGroupByRecords( + recordCalendarSelectedDate, ); - const objectNameSingular = objectMetadataItem.nameSingular; - - const params = useFindManyRecordIndexTableParams(objectNameSingular); - - const recordGqlFields = useRecordsFieldVisibleGqlFields({ - objectMetadataItem, - additionalFieldMetadataId: recordIndexCalendarFieldMetadataId, - }); - - const { records } = useFindManyRecords({ - ...params, - limit: 100, - recordGqlFields, - }); - useEffect(() => { upsertRecordsInStore(records); setRecordIndexAllRecordIdsSelector(records.map((record) => record.id)); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts new file mode 100644 index 0000000000..2c7b92afa4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts @@ -0,0 +1,119 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; +import { useGroupByRecordsQuery } from '@/object-record/hooks/useGroupByRecordsQuery'; +import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; +import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter'; +import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; +import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; +import { useQuery } from '@apollo/client'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const useRecordCalendarGroupByRecords = (selectedDate: Date) => { + const { objectMetadataItem } = useRecordCalendarContextOrThrow(); + + const recordIndexCalendarFieldMetadataId = useRecoilValue( + recordIndexCalendarFieldMetadataIdState, + ); + + const recordGqlFields = useRecordsFieldVisibleGqlFields({ + objectMetadataItem, + additionalFieldMetadataId: recordIndexCalendarFieldMetadataId, + }); + + const { dateRangeFilter } = + useRecordCalendarQueryDateRangeFilter(selectedDate); + + const calendarFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === recordIndexCalendarFieldMetadataId, + ); + + const groupBy = !isDefined(calendarFieldMetadataItem) + ? [] + : [ + buildGroupByFieldObject({ + field: calendarFieldMetadataItem, + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + }), + ]; + + const orderByForRecords: RecordGqlOperationOrderBy | undefined = + !objectMetadataItem.isRemote && + hasObjectMetadataItemPositionField(objectMetadataItem) + ? [ + { + position: 'AscNullsFirst', + }, + ] + : undefined; + + const { groupByRecordsQuery } = useGroupByRecordsQuery({ + objectNameSingular: objectMetadataItem.nameSingular, + recordGqlFields, + computeReferences: false, + }); + + const apolloCoreClient = useApolloCoreClient(); + + const { data, loading, error } = useQuery(groupByRecordsQuery, { + client: apolloCoreClient, + skip: + !isDefined(calendarFieldMetadataItem) || + groupBy.length === 0 || + Object.keys(dateRangeFilter).length === 0, + fetchPolicy: 'cache-and-network', + variables: { + groupBy, + filter: dateRangeFilter, + orderByForRecords, + }, + }); + + const groupByResults = data?.[`${objectMetadataItem.namePlural}GroupBy`]; + + const records: ObjectRecord[] = useMemo(() => { + if (!isDefined(groupByResults)) { + return []; + } + + const allRecords = groupByResults.flatMap((group: any) => + getRecordsFromRecordConnection({ + recordConnection: group, + }), + ); + + // Deduplicate records by ID to prevent duplicate keys in React + const uniqueRecordsMap = new Map(); + allRecords.forEach((record: ObjectRecord) => { + if (!uniqueRecordsMap.has(record.id)) { + uniqueRecordsMap.set(record.id, record); + } + }); + + return Array.from(uniqueRecordsMap.values()); + }, [groupByResults]); + + const groupByDimensionValues = useMemo( + () => + !isDefined(groupByResults) + ? [] + : groupByResults.flatMap( + (group: any) => group.groupByDimensionValues || [], + ), + [groupByResults], + ); + + return { + records, + groupByDimensionValues, + loading, + error, + calendarFieldMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx index da2d8b82a4..4538791fbb 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx @@ -1,11 +1,18 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import { + combineFilters, computeRecordGqlOperationFilter, isDefined, + turnAnyFieldFilterIntoRecordGqlFilter, } from 'twenty-shared/utils'; const DATE_RANGE_FILTER_AFTER_ID = 'DATE_RANGE_FILTER_AFTER_ID'; @@ -18,6 +25,20 @@ export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => { const { currentView } = useGetCurrentViewOnly(); + const currentRecordFilterGroups = useRecoilComponentValue( + currentRecordFilterGroupsComponentState, + ); + + const currentRecordFilters = useRecoilComponentValue( + currentRecordFiltersComponentState, + ); + + const { filterValueDependencies } = useFilterValueDependencies(); + + const anyFieldFilterValue = useRecoilComponentValue( + anyFieldFilterValueComponentState, + ); + if ( !isDefined(currentView) || !isDefined(currentView.calendarFieldMetadataId) @@ -50,13 +71,25 @@ export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => { }; const dateRangeFilter = computeRecordGqlOperationFilter({ - filterValueDependencies: {}, - recordFilters: [dateRangeFilterAfter, dateRangeFilterBefore], - recordFilterGroups: [], + filterValueDependencies, + recordFilters: [ + ...currentRecordFilters, + dateRangeFilterAfter, + dateRangeFilterBefore, + ], + recordFilterGroups: currentRecordFilterGroups, fields: objectMetadataItem.fields, }); + const { recordGqlOperationFilter: anyFieldFilter } = + turnAnyFieldFilterIntoRecordGqlFilter({ + fields: objectMetadataItem.fields, + filterValue: anyFieldFilterValue, + }); + + const combinedFilter = combineFilters([dateRangeFilter, anyFieldFilter]); + return { - dateRangeFilter, + dateRangeFilter: combinedFilter, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts index 412b57ac40..e629a13954 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts @@ -1,4 +1,5 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; @@ -60,6 +61,25 @@ export const calendarDayRecordIdsComponentFamilySelector = return isSameDay(recordDateObj, dayDate); }); + if ( + !objectMetadataItem.isRemote && + hasObjectMetadataItemPositionField(objectMetadataItem) + ) { + return recordIds.sort((a, b) => { + const recordA = get(recordStoreFamilyState(a)); + const recordB = get(recordStoreFamilyState(b)); + + const positionA = recordA?.position; + const positionB = recordB?.position; + + if (!isDefined(positionA) && !isDefined(positionB)) return 0; + if (!isDefined(positionA)) return -1; + if (!isDefined(positionB)) return 1; + + return positionA - positionB; + }); + } + return recordIds; }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts b/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts index a807fb06d6..1d26b1d619 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts @@ -58,18 +58,37 @@ export const useHandleDragOneCalendarCard = () => { .getLoadable(calendarDayRecordIdsSelector(destinationDate)) .getValue() as string[]; - const recordsWithPosition = extractRecordPositions( - destinationRecordIds, - snapshot, - ); + const targetDayIsEmpty = destinationRecordIds.length === 0; - const targetRecordId = destinationRecordIds[destinationIndex]; + let newPosition: number; - const newPosition = computeNewPositionOfDraggedRecord({ - arrayOfRecordsWithPosition: recordsWithPosition, - idOfItemToMove: recordId, - idOfTargetItem: targetRecordId, - }); + if (targetDayIsEmpty) { + newPosition = 1; + } else { + const recordsWithPosition = extractRecordPositions( + destinationRecordIds, + snapshot, + ); + + const isDroppedAfterList = + destinationIndex >= recordsWithPosition.length; + + const targetRecord = isDroppedAfterList + ? recordsWithPosition.at(-1) + : recordsWithPosition.at(destinationIndex); + + if (!isDefined(targetRecord)) { + throw new Error( + `targetRecord cannot be found in passed recordsWithPosition, this should not happen.`, + ); + } + + newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: recordsWithPosition, + idOfItemToMove: recordId, + idOfTargetItem: targetRecord.id, + }); + } const targetDate = parse(destinationDate, 'yyyy-MM-dd', new Date()); const currentFieldValue = record[calendarFieldMetadata.name]; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateGroupByRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateGroupByRecordsQuery.ts new file mode 100644 index 0000000000..26c21d8bcc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/generateGroupByRecordsQuery.ts @@ -0,0 +1,63 @@ +import { gql } from '@apollo/client'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { capitalize } from 'twenty-shared/utils'; + +export const generateGroupByRecordsQuery = ({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, +}: { + objectMetadataItem: ObjectMetadataItem; + objectMetadataItems: ObjectMetadataItem[]; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + computeReferences?: boolean; + objectPermissionsByObjectMetadataId: Record< + string, + ObjectPermissions & { objectMetadataId: string } + >; +}) => gql` +query GroupBy${capitalize( + objectMetadataItem.namePlural, +)}($groupBy: [${capitalize( + objectMetadataItem.nameSingular, +)}GroupByInput!]!, $filter: ${capitalize( + objectMetadataItem.nameSingular, +)}FilterInput, $orderBy: [${capitalize( + objectMetadataItem.nameSingular, +)}OrderByWithGroupByInput!], $orderByForRecords: [${capitalize( + objectMetadataItem.nameSingular, +)}OrderByInput], $viewId: UUID) { + ${objectMetadataItem.namePlural}GroupBy( + groupBy: $groupBy + filter: $filter + orderBy: $orderBy + orderByForRecords: $orderByForRecords + viewId: $viewId + ) { + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, + })} + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + groupByDimensionValues + } +} +`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts index 4149745ae0..7838fed7ae 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts @@ -6,11 +6,14 @@ export const getQueryIdentifier = ({ orderBy, limit, cursorFilter, + groupBy, }: RecordGqlOperationVariables & { objectNameSingular: string; + groupBy?: Record[]; }) => objectNameSingular + JSON.stringify(filter) + JSON.stringify(orderBy) + limit + - (cursorFilter ? JSON.stringify(cursorFilter) : undefined); + (cursorFilter ? JSON.stringify(cursorFilter) : undefined) + + (groupBy ? JSON.stringify(groupBy) : '');