part 2 of filter/sort drill down from charts (#16013)

in this PR, we will handle simple filter translations (ie, fields that
use the contains operand)
This commit is contained in:
nitin
2025-11-25 18:56:55 +05:30
committed by GitHub
parent 2028a8f9be
commit 3be3c4e965
35 changed files with 1097 additions and 245 deletions
@@ -0,0 +1,47 @@
import { isDefined } from 'twenty-shared/utils';
import { type UrlFilterGroup } from './mapRecordFilterGroupToUrlFilterGroup';
export const appendNestedUrlFilterGroupsToQueryParams = (
urlFilterGroups: UrlFilterGroup[],
queryParamsPrefix: string,
targetQueryParams: URLSearchParams,
): void => {
for (const [groupIndex, urlFilterGroup] of urlFilterGroups.entries()) {
const currentGroupPrefix = `${queryParamsPrefix}[${groupIndex}]`;
targetQueryParams.set(
`${currentGroupPrefix}[operator]`,
urlFilterGroup.operator,
);
if (isDefined(urlFilterGroup.filters)) {
for (const [filterIndex, urlFilter] of urlFilterGroup.filters.entries()) {
targetQueryParams.set(
`${currentGroupPrefix}[filters][${filterIndex}][field]`,
urlFilter.field,
);
targetQueryParams.set(
`${currentGroupPrefix}[filters][${filterIndex}][op]`,
urlFilter.op,
);
targetQueryParams.set(
`${currentGroupPrefix}[filters][${filterIndex}][value]`,
urlFilter.value,
);
if (isDefined(urlFilter.subField)) {
targetQueryParams.set(
`${currentGroupPrefix}[filters][${filterIndex}][subField]`,
urlFilter.subField,
);
}
}
}
if (isDefined(urlFilterGroup.groups)) {
appendNestedUrlFilterGroupsToQueryParams(
urlFilterGroup.groups,
`${currentGroupPrefix}[groups]`,
targetQueryParams,
);
}
}
};
@@ -0,0 +1,53 @@
import { type BuildChartDrilldownQueryParamsInput } from '@/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput';
import { buildFilterFromChartBucket } from '@/page-layout/widgets/graph/utils/buildFilterFromChartBucket';
import { buildFilterQueryParams } from '@/page-layout/widgets/graph/utils/buildFilterQueryParams';
import { isDefined } from 'twenty-shared/utils';
export const buildChartDrilldownQueryParams = ({
objectMetadataItem,
configuration,
clickedData,
viewId,
timezone,
}: BuildChartDrilldownQueryParamsInput): URLSearchParams => {
const drilldownQueryParams = new URLSearchParams();
if (isDefined(configuration.filter)) {
const chartFilterParams = buildFilterQueryParams({
recordFilters: configuration.filter.recordFilters ?? [],
recordFilterGroups: configuration.filter.recordFilterGroups ?? [],
objectMetadataItem,
});
chartFilterParams.forEach((value, key) => {
drilldownQueryParams.append(key, value);
});
}
const primaryField = objectMetadataItem.fields.find(
(field) => field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
if (isDefined(primaryField)) {
const primaryFilters = buildFilterFromChartBucket({
fieldMetadataItem: primaryField,
bucketRawValue: clickedData.primaryBucketRawValue,
dateGranularity: configuration.primaryAxisDateGranularity,
subFieldName: configuration.primaryAxisGroupBySubFieldName,
timezone,
});
primaryFilters.forEach((filter) => {
drilldownQueryParams.append(
`filter[${filter.fieldName}][${filter.operand}]`,
filter.value,
);
});
}
if (isDefined(viewId)) {
drilldownQueryParams.set('viewId', viewId);
}
return drilldownQueryParams;
};
@@ -0,0 +1,86 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { isNonEmptyString } from '@sniptt/guards';
import {
ViewFilterOperand,
type ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type ChartFilter = {
fieldName: string;
operand: ViewFilterOperand;
value: string;
};
type BuildFilterFromChartBucketParams = {
fieldMetadataItem: FieldMetadataItem;
bucketRawValue: unknown;
dateGranularity?: ObjectRecordGroupByDateGranularity | null; // TODO: Will be used for date filtering
subFieldName?: string | null;
timezone?: string; // TODO: Will be used for date filtering
};
const formatChartFilterValue = (
fieldType: FieldMetadataType,
bucketRawValue: unknown,
operand: ViewFilterOperand,
): string => {
const stringValue = String(bucketRawValue);
const needsJsonArray =
operand === ViewFilterOperand.IS &&
[
FieldMetadataType.SELECT,
FieldMetadataType.UUID,
FieldMetadataType.RELATION,
].includes(fieldType);
return needsJsonArray ? JSON.stringify([stringValue]) : stringValue;
};
export const buildFilterFromChartBucket = ({
fieldMetadataItem,
bucketRawValue,
subFieldName,
}: BuildFilterFromChartBucketParams): ChartFilter[] => {
const fieldName = isNonEmptyString(subFieldName)
? `${fieldMetadataItem.name}.${subFieldName}`
: fieldMetadataItem.name;
if (!isDefined(bucketRawValue) || !isNonEmptyString(String(bucketRawValue))) {
return [
{
fieldName,
operand: ViewFilterOperand.IS_EMPTY,
value: '',
},
];
}
const availableOperands = getRecordFilterOperands({
filterType: getFilterTypeFromFieldType(fieldMetadataItem.type),
subFieldName: subFieldName ?? undefined,
});
if (availableOperands.length === 0) {
return [];
}
const operand = availableOperands[0];
const value = formatChartFilterValue(
fieldMetadataItem.type,
bucketRawValue,
operand,
);
return [
{
fieldName,
operand,
value,
},
];
};
@@ -0,0 +1,79 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isDefined } from 'twenty-shared/utils';
import { appendNestedUrlFilterGroupsToQueryParams } from './appendNestedUrlFilterGroupsToQueryParams';
import { mapRecordFilterToUrlFilter } from './mapRecordFilterToUrlFilter';
import { mapRecordFilterGroupToUrlFilterGroup } from './mapRecordFilterGroupToUrlFilterGroup';
export const buildFilterQueryParams = ({
recordFilters = [],
recordFilterGroups = [],
objectMetadataItem,
}: {
recordFilters?: RecordFilter[];
recordFilterGroups?: RecordFilterGroup[];
objectMetadataItem: ObjectMetadataItem;
}): URLSearchParams => {
const params = new URLSearchParams();
const rootGroup = recordFilterGroups.find(
(group) => !isDefined(group.parentRecordFilterGroupId),
);
if (isDefined(rootGroup)) {
const urlFilterGroup = mapRecordFilterGroupToUrlFilterGroup({
recordFilterGroupId: rootGroup.id,
allRecordFilters: recordFilters,
allRecordFilterGroups: recordFilterGroups,
objectMetadataItem,
});
if (isDefined(urlFilterGroup)) {
params.set('filterGroup[operator]', urlFilterGroup.operator);
if (isDefined(urlFilterGroup.filters)) {
for (const [index, filter] of urlFilterGroup.filters.entries()) {
params.set(`filterGroup[filters][${index}][field]`, filter.field);
params.set(`filterGroup[filters][${index}][op]`, filter.op);
params.set(`filterGroup[filters][${index}][value]`, filter.value);
if (isDefined(filter.subField)) {
params.set(
`filterGroup[filters][${index}][subField]`,
filter.subField,
);
}
}
}
if (isDefined(urlFilterGroup.groups)) {
appendNestedUrlFilterGroupsToQueryParams(
urlFilterGroup.groups,
'filterGroup[groups]',
params,
);
}
}
} else {
const parentlessFilters = recordFilters.filter(
(filter) => !isDefined(filter.recordFilterGroupId),
);
for (const filter of parentlessFilters) {
const urlFilter = mapRecordFilterToUrlFilter({
recordFilter: filter,
objectMetadataItem,
});
if (isDefined(urlFilter)) {
const fieldName = isDefined(urlFilter.subField)
? `${urlFilter.field}.${urlFilter.subField}`
: urlFilter.field;
params.append(`filter[${fieldName}][${urlFilter.op}]`, urlFilter.value);
}
}
}
return params;
};
@@ -0,0 +1,18 @@
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { type FormattedDimensionValue } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
export const buildFormattedToRawLookup = (
formattedValues: FormattedDimensionValue[],
): Map<string, RawDimensionValue> => {
const lookup = new Map<string, RawDimensionValue>();
formattedValues.forEach(
({ formattedPrimaryDimensionValue, rawPrimaryDimensionValue }) => {
if (!lookup.has(formattedPrimaryDimensionValue)) {
lookup.set(formattedPrimaryDimensionValue, rawPrimaryDimensionValue);
}
},
);
return lookup;
};
@@ -0,0 +1,54 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql';
type FormatPrimaryDimensionValuesParameters = {
groupByRawResults: GroupByRawResult[];
primaryAxisGroupByField: FieldMetadataItem;
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity;
primaryAxisGroupBySubFieldName?: string;
};
export type FormattedDimensionValue = {
formattedPrimaryDimensionValue: string;
rawPrimaryDimensionValue: RawDimensionValue;
};
export const formatPrimaryDimensionValues = ({
groupByRawResults,
primaryAxisGroupByField,
primaryAxisDateGranularity,
primaryAxisGroupBySubFieldName,
}: FormatPrimaryDimensionValuesParameters): FormattedDimensionValue[] => {
return groupByRawResults.reduce<FormattedDimensionValue[]>(
(accumulator, rawResult) => {
const groupByDimensionValues = rawResult.groupByDimensionValues;
if (!isDefined(groupByDimensionValues?.[0])) {
return accumulator;
}
const rawPrimaryDimensionValue =
groupByDimensionValues[0] as RawDimensionValue;
const formattedPrimaryDimensionValue = formatDimensionValue({
value: rawPrimaryDimensionValue,
fieldMetadata: primaryAxisGroupByField,
dateGranularity: primaryAxisDateGranularity,
subFieldName: primaryAxisGroupBySubFieldName,
});
return [
...accumulator,
{
formattedPrimaryDimensionValue,
rawPrimaryDimensionValue,
},
];
},
[],
);
};
@@ -0,0 +1,88 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isDefined } from 'twenty-shared/utils';
import {
mapRecordFilterToUrlFilter,
type UrlFilter,
} from './mapRecordFilterToUrlFilter';
export type UrlFilterGroup = {
operator: string;
filters?: UrlFilter[];
groups?: UrlFilterGroup[];
};
export const mapRecordFilterGroupToUrlFilterGroup = ({
recordFilterGroupId,
allRecordFilters,
allRecordFilterGroups,
objectMetadataItem,
}: {
recordFilterGroupId: string;
allRecordFilters: RecordFilter[];
allRecordFilterGroups: RecordFilterGroup[];
objectMetadataItem: ObjectMetadataItem;
}): UrlFilterGroup | null => {
const currentGroup = allRecordFilterGroups.find(
(group) => group.id === recordFilterGroupId,
);
if (!isDefined(currentGroup)) {
return null;
}
const urlFilterGroup: UrlFilterGroup = {
operator: currentGroup.logicalOperator,
};
const filtersInGroup = allRecordFilters
.filter((filter) => filter.recordFilterGroupId === recordFilterGroupId)
.sort(
(a, b) =>
(a.positionInRecordFilterGroup ?? 0) -
(b.positionInRecordFilterGroup ?? 0),
);
if (filtersInGroup.length > 0) {
const urlFilters = filtersInGroup
.map((filter) =>
mapRecordFilterToUrlFilter({
recordFilter: filter,
objectMetadataItem,
}),
)
.filter(isDefined);
if (urlFilters.length > 0) {
urlFilterGroup.filters = urlFilters;
}
}
const childGroups = allRecordFilterGroups
.filter((group) => group.parentRecordFilterGroupId === recordFilterGroupId)
.sort(
(a, b) =>
(a.positionInRecordFilterGroup ?? 0) -
(b.positionInRecordFilterGroup ?? 0),
);
if (childGroups.length > 0) {
const urlChildGroups = childGroups
.map((childGroup) =>
mapRecordFilterGroupToUrlFilterGroup({
recordFilterGroupId: childGroup.id,
allRecordFilters,
allRecordFilterGroups,
objectMetadataItem,
}),
)
.filter(isDefined);
if (urlChildGroups.length > 0) {
urlFilterGroup.groups = urlChildGroups;
}
}
return urlFilterGroup;
};
@@ -0,0 +1,39 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export type UrlFilter = {
field: string;
op: string;
value: string;
subField?: string;
};
export const mapRecordFilterToUrlFilter = ({
recordFilter,
objectMetadataItem,
}: {
recordFilter: RecordFilter;
objectMetadataItem: ObjectMetadataItem;
}): UrlFilter | null => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
const urlFilter: UrlFilter = {
field: fieldMetadataItem.name,
op: recordFilter.operand,
value: recordFilter.value,
};
if (isNonEmptyString(recordFilter.subFieldName)) {
urlFilter.subField = recordFilter.subFieldName;
}
return urlFilter;
};
@@ -5,6 +5,7 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData';
import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData';
@@ -27,6 +28,7 @@ type TransformGroupByDataToLineChartDataResult = {
yAxisLabel?: string;
showDataLabels: boolean;
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
};
const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
@@ -35,6 +37,7 @@ const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
yAxisLabel: undefined,
showDataLabels: false,
hasTooManyGroups: false,
formattedToRawLookup: new Map(),
};
export const transformGroupByDataToLineChartData = ({
@@ -137,5 +140,6 @@ export const transformGroupByDataToLineChartData = ({
xAxisLabel,
yAxisLabel,
showDataLabels,
formattedToRawLookup: baseResult.formattedToRawLookup,
};
};
@@ -7,6 +7,9 @@ import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidget
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { isDefined } from 'twenty-shared/utils';
@@ -25,6 +28,7 @@ type TransformOneDimensionalGroupByToLineChartDataParams = {
type TransformOneDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
};
export const transformOneDimensionalGroupByToLineChartData = ({
@@ -42,6 +46,16 @@ export const transformOneDimensionalGroupByToLineChartData = ({
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
);
const formattedValues = formatPrimaryDimensionValues({
groupByRawResults: limitedResults,
primaryAxisGroupByField: groupByFieldX,
primaryAxisDateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
});
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
const data: LineChartDataPoint[] = limitedResults
.map((result) => {
const dimensionValues = result.groupByDimensionValues;
@@ -90,5 +104,6 @@ export const transformOneDimensionalGroupByToLineChartData = ({
series,
hasTooManyGroups:
rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
formattedToRawLookup,
};
};
@@ -6,6 +6,9 @@ import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidget
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries';
@@ -26,6 +29,7 @@ type TransformTwoDimensionalGroupByToLineChartDataParams = {
type TransformTwoDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
};
export const transformTwoDimensionalGroupByToLineChartData = ({
@@ -41,6 +45,14 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
const seriesMap = new Map<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
const formattedValues = formatPrimaryDimensionValues({
groupByRawResults: rawResults,
primaryAxisGroupByField: groupByFieldX,
primaryAxisDateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
});
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
let hasTooManyGroups = false;
rawResults.forEach((result) => {
@@ -70,8 +82,10 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
allXValues.push(xValue);
}
const seriesRawValue = dimensionValues[1];
const seriesKey = formatDimensionValue({
value: dimensionValues[1],
value: seriesRawValue,
fieldMetadata: groupByFieldY,
dateGranularity:
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
@@ -120,5 +134,6 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
return {
series,
hasTooManyGroups,
formattedToRawLookup,
};
};