Display and update aggregate queries in kanban views (#8833)

Closes #8752, #8753, #8754

Implements usage of aggregate queries in kanban views.

https://github.com/user-attachments/assets/732590ca-2785-4c57-82d5-d999a2279e92

TO DO

1. write tests + storybook
2. Fix values displayed should have the same format as defined in number
fields + Fix display for amountMicros

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Marie
2024-12-03 22:46:57 +01:00
committed by GitHub
parent 5e891a135b
commit 2fc247cb21
67 changed files with 1670 additions and 104 deletions

View File

@ -0,0 +1,75 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateAggregateQuery } from '../generateAggregateQuery';
describe('generateAggregateQuery', () => {
it('should generate correct aggregate query', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
nameSingular: 'company',
namePlural: 'companies',
id: 'test-id',
labelSingular: 'Company',
labelPlural: 'Companies',
isCustom: false,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fields: [],
indexMetadatas: [],
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
};
const mockRecordGqlFields = {
id: true,
name: true,
address: false,
createdAt: true,
};
const result = generateAggregateQuery({
objectMetadataItem: mockObjectMetadataItem,
recordGqlFields: mockRecordGqlFields,
});
const normalizedQuery = result.loc?.source.body.replace(/\s+/g, ' ').trim();
expect(normalizedQuery).toBe(
'query AggregateManyCompanies($filter: CompanyFilterInput) { companies(filter: $filter) { id name createdAt } }',
);
});
it('should handle empty record fields', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
nameSingular: 'person',
namePlural: 'people',
id: 'test-id',
labelSingular: 'Person',
labelPlural: 'People',
isCustom: false,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fields: [],
indexMetadatas: [],
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
};
const mockRecordGqlFields = {
id: true,
};
const result = generateAggregateQuery({
objectMetadataItem: mockObjectMetadataItem,
recordGqlFields: mockRecordGqlFields,
});
const normalizedQuery = result.loc?.source.body.replace(/\s+/g, ' ').trim();
expect(normalizedQuery).toBe(
'query AggregateManyPeople($filter: PersonFilterInput) { people(filter: $filter) { id } }',
);
});
});

View File

@ -0,0 +1,61 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { FieldMetadataType } from '~/generated/graphql';
const AMOUNT_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const PRICE_FIELD_ID = '9d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0b';
const NAME_FIELD_ID = '5d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0c';
describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => {
const mockFields = [
{ id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' },
{ id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' },
{ id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' },
];
it('should correctly map fields to available aggregate operations', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields(
mockFields as FieldMetadataItem[],
);
expect(result[AGGREGATE_OPERATIONS.sum]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.avg]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.min]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.max]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
});
it('should exclude non-numeric fields', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields([
{ id: NAME_FIELD_ID, type: FieldMetadataType.Text } as FieldMetadataItem,
]);
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
if (operation !== AGGREGATE_OPERATIONS.count) {
expect(result[operation]).toEqual([]);
}
});
});
it('should handle empty fields array', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields([]);
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
if (operation !== AGGREGATE_OPERATIONS.count) {
expect(result[operation]).toEqual([]);
}
});
});
});

View File

@ -0,0 +1,24 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
describe('initializeAvailableFieldsForAggregateOperationMap', () => {
it('should initialize empty arrays for each aggregate operation', () => {
const result = initializeAvailableFieldsForAggregateOperationMap();
expect(Object.keys(result)).toEqual(
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION),
);
Object.values(result).forEach((array) => {
expect(array).toEqual([]);
});
});
it('should not include count operation', () => {
const result = initializeAvailableFieldsForAggregateOperationMap();
expect(
result[AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingCount],
).toBeUndefined();
});
});

View File

@ -0,0 +1,57 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
import { FieldMetadataType } from '~/generated/graphql';
describe('isFieldTypeValidForAggregateOperation', () => {
it('should return true for valid field types and operations', () => {
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Number,
AGGREGATE_OPERATIONS.sum,
),
).toBe(true);
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Currency,
AGGREGATE_OPERATIONS.min,
),
).toBe(true);
});
it('should return false for invalid field types', () => {
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Text,
AGGREGATE_OPERATIONS.avg,
),
).toBe(false);
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Boolean,
AGGREGATE_OPERATIONS.max,
),
).toBe(false);
});
it('should handle all aggregate operations', () => {
const numericField = FieldMetadataType.Number;
const operations = [
AGGREGATE_OPERATIONS.min,
AGGREGATE_OPERATIONS.max,
AGGREGATE_OPERATIONS.avg,
AGGREGATE_OPERATIONS.sum,
];
operations.forEach((operation) => {
expect(
isFieldTypeValidForAggregateOperation(
numericField,
operation as AggregateOperationsOmittingCount,
),
).toBe(true);
});
});
});

View File

@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { capitalize } from '~/utils/string/capitalize';
export const generateAggregateQuery = ({
objectMetadataItem,
recordGqlFields,
}: {
objectMetadataItem: ObjectMetadataItem;
recordGqlFields: RecordGqlFields;
}) => {
const selectedFields = Object.entries(recordGqlFields)
.filter(([_, shouldBeQueried]) => shouldBeQueried)
.map(([fieldName]) => fieldName)
.join('\n ');
return gql`
query AggregateMany${capitalize(objectMetadataItem.namePlural)}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput) {
${objectMetadataItem.namePlural}(filter: $filter) {
${selectedFields}
}
}
`;
};

View File

@ -0,0 +1,51 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { capitalize } from '~/utils/string/capitalize';
type NameForAggregation = {
[T in AGGREGATE_OPERATIONS]?: string;
};
type Aggregations = {
[key: string]: NameForAggregation;
};
export const getAvailableAggregationsFromObjectFields = (
fields: FieldMetadataItem[],
): Aggregations => {
return fields.reduce<Record<string, NameForAggregation>>((acc, field) => {
if (field.type === FieldMetadataType.DateTime) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
};
}
if (field.type === FieldMetadataType.Number) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}`,
};
}
if (field.type === FieldMetadataType.Currency) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}AmountMicros`,
};
}
if (acc[field.name] === undefined) {
acc[field.name] = {};
}
acc[field.name][AGGREGATE_OPERATIONS.count] = 'totalCount';
return acc;
}, {});
};

View File

@ -0,0 +1,32 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
export const getAvailableFieldsIdsForAggregationFromObjectFields = (
fields: FieldMetadataItem[],
): AvailableFieldsForAggregateOperation => {
const aggregationMap = initializeAvailableFieldsForAggregateOperationMap();
return fields.reduce((acc, field) => {
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach(
(aggregateOperation) => {
const typedAggregateOperation =
aggregateOperation as AggregateOperationsOmittingCount;
if (
isFieldTypeValidForAggregateOperation(
field.type,
typedAggregateOperation,
)
) {
acc[typedAggregateOperation]?.push(field.id);
}
},
);
return acc;
}, aggregationMap);
};

View File

@ -0,0 +1,13 @@
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
export const initializeAvailableFieldsForAggregateOperationMap =
(): AvailableFieldsForAggregateOperation => {
return Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).reduce(
(acc, operation) => ({
...acc,
[operation]: [],
}),
{},
);
};

View File

@ -0,0 +1,12 @@
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const isFieldTypeValidForAggregateOperation = (
fieldType: FieldMetadataType,
aggregateOperation: AggregateOperationsOmittingCount,
): boolean => {
return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes(
fieldType,
);
};