Add useGroupBy hook + update calendar view to use groupBy (#15439)

## Context
Took inspiration from findMany + kanban => Implemented a hook for the
new groupBy API endpoints
Fixed an issue when DnD to an empty day
This commit is contained in:
Weiko
2025-10-31 18:39:22 +01:00
committed by GitHub
parent bb471cb8a5
commit 09c704c8bd
24 changed files with 1218 additions and 39 deletions
@@ -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);
});
});
@@ -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));
});
});
@@ -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);
});
});
@@ -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<unknown>;
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<unknown>;
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<unknown>;
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<unknown>;
triggerUpdateGroupByQueriesOptimisticEffect({
cache: mockCache,
objectMetadataItem: mockObjectMetadataItem,
operation: 'create',
records: [],
shouldMatchRootQueryFilter: false,
});
expect(mockModify).toHaveBeenCalled();
});
});
@@ -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<Record<string, boolean | Record<string, string>>>,
): 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;
};
@@ -0,0 +1,33 @@
import { isDefined } from 'twenty-shared/utils';
export const normalizeGroupByDimensionValue = (
value: any,
fieldConfig: boolean | Record<string, string> | 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);
};
@@ -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<Record<string, boolean | Record<string, string>>>;
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 };
};
@@ -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<unknown>;
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<StoreObject>({
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<RecordGqlRefEdge[]>('edges', groupConnection) || [];
const cachedTotalCount = readField<number | undefined>(
'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;
},
},
});
};
@@ -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,
});
};
@@ -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,
});
};
@@ -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,
});
};
@@ -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,
});
};
@@ -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);
});
});
@@ -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),
};
};
@@ -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[];
};
@@ -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<string, any>[];
filter?: RecordGqlOperationFilter;
orderBy?: Record<string, any>[];
orderByForRecords?: RecordGqlOperationOrderBy;
viewId?: string;
};
@@ -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,
};
};
@@ -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));
@@ -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<ObjectRecord>({
recordConnection: group,
}),
);
// Deduplicate records by ID to prevent duplicate keys in React
const uniqueRecordsMap = new Map<string, ObjectRecord>();
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,
};
};
@@ -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,
};
};
@@ -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;
},
});
@@ -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];
@@ -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
}
}
`;
@@ -6,11 +6,14 @@ export const getQueryIdentifier = ({
orderBy,
limit,
cursorFilter,
groupBy,
}: RecordGqlOperationVariables & {
objectNameSingular: string;
groupBy?: Record<string, any>[];
}) =>
objectNameSingular +
JSON.stringify(filter) +
JSON.stringify(orderBy) +
limit +
(cursorFilter ? JSON.stringify(cursorFilter) : undefined);
(cursorFilter ? JSON.stringify(cursorFilter) : undefined) +
(groupBy ? JSON.stringify(groupBy) : '');