Connect the bar chart to the group by resolver (#14885)

Closes https://github.com/twentyhq/core-team-issues/issues/1535

Video QA:


https://github.com/user-attachments/assets/153fa3f1-08d9-4952-9456-635c602e1f57



https://github.com/user-attachments/assets/d3111afb-4c84-4f57-8a56-5cf666748c86

There are still some formatting and design issues (the labels which are
on top of each other and the values which should be formatted) that will
be fixed in a future PR

---------

Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
Raphaël Bosi
2025-10-09 14:54:33 +02:00
committed by GitHub
co-authored by ehconitin
parent 973b4fef90
commit 875dc1a6b8
50 changed files with 1653 additions and 70 deletions
@@ -4432,7 +4432,7 @@ export type SearchQueryVariables = Exact<{
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: any, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } };
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null };
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupBySubFieldName?: string | null, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null };
export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
id: Scalars['String'];
@@ -4440,7 +4440,7 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
}>;
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }> | null }> | null } };
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupBySubFieldName?: string | null, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }> | null }> | null } };
export type OnDbEventSubscriptionVariables = Exact<{
input: OnDbEventInput;
@@ -4744,8 +4744,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
aggregateFieldMetadataId
aggregateOperation
groupByFieldMetadataIdX
groupBySubFieldNameX
orderByX
groupByFieldMetadataIdY
groupBySubFieldNameY
orderByY
omitNullValues
axisNameDisplay
@@ -4761,8 +4763,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
aggregateFieldMetadataId
aggregateOperation
groupByFieldMetadataIdX
groupBySubFieldNameX
orderByX
groupByFieldMetadataIdY
groupBySubFieldNameY
orderByY
omitNullValues
axisNameDisplay
@@ -4778,6 +4782,7 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
groupByFieldMetadataId
aggregateFieldMetadataId
aggregateOperation
groupBySubFieldName
orderBy
displayDataLabel
color
@@ -58,8 +58,8 @@ export const ChartFieldSelectionForAggregateOperationDropdownContent = () => {
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
// TODO: remove this once group by is supported for relation fields
}).filter((field) => !isFieldRelation(field));
// TODO: remove the relation filter once group by is supported for relation fields
}).filter((field) => !isFieldRelation(field) && !field.isSystem);
const { getIcon } = useIcons();
@@ -76,8 +76,8 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
// TODO: remove this once group by is supported for relation fields
}).filter((field) => !isFieldRelation(field)),
// TODO: remove the relation filter once group by is supported for relation fields
}).filter((field) => !isFieldRelation(field) && !field.isSystem),
[sourceObjectMetadataItem?.fields, searchQuery],
);
@@ -31,6 +31,7 @@ export const computeAggregateValueAndLabel = ({
timeZone,
localeCatalog,
formatNumberFn,
formatShortNumberFn,
}: {
data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem;
@@ -44,6 +45,7 @@ export const computeAggregateValueAndLabel = ({
value: number,
options?: Omit<FormatNumberOptions, 'format'>,
) => string;
formatShortNumberFn?: (value: number) => string | number;
}) => {
const formatNumber =
formatNumberFn ??
@@ -95,7 +97,9 @@ export const computeAggregateValueAndLabel = ({
switch (field.type) {
case FieldMetadataType.CURRENCY: {
value = Number(aggregateValue);
value = formatToShortNumber(value / 1_000_000);
value = isDefined(formatShortNumberFn)
? formatShortNumberFn(value / 1_000_000)
: formatToShortNumber(value / 1_000_000);
break;
}
@@ -15,7 +15,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
onInitialized: (pageLayout: PageLayout) => void;
onInitialized?: (pageLayout: PageLayout) => void;
};
export const PageLayoutInitializationQueryEffect = ({
@@ -10,7 +10,7 @@ import 'react-resizable/css/styles.css';
type PageLayoutRendererProps = {
pageLayoutId: string;
onInitialized: (pageLayout: PageLayout) => void;
onInitialized?: (pageLayout: PageLayout) => void;
};
export const PageLayoutRenderer = ({
@@ -1,21 +1,33 @@
import { MockedProvider, type MockedResponse } from '@apollo/client/testing';
import {
type ApolloClient,
type NormalizedCacheObject,
useApolloClient,
} from '@apollo/client';
import { type MockedResponse } from '@apollo/client/testing';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { MemoryRouter } from 'react-router-dom';
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { RecoilRoot } from 'recoil';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import {
GraphOrderBy,
GraphType,
WidgetType,
} from '~/generated-metadata/graphql';
import {
AxisNameDisplay,
type BarChartConfiguration,
ExtendedAggregateOperations,
PageLayoutType,
type PageLayoutWidget,
} from '~/generated/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const mockPersonObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const validatePageLayoutContent = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
@@ -31,7 +43,7 @@ const mixedGraphsPageLayoutMocks = {
id: 'mixed-graphs-layout',
name: 'Mixed Graph Dashboard',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
objectMetadataId: mockPersonObjectMetadataItem.id,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
@@ -52,7 +64,7 @@ const mixedGraphsPageLayoutMocks = {
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue',
objectMetadataId: null,
objectMetadataId: mockPersonObjectMetadataItem.id,
gridPosition: {
__typename: 'GridPosition',
row: 0,
@@ -76,7 +88,7 @@ const mixedGraphsPageLayoutMocks = {
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Goal Progress',
objectMetadataId: null,
objectMetadataId: mockPersonObjectMetadataItem.id,
gridPosition: {
__typename: 'GridPosition',
row: 0,
@@ -101,7 +113,7 @@ const mixedGraphsPageLayoutMocks = {
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue Sources',
objectMetadataId: null,
objectMetadataId: mockPersonObjectMetadataItem.id,
gridPosition: {
__typename: 'GridPosition',
row: 0,
@@ -127,7 +139,7 @@ const mixedGraphsPageLayoutMocks = {
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Quarterly Comparison',
objectMetadataId: null,
objectMetadataId: mockPersonObjectMetadataItem.id,
gridPosition: {
__typename: 'GridPosition',
row: 2,
@@ -139,10 +151,16 @@ const mixedGraphsPageLayoutMocks = {
__typename: 'BarChartConfiguration',
graphType: GraphType.BAR,
aggregateOperation: ExtendedAggregateOperations.COUNT,
aggregateFieldMetadataId: 'id',
groupByFieldMetadataIdX: 'createdAt',
aggregateFieldMetadataId: mockPersonObjectMetadataItem.fields.find(
(field) => field.name === 'name',
)?.id,
groupByFieldMetadataIdX: mockPersonObjectMetadataItem.fields.find(
(field) => field.name === 'createdAt',
)?.id,
orderByX: GraphOrderBy.FIELD_ASC,
},
axisNameDisplay: AxisNameDisplay.BOTH,
displayDataLabel: false,
} satisfies BarChartConfiguration,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
@@ -152,6 +170,11 @@ const mixedGraphsPageLayoutMocks = {
],
};
const barChartGroupByQuery = generateGroupByQuery({
objectMetadataItem: mockPersonObjectMetadataItem,
aggregateOperations: ['totalCount'],
});
const graphqlMocks: MockedResponse[] = [
{
request: {
@@ -166,19 +189,81 @@ const graphqlMocks: MockedResponse[] = [
},
},
},
{
request: {
query: barChartGroupByQuery,
variables: {
groupBy: [
{
createdAt: {
granularity: 'DAY',
},
},
],
},
},
result: {
data: {
peopleGroupBy: [
{
groupByDimensionValues: ['2024-01-15T00:00:00.000Z'],
totalCount: 12,
},
{
groupByDimensionValues: ['2024-02-15T00:00:00.000Z'],
totalCount: 18,
},
{
groupByDimensionValues: ['2024-03-15T00:00:00.000Z'],
totalCount: 25,
},
{
groupByDimensionValues: ['2024-04-15T00:00:00.000Z'],
totalCount: 15,
},
{
groupByDimensionValues: ['2024-05-15T00:00:00.000Z'],
totalCount: 22,
},
{
groupByDimensionValues: ['2024-06-15T00:00:00.000Z'],
totalCount: 30,
},
],
},
},
},
];
const CoreClientProviderWrapper = ({
children,
}: {
children: React.ReactNode;
}) => {
const apolloClient = useApolloClient() as ApolloClient<NormalizedCacheObject>;
return (
<ApolloCoreClientContext.Provider value={apolloClient}>
{children}
</ApolloCoreClientContext.Provider>
);
};
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: graphqlMocks,
});
const meta: Meta<typeof PageLayoutRenderer> = {
title: 'Modules/PageLayout/PageLayoutRenderer',
component: PageLayoutRenderer,
decorators: [
(Story) => (
<MemoryRouter>
<MockedProvider mocks={graphqlMocks} addTypename={false}>
<RecoilRoot>
<JestMetadataAndApolloMocksWrapper>
<CoreClientProviderWrapper>
<Story />
</RecoilRoot>
</MockedProvider>
</CoreClientProviderWrapper>
</JestMetadataAndApolloMocksWrapper>
</MemoryRouter>
),
],
@@ -21,8 +21,10 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
aggregateFieldMetadataId
aggregateOperation
groupByFieldMetadataIdX
groupBySubFieldNameX
orderByX
groupByFieldMetadataIdY
groupBySubFieldNameY
orderByY
omitNullValues
axisNameDisplay
@@ -38,8 +40,10 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
aggregateFieldMetadataId
aggregateOperation
groupByFieldMetadataIdX
groupBySubFieldNameX
orderByX
groupByFieldMetadataIdY
groupBySubFieldNameY
orderByY
omitNullValues
axisNameDisplay
@@ -55,6 +59,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
groupByFieldMetadataId
aggregateFieldMetadataId
aggregateOperation
groupBySubFieldName
orderBy
displayDataLabel
color
@@ -0,0 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const getGroupByQueryName = (
objectMetadataItem: ObjectMetadataItem,
): string => {
return `${objectMetadataItem.namePlural}GroupBy`;
};
@@ -1,17 +1,10 @@
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetBarChartRenderer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
import { lazy, Suspense } from 'react';
import { GraphType, type PageLayoutWidget } from '~/generated-metadata/graphql';
const GraphWidgetBarChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'
).then((module) => ({
default: module.GraphWidgetBarChart,
})),
);
const GraphWidgetLineChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'
@@ -100,21 +93,7 @@ export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
);
case GraphType.BAR:
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetBarChart
data={data.items}
indexBy={data.indexBy}
keys={data.keys}
seriesLabels={data.seriesLabels}
layout={data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
</Suspense>
);
return <GraphWidgetBarChartRenderer widget={widget} />;
case GraphType.LINE:
return (
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
@@ -0,0 +1 @@
export const GRAPH_DEFAULT_AGGREGATE_VALUE = 0;
@@ -0,0 +1,3 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
export const GRAPH_DEFAULT_COLOR: GraphColor = 'blue';
@@ -0,0 +1,6 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
// Default date granularity for grouping date fields in graphs
// TODO: This will be configurable in the graph editor in the future
export const GRAPH_DEFAULT_DATE_GRANULARITY =
ObjectRecordGroupByDateGranularity.DAY;
@@ -0,0 +1 @@
export const GRAPH_MAXIMUM_NUMBER_OF_GROUPS = 50;
@@ -0,0 +1,60 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type BarChartConfiguration,
type PageLayoutWidget,
} from '~/generated/graphql';
const GraphWidgetBarChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'
).then((module) => ({
default: module.GraphWidgetBarChart,
})),
);
export const GraphWidgetBarChartRenderer = ({
widget,
}: {
widget: PageLayoutWidget;
}) => {
const {
data,
indexBy,
keys,
series,
xAxisLabel,
yAxisLabel,
showDataLabels,
loading,
error,
} = useGraphBarChartWidgetData({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration as BarChartConfiguration,
});
if (loading) {
return <ChartSkeletonLoader />;
}
if (isDefined(error)) {
return <div>Error: {error.message}</div>;
}
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetBarChart
data={data}
series={series}
indexBy={indexBy}
keys={keys}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
showValues={showDataLabels}
id={widget.id}
/>
</Suspense>
);
};
@@ -36,6 +36,7 @@ export const useBarChartData = ({
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
[series],
);
const barConfigs = useMemo((): BarChartConfig[] => {
return data.flatMap((dataPoint, dataIndex) => {
const indexValue = dataPoint[indexBy];
@@ -0,0 +1,60 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData';
import { useMemo } from 'react';
import { type BarChartConfiguration } from '~/generated/graphql';
type UseGraphBarChartWidgetDataProps = {
objectMetadataItemId: string;
configuration: BarChartConfiguration;
};
type UseGraphBarChartWidgetDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
loading: boolean;
error?: Error;
};
export const useGraphBarChartWidgetData = ({
objectMetadataItemId,
configuration,
}: UseGraphBarChartWidgetDataProps): UseGraphBarChartWidgetDataResult => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const {
data: groupByData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
});
const transformedData = useMemo(
() =>
transformGroupByDataToBarChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}),
[groupByData, objectMetadataItem, configuration, aggregateOperation],
);
return {
...transformedData,
loading,
error,
};
};
@@ -0,0 +1,74 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated-metadata/graphql';
export const useGraphWidgetGroupByQuery = ({
objectMetadataItemId,
configuration,
}: {
objectMetadataItemId: string;
configuration: BarChartConfiguration;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const availableAggregations = useMemo(
() =>
getAvailableAggregationsFromObjectFields(
objectMetadataItem.readableFields,
),
[objectMetadataItem.readableFields],
);
const aggregateFieldId = configuration.aggregateFieldMetadataId;
const aggregateField = objectMetadataItem.readableFields.find(
(field) => field.id === aggregateFieldId,
);
if (!isDefined(aggregateField)) {
throw new Error('Aggregate field not found');
}
const aggregateOperation =
availableAggregations[aggregateField.name]?.[
configuration.aggregateOperation
];
if (!isDefined(aggregateOperation)) {
throw new Error('Aggregate operation not found');
}
const query = generateGroupByQuery({
objectMetadataItem,
aggregateOperations: [aggregateOperation],
});
const variables = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem,
barChartConfiguration: configuration,
});
const apolloCoreClient = useApolloCoreClient();
const { data, loading, error, refetch } = useQuery(query, {
client: apolloCoreClient,
variables,
});
return {
data,
loading,
error,
refetch,
variables,
aggregateOperation,
};
};
@@ -7,4 +7,5 @@ export type GraphColor =
| 'yellow'
| 'red'
| 'green'
| 'sky';
| 'sky'
| 'gray';
@@ -0,0 +1,4 @@
export type GroupByRawResult = {
groupByDimensionValues: unknown[];
[key: string]: unknown;
};
@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-01-15T00:00:00.000Z 1`] = `"Q1 2024"`;
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-04-15T00:00:00.000Z 1`] = `"Q2 2024"`;
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-07-15T00:00:00.000Z 1`] = `"Q3 2024"`;
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-10-15T00:00:00.000Z 1`] = `"Q4 2024"`;
exports[`formatDateByGranularity should format date for DAY granularity 1`] = `"Mar 20, 2024"`;
exports[`formatDateByGranularity should format date for MONTH granularity 1`] = `"March 2024"`;
exports[`formatDateByGranularity should format date for NONE granularity 1`] = `"3/20/2024"`;
exports[`formatDateByGranularity should format date for QUARTER granularity 1`] = `"Q1 2024"`;
exports[`formatDateByGranularity should format date for YEAR granularity 1`] = `"2024"`;
@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQuery should generate valid GraphQL query for empty aggregate operations 1`] = `
"
query PeopleGroupBy(
$groupBy: [PersonGroupByInput!]
$filter: PersonFilterInput
$orderBy: [PersonOrderByWithGroupByInput!]
$viewId: UUID
) {
peopleGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
) {
groupByDimensionValues
}
}
"
`;
exports[`generateGroupByQuery should generate valid GraphQL query for multiple aggregate operations 1`] = `
"
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]
$filter: OpportunityFilterInput
$orderBy: [OpportunityOrderByWithGroupByInput!]
$viewId: UUID
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
) {
groupByDimensionValues
totalCount
sumAmountAmountMicros
avgAmountAmountMicros
}
}
"
`;
exports[`generateGroupByQuery should generate valid GraphQL query for single aggregate operation 1`] = `
"
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]
$filter: OpportunityFilterInput
$orderBy: [OpportunityOrderByWithGroupByInput!]
$viewId: UUID
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
) {
groupByDimensionValues
sumAmountAmountMicros
}
}
"
`;
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
@@ -0,0 +1,104 @@
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('buildGroupByFieldObject', () => {
it('should return field with Id suffix for relation fields', () => {
const field = {
name: 'company',
type: FieldMetadataType.RELATION,
} as any;
const result = buildGroupByFieldObject({ field });
expect(result).toEqual({ companyId: true });
});
it('should return nested object for composite fields with subfield', () => {
const field = {
name: 'name',
type: FieldMetadataType.FULL_NAME,
} as any;
const result = buildGroupByFieldObject({
field,
subFieldName: 'firstName',
});
expect(result).toEqual({
name: {
firstName: true,
},
});
});
it('should throw error for composite field without subfield', () => {
const field = {
name: 'name',
type: FieldMetadataType.FULL_NAME,
} as any;
expect(() => buildGroupByFieldObject({ field })).toThrow(
'Composite field name requires a subfield to be specified',
);
});
it('should return field with default granularity for DATE field', () => {
const field = {
name: 'createdAt',
type: FieldMetadataType.DATE,
} as any;
const result = buildGroupByFieldObject({ field });
expect(result).toEqual({
createdAt: {
granularity: ObjectRecordGroupByDateGranularity.DAY,
},
});
});
it('should return field with default granularity for DATE_TIME field', () => {
const field = {
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
} as any;
const result = buildGroupByFieldObject({ field });
expect(result).toEqual({
updatedAt: {
granularity: ObjectRecordGroupByDateGranularity.DAY,
},
});
});
it('should return field with custom granularity for date field', () => {
const field = {
name: 'createdAt',
type: FieldMetadataType.DATE,
} as any;
const result = buildGroupByFieldObject({
field,
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
});
expect(result).toEqual({
createdAt: {
granularity: ObjectRecordGroupByDateGranularity.MONTH,
},
});
});
it('should return simple field object for regular fields', () => {
const field = {
name: 'status',
type: FieldMetadataType.TEXT,
} as any;
const result = buildGroupByFieldObject({ field });
expect(result).toEqual({ status: true });
});
});
@@ -0,0 +1,71 @@
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { computeAggregateNumericValueForGraph } from '../computeAggregateNumericValueForGraph';
describe('computeAggregateNumericValueForGraph', () => {
it('should throw error for empty data', () => {
expect(() =>
computeAggregateNumericValueForGraph({
data: {},
objectMetadataItem: { fields: [] } as any,
}),
).toThrow('Empty aggregate records data');
});
it('should return count value for COUNT operation', () => {
const result = computeAggregateNumericValueForGraph({
data: { status: { COUNT: 25 } },
objectMetadataItem: {
fields: [{ id: 'field-1', name: 'status' }],
} as any,
fieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.COUNT,
});
expect(result).toBe(25);
});
it('should multiply by 100 for percentage operations', () => {
const result = computeAggregateNumericValueForGraph({
data: { status: { PERCENTAGE_EMPTY: 0.25 } },
objectMetadataItem: {
fields: [{ id: 'field-1', name: 'status' }],
} as any,
fieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.PERCENTAGE_EMPTY,
});
expect(result).toBe(25);
});
it('should return numeric value for NUMBER field', () => {
const result = computeAggregateNumericValueForGraph({
data: { amount: { SUM: 1000 } },
objectMetadataItem: {
fields: [
{ id: 'field-1', name: 'amount', type: FieldMetadataType.NUMBER },
],
} as any,
fieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.SUM,
});
expect(result).toBe(1000);
});
it('should return timestamp for DATE field', () => {
const dateString = '2024-01-15';
const result = computeAggregateNumericValueForGraph({
data: { createdAt: { MIN: dateString } },
objectMetadataItem: {
fields: [
{ id: 'field-1', name: 'createdAt', type: FieldMetadataType.DATE },
],
} as any,
fieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.MIN,
});
expect(result).toBe(new Date(dateString).getTime());
});
});
@@ -0,0 +1,79 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { formatDateByGranularity } from '../formatDateByGranularity';
describe('formatDateByGranularity', () => {
const testDate = new Date('2024-03-20T12:00:00Z');
beforeAll(() => {
// Mock toLocaleDateString to avoid timezone/locale issues
jest
.spyOn(Date.prototype, 'toLocaleDateString')
.mockImplementation((_locales, options) => {
if (options?.weekday === 'long') return 'Wednesday';
if (options?.month === 'long' && !isDefined(options?.year))
return 'March';
if (options?.month === 'long' && isDefined(options?.year))
return 'March 2024';
if (options?.month === 'short') return 'Mar 20, 2024';
return '3/20/2024';
});
});
afterAll(() => {
jest.restoreAllMocks();
});
const testCases: {
granularity:
| ObjectRecordGroupByDateGranularity.DAY
| ObjectRecordGroupByDateGranularity.MONTH
| ObjectRecordGroupByDateGranularity.QUARTER
| ObjectRecordGroupByDateGranularity.YEAR
| ObjectRecordGroupByDateGranularity.NONE;
}[] = [
{ granularity: ObjectRecordGroupByDateGranularity.DAY },
{ granularity: ObjectRecordGroupByDateGranularity.MONTH },
{ granularity: ObjectRecordGroupByDateGranularity.QUARTER },
{ granularity: ObjectRecordGroupByDateGranularity.YEAR },
{ granularity: ObjectRecordGroupByDateGranularity.NONE },
];
it.each(testCases)(
'should format date for $granularity granularity',
({ granularity }) => {
expect(formatDateByGranularity(testDate, granularity)).toMatchSnapshot();
},
);
describe('quarter calculations', () => {
const quarterTestCases: {
date: Date;
granularity: ObjectRecordGroupByDateGranularity.QUARTER;
}[] = [
{
date: new Date('2024-01-15'),
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
},
{
date: new Date('2024-04-15'),
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
},
{
date: new Date('2024-07-15'),
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
},
{
date: new Date('2024-10-15'),
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
},
];
it.each(quarterTestCases)(
'should calculate quarter for $granularity with date $date',
({ date, granularity }) => {
expect(formatDateByGranularity(date, granularity)).toMatchSnapshot();
},
);
});
});
@@ -0,0 +1,47 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateGroupByQuery } from '../generateGroupByQuery';
describe('generateGroupByQuery', () => {
const testCases = [
{
description: 'single aggregate operation',
objectMetadataItem: {
nameSingular: 'opportunity',
namePlural: 'opportunities',
},
aggregateOperations: ['sumAmountAmountMicros'],
},
{
description: 'multiple aggregate operations',
objectMetadataItem: {
nameSingular: 'opportunity',
namePlural: 'opportunities',
},
aggregateOperations: [
'totalCount',
'sumAmountAmountMicros',
'avgAmountAmountMicros',
],
},
{
description: 'empty aggregate operations',
objectMetadataItem: {
nameSingular: 'person',
namePlural: 'people',
},
aggregateOperations: [],
},
];
it.each(testCases)(
'should generate valid GraphQL query for $description',
({ objectMetadataItem, aggregateOperations }) => {
const result = generateGroupByQuery({
objectMetadataItem: objectMetadataItem as ObjectMetadataItem,
aggregateOperations,
});
expect(result.loc?.source.body).toMatchSnapshot();
},
);
});
@@ -0,0 +1,86 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type BarChartConfiguration,
FieldMetadataType,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration';
describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [
{
id: 'field-1',
name: 'stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'owner',
type: FieldMetadataType.RELATION,
},
{
id: 'field-3',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
{
id: 'field-4',
name: 'name',
type: FieldMetadataType.FULL_NAME,
},
],
} as ObjectMetadataItem;
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-1',
groupBySubFieldNameX: null,
} as BarChartConfiguration,
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-1',
groupBySubFieldNameX: null,
groupByFieldMetadataIdY: 'field-2',
groupBySubFieldNameY: null,
} as BarChartConfiguration,
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-4',
groupBySubFieldNameX: 'firstName',
} as BarChartConfiguration,
});
expect(result).toMatchSnapshot();
});
it('should throw error when groupBy field X not found', () => {
expect(() =>
generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'invalid-field',
groupBySubFieldNameX: null,
} as BarChartConfiguration,
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
});
@@ -0,0 +1,32 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
import { getFieldKey } from '../getFieldKey';
describe('getFieldKey', () => {
it('should return field name for non-composite field', () => {
const field = {
name: 'companyName',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
expect(getFieldKey({ field })).toBe('companyName');
});
it('should return composite key for composite field with subFieldName', () => {
const field = {
name: 'address',
type: FieldMetadataType.ADDRESS,
} as FieldMetadataItem;
expect(getFieldKey({ field, subFieldName: 'city' })).toBe('address.city');
});
it('should return field name when subFieldName is null', () => {
const field = {
name: 'address',
type: FieldMetadataType.ADDRESS,
} as FieldMetadataItem;
expect(getFieldKey({ field, subFieldName: null })).toBe('address');
});
});
@@ -0,0 +1,52 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const buildGroupByFieldObject = ({
field,
subFieldName,
dateGranularity,
}: {
field: FieldMetadataItem;
subFieldName?: string | null;
dateGranularity?: ObjectRecordGroupByDateGranularity;
}): Record<string, boolean | Record<string, boolean | string>> => {
const isRelation = isFieldRelation(field) || isFieldMorphRelation(field);
const isComposite = isCompositeFieldType(field.type);
const isDateField =
field.type === FieldMetadataType.DATE ||
field.type === FieldMetadataType.DATE_TIME;
if (isRelation) {
return { [`${field.name}Id`]: true };
}
if (isComposite) {
if (!isDefined(subFieldName)) {
throw new Error(
`Composite field ${field.name} requires a subfield to be specified`,
);
}
return {
[field.name]: {
[subFieldName]: true,
},
};
}
if (isDateField) {
return {
[field.name]: {
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
},
};
}
return { [field.name]: true };
};
@@ -0,0 +1,77 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { type AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_AGGREGATE_VALUE } from '@/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const computeAggregateNumericValueForGraph = ({
data,
objectMetadataItem,
fieldMetadataId,
aggregateOperation,
}: {
data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem;
fieldMetadataId?: string | null;
aggregateOperation?: ExtendedAggregateOperations | null;
}): number => {
if (isEmpty(data)) {
throw new Error('Empty aggregate records data');
}
const field = objectMetadataItem.fields?.find(
(field) => field.id === fieldMetadataId,
);
if (!isDefined(field)) {
throw new Error(`Field with id ${fieldMetadataId} not found`);
}
if (!isDefined(aggregateOperation)) {
throw new Error(`Missing aggregate operation for field ${field.name}`);
}
const aggregateValue = data[field.name]?.[aggregateOperation];
if (
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
return isDefined(aggregateValue)
? Number(aggregateValue)
: GRAPH_DEFAULT_AGGREGATE_VALUE;
}
if (!isDefined(aggregateValue)) {
return GRAPH_DEFAULT_AGGREGATE_VALUE;
}
if (
PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
return Number(aggregateValue) * 100;
}
switch (field.type) {
case FieldMetadataType.CURRENCY:
case FieldMetadataType.NUMBER: {
return Number(aggregateValue);
}
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE: {
return new Date(aggregateValue as string).getTime();
}
default:
throw new Error(`Unsupported field type ${field.type}`);
}
};
@@ -0,0 +1,38 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { computeAggregateNumericValueForGraph } from '@/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph';
type GroupByRawResult = {
groupByDimensionValues: unknown[];
[key: string]: unknown;
};
type ComputeAggregateFromGroupByResultParams = {
rawResult: GroupByRawResult;
aggregateField: FieldMetadataItem;
aggregateOperation: ExtendedAggregateOperations;
aggregateOperationFromRawResult: string;
objectMetadataItem: ObjectMetadataItem;
};
export const computeAggregateValueFromGroupByResult = ({
rawResult,
aggregateField,
aggregateOperation,
aggregateOperationFromRawResult,
objectMetadataItem,
}: ComputeAggregateFromGroupByResultParams): number => {
const transformedData = {
[aggregateField.name]: {
[aggregateOperation]: rawResult[aggregateOperationFromRawResult],
},
};
return computeAggregateNumericValueForGraph({
data: transformedData,
objectMetadataItem,
fieldMetadataId: aggregateField.id,
aggregateOperation: aggregateOperation,
});
};
@@ -80,4 +80,12 @@ export const createGraphColorRegistry = (
},
solid: theme.color.sky,
},
gray: {
name: 'gray',
gradient: {
normal: [theme.adaptiveColors.gray1, theme.adaptiveColors.gray2],
hover: [theme.adaptiveColors.gray3, theme.adaptiveColors.gray4],
},
solid: theme.color.gray,
},
});
@@ -0,0 +1,32 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
export const formatDateByGranularity = (
date: Date,
granularity:
| ObjectRecordGroupByDateGranularity.DAY
| ObjectRecordGroupByDateGranularity.MONTH
| ObjectRecordGroupByDateGranularity.QUARTER
| ObjectRecordGroupByDateGranularity.YEAR
| ObjectRecordGroupByDateGranularity.NONE,
): string => {
switch (granularity) {
case ObjectRecordGroupByDateGranularity.DAY:
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
case ObjectRecordGroupByDateGranularity.MONTH:
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
});
case ObjectRecordGroupByDateGranularity.QUARTER:
return `Q${Math.floor(date.getMonth() / 3) + 1} ${date.getFullYear()}`;
case ObjectRecordGroupByDateGranularity.YEAR:
return date.getFullYear().toString();
case ObjectRecordGroupByDateGranularity.NONE:
default:
return date.toLocaleDateString();
}
};
@@ -0,0 +1,91 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity';
import { t } from '@lingui/core/macro';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type FormatDimensionValueParams = {
value: unknown;
fieldMetadata: FieldMetadataItem;
dateGranularity?: ObjectRecordGroupByDateGranularity;
};
const normalizeMultiSelectValue = (value: unknown): unknown[] => {
if (Array.isArray(value)) {
return value;
}
if (typeof value !== 'string') {
return [value];
}
const trimmed = value.trim();
const isPostgresArrayFormat =
trimmed.startsWith('{') && trimmed.endsWith('}');
if (!isPostgresArrayFormat) {
return [value];
}
const content = trimmed.slice(1, -1);
return content ? content.split(',') : [];
};
export const formatDimensionValue = ({
value,
fieldMetadata,
dateGranularity = GRAPH_DEFAULT_DATE_GRANULARITY as ObjectRecordGroupByDateGranularity,
}: FormatDimensionValueParams): string => {
if (!isDefined(value)) {
return '';
}
switch (fieldMetadata.type) {
case FieldMetadataType.SELECT: {
const selectedOption = fieldMetadata.options?.find(
(option) => option.value === value,
);
return selectedOption?.label ?? String(value);
}
case FieldMetadataType.MULTI_SELECT: {
const values = normalizeMultiSelectValue(value);
return values
.map((value) => {
const option = fieldMetadata.options?.find(
(option) => option.value === value,
);
return option?.label ?? String(value);
})
.join(', ');
}
case FieldMetadataType.BOOLEAN: {
return value === true ? t`Yes` : t`No`;
}
case FieldMetadataType.DATE:
case FieldMetadataType.DATE_TIME: {
// TODO: granularity will be passed from the graph configuration when implemented
if (
dateGranularity ===
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
dateGranularity ===
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR ||
dateGranularity ===
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR
) {
return String(value);
}
return formatDateByGranularity(new Date(String(value)), dateGranularity);
}
default:
return String(value);
}
};
@@ -0,0 +1,35 @@
import gql from 'graphql-tag';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from 'twenty-shared/utils';
import { getGroupByQueryName } from '../../../utils/getGroupByQueryName';
export const generateGroupByQuery = ({
objectMetadataItem,
aggregateOperations,
}: {
objectMetadataItem: ObjectMetadataItem;
aggregateOperations: string[];
}) => {
const capitalizedSingular = capitalize(objectMetadataItem.nameSingular);
const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupBy`;
const queryFieldName = getGroupByQueryName(objectMetadataItem);
return gql`
query ${queryName}(
$groupBy: [${capitalizedSingular}GroupByInput!]
$filter: ${capitalizedSingular}FilterInput
$orderBy: [${capitalizedSingular}OrderByWithGroupByInput!]
$viewId: UUID
) {
${queryFieldName}(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
) {
groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''}
}
}
`;
};
@@ -0,0 +1,86 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql';
import { buildGroupByFieldObject } from './buildGroupByFieldObject';
const _mapOrderByToDirection = (orderByEnum: GraphOrderBy) => {
switch (orderByEnum) {
case GraphOrderBy.FIELD_ASC:
return 'AscNullsLast';
case GraphOrderBy.FIELD_DESC:
return 'DescNullsLast';
case GraphOrderBy.VALUE_ASC:
return 'AscNullsLast';
case GraphOrderBy.VALUE_DESC:
return 'DescNullsLast';
default:
assertUnreachable(orderByEnum);
}
};
export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
objectMetadataItem,
barChartConfiguration,
}: {
objectMetadataItem: ObjectMetadataItem;
barChartConfiguration: BarChartConfiguration;
}) => {
const groupByFieldX = objectMetadataItem.fields.find(
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdX,
);
const groupByFieldY = objectMetadataItem.fields.find(
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdY,
);
if (!isDefined(groupByFieldX)) {
throw new Error(
`Field with id ${barChartConfiguration.groupByFieldMetadataIdX} not found in object metadata`,
);
}
const groupBy: Array<
Record<string, boolean | Record<string, boolean | string>>
> = [];
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldX,
subFieldName: barChartConfiguration.groupBySubFieldNameX,
}),
);
if (isDefined(groupByFieldY)) {
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldY,
subFieldName: barChartConfiguration.groupBySubFieldNameY,
}),
);
}
const orderBy: Array<Record<string, string>> = [];
// TODO: Add orderBy back in when the backend is ready
// if (isDefined(barChartConfiguration.orderByX)) {
// orderBy.push({
// [groupByFieldX.name]: mapOrderByToDirection(
// barChartConfiguration.orderByX,
// ),
// });
// }
// if (isDefined(groupByFieldY) && isDefined(barChartConfiguration.orderByY)) {
// orderBy.push({
// [groupByFieldY.name]: mapOrderByToDirection(
// barChartConfiguration.orderByY,
// ),
// });
// }
return {
groupBy,
// TODO: Add filters
...(orderBy.length > 0 && { orderBy }),
};
};
@@ -0,0 +1,20 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isDefined } from 'twenty-shared/utils';
import { type Maybe } from '~/generated/graphql';
type GetFieldKeyParams = {
field: FieldMetadataItem;
subFieldName?: Maybe<string>;
};
export const getFieldKey = ({
field,
subFieldName,
}: GetFieldKeyParams): string => {
const isComposite = isCompositeFieldType(field.type);
return isComposite && isDefined(subFieldName)
? `${field.name}.${subFieldName}`
: field.name;
};
@@ -0,0 +1,133 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { GRAPH_MAXIMUM_NUMBER_OF_GROUPS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData';
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData';
import { isDefined } from 'twenty-shared/utils';
import {
AxisNameDisplay,
type BarChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToBarChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
};
type TransformGroupByDataToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
};
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
data: [],
indexBy: '',
keys: [],
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
};
export const transformGroupByDataToBarChartData = ({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_BAR_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.groupByFieldMetadataIdX,
);
const groupByFieldY = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.groupByFieldMetadataIdY,
);
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return EMPTY_BAR_CHART_RESULT;
}
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
});
const queryName = getGroupByQueryName(objectMetadataItem);
const queryResults = groupByData[queryName];
if (!isDefined(queryResults) || !Array.isArray(queryResults)) {
return {
...EMPTY_BAR_CHART_RESULT,
indexBy: indexByKey,
};
}
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const rawResults = queryResults.slice(0, GRAPH_MAXIMUM_NUMBER_OF_GROUPS);
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
const yAxisLabel = showYAxis
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const showDataLabels = configuration.displayDataLabel;
const baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToBarChartData({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
})
: transformOneDimensionalGroupByToBarChartData({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
});
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
};
};
@@ -0,0 +1,83 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
};
type TransformOneDimensionalGroupByToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
};
export const transformOneDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
});
const data: BarChartDataItem[] = rawResults.map((result) => {
const dimensionValues = result.groupByDimensionValues;
const xValue = isDefined(dimensionValues?.[0])
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
})
: '';
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
return {
[indexByKey]: xValue,
[aggregateField.name]: aggregateValue,
};
});
const series: BarChartSeries[] = [
{
key: aggregateField.name,
label: aggregateField.label,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
},
];
return {
data,
indexBy: indexByKey,
keys: [aggregateField.name],
series,
};
};
@@ -0,0 +1,93 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
};
type TransformTwoDimensionalGroupByToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
};
export const transformTwoDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
});
const dataMap = new Map<string, BarChartDataItem>();
const yValues = new Set<string>();
rawResults.forEach((result) => {
const dimensionValues = result.groupByDimensionValues;
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
const xValue = formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
});
const yValue = formatDimensionValue({
value: dimensionValues[1],
fieldMetadata: groupByFieldY,
});
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
yValues.add(yValue);
if (!dataMap.has(xValue)) {
dataMap.set(xValue, {
[indexByKey]: xValue,
});
}
const dataItem = dataMap.get(xValue)!;
dataItem[yValue] = aggregateValue;
});
const keys = Array.from(yValues);
const series: BarChartSeries[] = keys.map((key) => ({
key,
label: key,
}));
return {
data: Array.from(dataMap.values()),
indexBy: indexByKey,
keys,
series,
};
};
@@ -1,4 +1,4 @@
import { type ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -1,9 +1,7 @@
import { t } from '@lingui/core/macro';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import {
ObjectRecordGroupByDateGranularity,
type ObjectRecord,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface';
@@ -1,7 +1,6 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
@@ -1,7 +1,9 @@
import { isDefined } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
@@ -1,3 +1,5 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
//TODO : Refacto-common - Should be moved to common api layer
export interface ObjectRecord {
id: string;
@@ -40,17 +42,6 @@ export enum OrderByDirection {
DescNullsLast = 'DescNullsLast',
}
export enum ObjectRecordGroupByDateGranularity {
DAY = 'DAY',
MONTH = 'MONTH',
QUARTER = 'QUARTER',
YEAR = 'YEAR',
DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK',
QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR',
MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR',
NONE = 'NONE',
}
export type ObjectRecordOrderBy = Array<
ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField
>;
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage';
@@ -0,0 +1,10 @@
export enum ObjectRecordGroupByDateGranularity {
DAY = 'DAY',
MONTH = 'MONTH',
QUARTER = 'QUARTER',
YEAR = 'YEAR',
DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK',
MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR',
QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR',
NONE = 'NONE',
}
@@ -31,6 +31,7 @@ export type { Nullable } from './Nullable';
export type { NullablePartial } from './NullablePartial';
export type { ObjectPermissions } from './ObjectPermissions';
export type { ObjectPermissionsDeprecated } from './ObjectPermissionsDeprecated';
export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity';
export type { ObjectsPermissions } from './ObjectsPermissions';
export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId';
export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated';