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:
+64
@@ -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);
|
||||
});
|
||||
});
|
||||
+47
@@ -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));
|
||||
});
|
||||
});
|
||||
+121
@@ -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);
|
||||
});
|
||||
});
|
||||
+92
@@ -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();
|
||||
});
|
||||
});
|
||||
+48
@@ -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;
|
||||
};
|
||||
+33
@@ -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);
|
||||
};
|
||||
+130
@@ -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 };
|
||||
};
|
||||
+214
@@ -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
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+8
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+9
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+9
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+69
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+30
@@ -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),
|
||||
};
|
||||
};
|
||||
+9
@@ -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[];
|
||||
};
|
||||
+10
@@ -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,
|
||||
};
|
||||
};
|
||||
+7
-24
@@ -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));
|
||||
|
||||
+119
@@ -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,
|
||||
};
|
||||
};
|
||||
+37
-4
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+20
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
+29
-10
@@ -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) : '');
|
||||
|
||||
Reference in New Issue
Block a user