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:
@ -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 } }',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
@ -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;
|
||||
}, {});
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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]: [],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user