Fix various graphs bugs (#21311)
Some bugs fixed in this PR
1. From UI any field could be chosen to group the query by it, while for
instance, RAW_JSON type (eg workflowRun.state) is not supported by
PostgreSQL to group a query by. Fix: removed it from the "group by"
fields options in FE + in BE -->
2. The BE check existed (isFlatFieldMetadataSupportedInGroupBy) but the
signature was malformed: it expected`{ fieldMetadataType,
fieldMetadataName, fieldMetadataIsSystem }` while every caller passes a
flat field metadata object with type/name/isSystem. So the check is
mis-wired — at runtime the destructured props are undefined, making it
always return true (validation bypassed). Fixed this.
3. Group by does not work with Morph relations if their direction is
ONE_TO_MANY. Added that constraint.
4. Group by with morph relations were broken even for MANY_TO_ONE,
because a morph is stored as one field per target
(polymorphicOwnerRocket, polymorphicOwnerSurveyResult…), each with its
own join column, but the frontend collapsed them into a single
polymorphicOwner field — so the backend tried to resolve a non-existent
polymorphicOwnerId. Fix: Frontend: added a target picker so you choose
the specific morph target (then its sub-field), storing the real
per-target field id. Backend: fixed validate-relation-subfield to use
the per-target field's own relationTargetObjectMetadataId instead of the
multi-target resolver that returned null.
5. (improvement) When an error occured in the query, the graph showed
"No data". Updated it to "error". (screenshot 1)
6. When a field used as a filter on a graph is deleted, it is not
deleted as a graph filter (which is ok because it would involve parsing
all the graph's configuration json to find whether a field is
referenced; there is no foreign key), which prevented from further
modifying the graph's filters. Fixed this + add an indicator that the
filter is can/should be removed (see screenshot 2)
7. "Ambiguous column name" PG error occurs when ordering by "creation
date" of a related field, because both objects have createdAt field.
Fixed it by adding table alias as prefix.
8. (improvement) While working on #5 I did not understand why we could
directly do `"objectMetadataNameSingular"."columnName" `while I expected
that for custom objects it would have to be
`_objectMetadataNameSingular`. that's simply because we use an alias
from the beginning. To add clarity, within groupBy code I replaced
`objectMetadataNameSingular` with `objectAlias` everywhere it is indeed
inherited from us using objectAlias.
<img width="685" height="391" alt="Screenshot 2026-06-08 at 12 01 45"
src="https://github.com/user-attachments/assets/f2b15ca5-da39-4114-8188-69f58f3c4cbf"
/>
<img width="598" height="341" alt="Screenshot 2026-06-08 at 11 53 55"
src="https://github.com/user-attachments/assets/66372811-4a37-40d9-b43a-4af51f89b6e6"
/>
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
||||
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
|
||||
import { PageLayoutWidgetStatusDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetStatusDisplay';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
|
||||
|
||||
const StyledForbiddenContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
type PageLayoutWidgetErrorDisplayProps = {
|
||||
widgetId: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export const PageLayoutWidgetErrorDisplay = ({
|
||||
widgetId,
|
||||
error,
|
||||
}: PageLayoutWidgetErrorDisplayProps) => {
|
||||
if (isGraphqlErrorOfType(error, 'FORBIDDEN')) {
|
||||
return (
|
||||
<StyledForbiddenContainer>
|
||||
<PageLayoutWidgetForbiddenDisplay
|
||||
widgetId={widgetId}
|
||||
restriction={{ type: null }}
|
||||
/>
|
||||
</StyledForbiddenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayoutWidgetStatusDisplay
|
||||
tooltipId={`widget-error-tooltip-${widgetId}`}
|
||||
text={t`Error`}
|
||||
tooltipContent={t`An error occurred while loading this widget.`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+6
-18
@@ -1,17 +1,10 @@
|
||||
import { PageLayoutWidgetStatusDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetStatusDisplay';
|
||||
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { AppTooltip, Status } from 'twenty-ui-deprecated/display';
|
||||
import { WidgetType } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledNoDataContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
export const PageLayoutWidgetNoDataDisplay = () => {
|
||||
const widget = useCurrentWidget();
|
||||
const tooltipId = `widget-incomplete-tooltip-${widget.id}`;
|
||||
|
||||
const text = widget.type === WidgetType.IFRAME ? t`Invalid URL` : t`No Data`;
|
||||
const tooltipContent =
|
||||
@@ -20,15 +13,10 @@ export const PageLayoutWidgetNoDataDisplay = () => {
|
||||
: t`No data available. Click edit to configure this widget.`;
|
||||
|
||||
return (
|
||||
<StyledNoDataContainer>
|
||||
<div id={tooltipId}>
|
||||
<Status color="red" text={text} />
|
||||
</div>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${tooltipId}`}
|
||||
content={tooltipContent}
|
||||
place="top"
|
||||
/>
|
||||
</StyledNoDataContainer>
|
||||
<PageLayoutWidgetStatusDisplay
|
||||
tooltipId={`widget-incomplete-tooltip-${widget.id}`}
|
||||
text={text}
|
||||
tooltipContent={tooltipContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { AppTooltip, Status } from 'twenty-ui-deprecated/display';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
type PageLayoutWidgetStatusDisplayProps = {
|
||||
tooltipId: string;
|
||||
text: string;
|
||||
tooltipContent: string;
|
||||
color?: ThemeColor;
|
||||
};
|
||||
|
||||
export const PageLayoutWidgetStatusDisplay = ({
|
||||
tooltipId,
|
||||
text,
|
||||
tooltipContent,
|
||||
color = 'red',
|
||||
}: PageLayoutWidgetStatusDisplayProps) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<div id={tooltipId}>
|
||||
<Status color={color} text={text} />
|
||||
</div>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${tooltipId}`}
|
||||
content={tooltipContent}
|
||||
place="top"
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+7
-1
@@ -1,8 +1,10 @@
|
||||
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
|
||||
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
|
||||
import { useGraphWidgetAggregateQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery';
|
||||
import { assertAggregateChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertAggregateChartWidget';
|
||||
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const GraphWidgetAggregateChart = lazy(() =>
|
||||
import('@/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChart').then(
|
||||
@@ -17,7 +19,7 @@ export const GraphWidgetAggregateChartRenderer = () => {
|
||||
|
||||
assertAggregateChartWidgetOrThrow(widget);
|
||||
|
||||
const { value, loading } = useGraphWidgetAggregateQuery({
|
||||
const { value, loading, error } = useGraphWidgetAggregateQuery({
|
||||
objectMetadataItemId: widget.objectMetadataId,
|
||||
configuration: widget.configuration,
|
||||
});
|
||||
@@ -26,6 +28,10 @@ export const GraphWidgetAggregateChartRenderer = () => {
|
||||
return <WidgetSkeletonLoader />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetSkeletonLoader />}>
|
||||
<GraphWidgetAggregateChart
|
||||
|
||||
+7
-1
@@ -1,4 +1,5 @@
|
||||
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
|
||||
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
|
||||
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
|
||||
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
|
||||
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useGraphBarChartWidgetData';
|
||||
@@ -15,7 +16,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { getAppPath, isDefined } from 'twenty-shared/utils';
|
||||
import { AxisNameDisplay } from '~/generated-metadata/graphql';
|
||||
|
||||
const GraphWidgetBarChart = lazy(() =>
|
||||
@@ -46,6 +47,7 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
layout,
|
||||
groupMode,
|
||||
loading,
|
||||
error,
|
||||
hasTooManyGroups,
|
||||
formattedToRawLookup,
|
||||
colorMode,
|
||||
@@ -118,6 +120,10 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
return <WidgetSkeletonLoader />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetSkeletonLoader />}>
|
||||
<GraphWidgetChartHasTooManyGroupsEffect
|
||||
|
||||
+6
@@ -1,4 +1,5 @@
|
||||
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
|
||||
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
|
||||
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
|
||||
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
|
||||
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graph-widget-line-chart/constants/LineChartConstants';
|
||||
@@ -45,6 +46,7 @@ export const GraphWidgetLineChartRenderer = () => {
|
||||
showLegend,
|
||||
hasTooManyGroups,
|
||||
loading,
|
||||
error,
|
||||
formattedToRawLookup,
|
||||
colorMode,
|
||||
objectMetadataItem,
|
||||
@@ -127,6 +129,10 @@ export const GraphWidgetLineChartRenderer = () => {
|
||||
return <WidgetSkeletonLoader />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetSkeletonLoader />}>
|
||||
<GraphWidgetChartHasTooManyGroupsEffect
|
||||
|
||||
+7
-1
@@ -1,4 +1,5 @@
|
||||
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
|
||||
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
|
||||
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
|
||||
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
|
||||
import { useGraphPieChartWidgetData } from '@/page-layout/widgets/graph/graph-widget-pie-chart/hooks/useGraphPieChartWidgetData';
|
||||
@@ -14,7 +15,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { getAppPath, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const GraphWidgetPieChart = lazy(() =>
|
||||
import('@/page-layout/widgets/graph/graph-widget-pie-chart/components/GraphWidgetPieChart').then(
|
||||
@@ -34,6 +35,7 @@ export const GraphWidgetPieChartRenderer = () => {
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
hasTooManyGroups,
|
||||
objectMetadataItem,
|
||||
showLegend,
|
||||
@@ -91,6 +93,10 @@ export const GraphWidgetPieChartRenderer = () => {
|
||||
return <WidgetSkeletonLoader />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetSkeletonLoader />}>
|
||||
<GraphWidgetChartHasTooManyGroupsEffect
|
||||
|
||||
+11
-2
@@ -1,6 +1,7 @@
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { dropChartRecordFiltersWithDeletedFields } from '@/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
@@ -44,12 +45,20 @@ export const useGraphWidgetQueryCommon = ({
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const widgetRecordFilters = configuration.filter?.recordFilters ?? [];
|
||||
const objectFieldMetadataIds = new Set(
|
||||
objectMetadataItem.fields.map((field: { id: string }) => field.id),
|
||||
);
|
||||
|
||||
const { recordFilters: sanitizedRecordFilters } =
|
||||
dropChartRecordFiltersWithDeletedFields({
|
||||
chartFilters: configuration.filter ?? {},
|
||||
validFieldMetadataIds: objectFieldMetadataIds,
|
||||
});
|
||||
|
||||
const gqlOperationFilter = computeRecordGqlOperationFilter({
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
recordFilters: widgetRecordFilters,
|
||||
recordFilters: sanitizedRecordFilters ?? [],
|
||||
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
|
||||
});
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SidePanelInformationBanner } from 'twenty-ui-deprecated/display';
|
||||
type ChartFiltersDeletedFieldsWarningProps = {
|
||||
validFieldMetadataIds: Set<string>;
|
||||
};
|
||||
|
||||
export const ChartFiltersDeletedFieldsWarning = ({
|
||||
validFieldMetadataIds,
|
||||
}: ChartFiltersDeletedFieldsWarningProps) => {
|
||||
const currentRecordFilters = useAtomComponentStateValue(
|
||||
currentRecordFiltersComponentState,
|
||||
);
|
||||
|
||||
const deletedFieldFiltersCount = currentRecordFilters.filter(
|
||||
(recordFilter) => !validFieldMetadataIds.has(recordFilter.fieldMetadataId),
|
||||
).length;
|
||||
|
||||
if (deletedFieldFiltersCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelInformationBanner
|
||||
variant="warning"
|
||||
message={t`This rule filters on one or more deleted fields that should be removed.`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+16
-1
@@ -1,3 +1,4 @@
|
||||
import { ChartFiltersDeletedFieldsWarning } from '@/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning';
|
||||
import { ChartFiltersSettingsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect';
|
||||
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
|
||||
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
|
||||
@@ -16,7 +17,8 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useStore } from 'jotai';
|
||||
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
|
||||
import { useMemo } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledChartFiltersPageContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -59,6 +61,16 @@ export const ChartFiltersSettings = ({
|
||||
const store = useStore();
|
||||
const chartWidgetConfiguration = widget.configuration;
|
||||
|
||||
const validFieldMetadataIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
objectMetadataItem.fields
|
||||
.filter((fieldMetadataItem) => fieldMetadataItem.isActive)
|
||||
.map((fieldMetadataItem) => fieldMetadataItem.id),
|
||||
),
|
||||
[objectMetadataItem.fields],
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = () => {
|
||||
const existingRecordFilters = store.get(currentRecordFilters);
|
||||
const existingRecordFilterGroups = store.get(currentRecordFilterGroups);
|
||||
@@ -84,6 +96,9 @@ export const ChartFiltersSettings = ({
|
||||
<RecordFiltersComponentInstanceContext.Provider
|
||||
value={{ instanceId }}
|
||||
>
|
||||
<ChartFiltersDeletedFieldsWarning
|
||||
validFieldMetadataIds={validFieldMetadataIds}
|
||||
/>
|
||||
<AdvancedFilterSidePanelContainer
|
||||
onUpdate={handleFiltersUpdate}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { hasInitializedChartFiltersComponentState } from '@/side-panel/pages/page-layout/states/hasInitializedChartFiltersComponentState';
|
||||
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
|
||||
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { hasInitializedChartFiltersComponentState } from '@/side-panel/pages/page-layout/states/hasInitializedChartFiltersComponentState';
|
||||
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
+65
-8
@@ -1,15 +1,17 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
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 { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
|
||||
import { ChartGroupByFieldSelectionMorphRelationFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView';
|
||||
import { ChartGroupByFieldSelectionRelationFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView';
|
||||
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
|
||||
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
|
||||
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
|
||||
import { type ChartConfiguration } from '@/side-panel/pages/page-layout/types/ChartConfiguration';
|
||||
import { buildChartGroupByFieldConfigUpdate } from '@/side-panel/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
@@ -22,7 +24,10 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
} from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui-deprecated/display';
|
||||
import { MenuItemSelect } from 'twenty-ui-deprecated/navigation';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
@@ -49,6 +54,9 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
const [selectedRelationField, setSelectedRelationField] =
|
||||
useState<FieldMetadataItem | null>(null);
|
||||
|
||||
const [selectedMorphField, setSelectedMorphField] =
|
||||
useState<FieldMetadataItem | null>(null);
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const { pageLayoutId } = usePageLayoutIdFromContextStore();
|
||||
@@ -90,7 +98,12 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
if (isFieldRelation(field)) {
|
||||
return field.relation?.type === RelationType.MANY_TO_ONE;
|
||||
}
|
||||
return true;
|
||||
return isFieldMetadataSupportedInGroupBy({
|
||||
type: field.type,
|
||||
name: field.name,
|
||||
isSystem: field.isSystem ?? false,
|
||||
relationType: field.settings?.relationType ?? null,
|
||||
});
|
||||
}),
|
||||
[sourceObjectMetadataItem?.fields, searchQuery],
|
||||
);
|
||||
@@ -114,6 +127,11 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
}
|
||||
|
||||
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
|
||||
if (isFieldMorphRelation(fieldMetadataItem)) {
|
||||
setSelectedMorphField(fieldMetadataItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFieldRelation(fieldMetadataItem)) {
|
||||
setSelectedRelationField(fieldMetadataItem);
|
||||
return;
|
||||
@@ -161,6 +179,31 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
setSelectedRelationField(null);
|
||||
};
|
||||
|
||||
const handleBackFromMorph = () => {
|
||||
setSelectedMorphField(null);
|
||||
};
|
||||
|
||||
const handleSelectMorphTargetSubField = ({
|
||||
perTargetFieldId,
|
||||
subFieldName,
|
||||
}: {
|
||||
perTargetFieldId: string;
|
||||
subFieldName: string;
|
||||
}) => {
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: buildChartGroupByFieldConfigUpdate({
|
||||
configuration,
|
||||
fieldMetadataIdKey,
|
||||
subFieldNameKey,
|
||||
fieldId: perTargetFieldId,
|
||||
subFieldName,
|
||||
objectMetadataItem: sourceObjectMetadataItem,
|
||||
objectMetadataItems,
|
||||
}),
|
||||
});
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleSelectCompositeSubField = (subFieldName: string) => {
|
||||
if (!isDefined(selectedCompositeField)) {
|
||||
return;
|
||||
@@ -199,6 +242,18 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
if (isDefined(selectedMorphField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionMorphRelationFieldView
|
||||
morphField={selectedMorphField}
|
||||
currentFieldMetadataId={currentGroupByFieldMetadataId}
|
||||
currentSubFieldName={currentSubFieldName}
|
||||
onBack={handleBackFromMorph}
|
||||
onSelectTargetSubField={handleSelectMorphTargetSubField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(selectedRelationField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionRelationFieldView
|
||||
@@ -261,13 +316,15 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
selected={
|
||||
!isCompositeFieldType(fieldMetadataItem.type) &&
|
||||
!isFieldRelation(fieldMetadataItem) &&
|
||||
!isFieldMorphRelation(fieldMetadataItem) &&
|
||||
currentGroupByFieldMetadataId === fieldMetadataItem.id
|
||||
}
|
||||
focused={selectedItemId === fieldMetadataItem.id}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
hasSubMenu={
|
||||
isCompositeFieldType(fieldMetadataItem.type) ||
|
||||
isFieldRelation(fieldMetadataItem)
|
||||
isFieldRelation(fieldMetadataItem) ||
|
||||
isFieldMorphRelation(fieldMetadataItem)
|
||||
}
|
||||
onClick={() => {
|
||||
handleSelectField(fieldMetadataItem);
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
|
||||
import { MenuItem } from 'twenty-ui-deprecated/navigation';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
|
||||
type MorphRelationTarget = {
|
||||
perTargetFieldId: string;
|
||||
targetObjectNameSingular: string;
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
type ChartGroupByFieldSelectionMorphRelationFieldViewProps = {
|
||||
morphField: FieldMetadataItem;
|
||||
currentFieldMetadataId: string | undefined;
|
||||
currentSubFieldName: string | undefined;
|
||||
onBack: () => void;
|
||||
onSelectTargetSubField: (params: {
|
||||
perTargetFieldId: string;
|
||||
subFieldName: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
|
||||
morphField,
|
||||
currentFieldMetadataId,
|
||||
currentSubFieldName,
|
||||
onBack,
|
||||
onSelectTargetSubField,
|
||||
}: ChartGroupByFieldSelectionMorphRelationFieldViewProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const [selectedTarget, setSelectedTarget] =
|
||||
useState<MorphRelationTarget | null>(null);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
const availableTargets = useMemo<MorphRelationTarget[]>(() => {
|
||||
return (morphField.morphRelations ?? [])
|
||||
.filter(
|
||||
(morphRelation) => morphRelation.type === RelationType.MANY_TO_ONE,
|
||||
)
|
||||
.map((morphRelation) => {
|
||||
const targetObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) =>
|
||||
item.nameSingular ===
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
return {
|
||||
perTargetFieldId: morphRelation.sourceFieldMetadata.id,
|
||||
targetObjectNameSingular:
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
label:
|
||||
targetObjectMetadataItem?.labelSingular ??
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
icon: targetObjectMetadataItem?.icon,
|
||||
};
|
||||
});
|
||||
}, [morphField.morphRelations, objectMetadataItems]);
|
||||
|
||||
if (isDefined(selectedTarget)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionTargetObjectFieldsView
|
||||
targetObjectNameSingular={selectedTarget.targetObjectNameSingular}
|
||||
headerLabel={`${morphField.label} · ${selectedTarget.label}`}
|
||||
currentSubFieldName={
|
||||
selectedTarget.perTargetFieldId === currentFieldMetadataId
|
||||
? currentSubFieldName
|
||||
: undefined
|
||||
}
|
||||
onBack={() => setSelectedTarget(null)}
|
||||
onSelectSubField={(subFieldName) =>
|
||||
onSelectTargetSubField({
|
||||
perTargetFieldId: selectedTarget.perTargetFieldId,
|
||||
subFieldName,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={onBack}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{morphField.label}
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{availableTargets.length === 0 ? (
|
||||
<MenuItem text={t`No targets available`} />
|
||||
) : (
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={availableTargets.map(
|
||||
(target) => target.perTargetFieldId,
|
||||
)}
|
||||
>
|
||||
{availableTargets.map((target) => (
|
||||
<SelectableListItem
|
||||
key={target.perTargetFieldId}
|
||||
itemId={target.perTargetFieldId}
|
||||
onEnter={() => {
|
||||
setSelectedTarget(target);
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
text={target.label}
|
||||
focused={selectedItemId === target.perTargetFieldId}
|
||||
LeftIcon={
|
||||
isDefined(target.icon) ? getIcon(target.icon) : undefined
|
||||
}
|
||||
hasSubMenu
|
||||
onClick={() => {
|
||||
setSelectedTarget(target);
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+10
-153
@@ -1,26 +1,5 @@
|
||||
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui-deprecated/navigation';
|
||||
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
|
||||
import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView';
|
||||
|
||||
type ChartGroupByFieldSelectionRelationFieldViewProps = {
|
||||
relationField: FieldMetadataItem;
|
||||
@@ -35,137 +14,15 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
|
||||
onBack,
|
||||
onSelectSubField,
|
||||
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [selectedCompositeField, setSelectedCompositeField] =
|
||||
useState<FieldMetadataItem | null>(null);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const targetObjectNameSingular =
|
||||
relationField.relation?.targetObjectMetadata?.nameSingular;
|
||||
|
||||
const targetObjectMetadataItem = useMemo(
|
||||
() =>
|
||||
objectMetadataItems.find(
|
||||
(item) => item.nameSingular === targetObjectNameSingular,
|
||||
),
|
||||
[objectMetadataItems, targetObjectNameSingular],
|
||||
);
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
if (!isDefined(targetObjectMetadataItem)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filterBySearchQuery({
|
||||
items: targetObjectMetadataItem.fields.filter(
|
||||
(field) => !isHiddenSystemField(field) && !isFieldRelation(field),
|
||||
),
|
||||
searchQuery,
|
||||
getSearchableValues: (field) => [field.label, field.name],
|
||||
});
|
||||
}, [targetObjectMetadataItem, searchQuery]);
|
||||
|
||||
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
|
||||
if (isCompositeFieldType(fieldMetadataItem.type)) {
|
||||
setSelectedCompositeField(fieldMetadataItem);
|
||||
} else {
|
||||
onSelectSubField(fieldMetadataItem.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCompositeSubField = (compositeSubFieldName: string) => {
|
||||
if (!isDefined(selectedCompositeField)) {
|
||||
return;
|
||||
}
|
||||
onSelectSubField(`${selectedCompositeField.name}.${compositeSubFieldName}`);
|
||||
};
|
||||
|
||||
const handleBackFromComposite = () => {
|
||||
setSelectedCompositeField(null);
|
||||
};
|
||||
|
||||
const [currentNestedFieldName, currentNestedSubFieldName] =
|
||||
currentSubFieldName?.split('.') ?? [];
|
||||
|
||||
if (isDefined(selectedCompositeField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionCompositeFieldView
|
||||
compositeField={selectedCompositeField}
|
||||
currentSubFieldName={currentNestedSubFieldName}
|
||||
onBack={handleBackFromComposite}
|
||||
onSelectSubField={handleSelectCompositeSubField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={onBack}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relationField.label}
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t`Search fields`}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
value={searchQuery}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{availableFields.length === 0 ? (
|
||||
<MenuItem text={t`No fields available`} />
|
||||
) : (
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={availableFields.map((field) => field.id)}
|
||||
>
|
||||
{availableFields.map((fieldMetadataItem) => (
|
||||
<SelectableListItem
|
||||
key={fieldMetadataItem.id}
|
||||
itemId={fieldMetadataItem.id}
|
||||
onEnter={() => {
|
||||
handleSelectField(fieldMetadataItem);
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={fieldMetadataItem.label}
|
||||
selected={
|
||||
!isCompositeFieldType(fieldMetadataItem.type) &&
|
||||
currentNestedFieldName === fieldMetadataItem.name
|
||||
}
|
||||
focused={selectedItemId === fieldMetadataItem.id}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
|
||||
onClick={() => {
|
||||
handleSelectField(fieldMetadataItem);
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
<ChartGroupByFieldSelectionTargetObjectFieldsView
|
||||
targetObjectNameSingular={
|
||||
relationField.relation?.targetObjectMetadata?.nameSingular
|
||||
}
|
||||
headerLabel={relationField.label}
|
||||
currentSubFieldName={currentSubFieldName}
|
||||
onBack={onBack}
|
||||
onSelectSubField={onSelectSubField}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui-deprecated/navigation';
|
||||
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
|
||||
|
||||
type ChartGroupByFieldSelectionTargetObjectFieldsViewProps = {
|
||||
targetObjectNameSingular?: string;
|
||||
headerLabel: string;
|
||||
currentSubFieldName: string | undefined;
|
||||
onBack: () => void;
|
||||
onSelectSubField: (subFieldName: string) => void;
|
||||
};
|
||||
|
||||
export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
targetObjectNameSingular,
|
||||
headerLabel,
|
||||
currentSubFieldName,
|
||||
onBack,
|
||||
onSelectSubField,
|
||||
}: ChartGroupByFieldSelectionTargetObjectFieldsViewProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [selectedCompositeField, setSelectedCompositeField] =
|
||||
useState<FieldMetadataItem | null>(null);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const targetObjectMetadataItem = useMemo(
|
||||
() =>
|
||||
objectMetadataItems.find(
|
||||
(item) => item.nameSingular === targetObjectNameSingular,
|
||||
),
|
||||
[objectMetadataItems, targetObjectNameSingular],
|
||||
);
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
if (!isDefined(targetObjectMetadataItem)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filterBySearchQuery({
|
||||
items: targetObjectMetadataItem.fields.filter(
|
||||
(field) => !isHiddenSystemField(field) && !isFieldRelation(field),
|
||||
),
|
||||
searchQuery,
|
||||
getSearchableValues: (field) => [field.label, field.name],
|
||||
});
|
||||
}, [targetObjectMetadataItem, searchQuery]);
|
||||
|
||||
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
|
||||
if (isCompositeFieldType(fieldMetadataItem.type)) {
|
||||
setSelectedCompositeField(fieldMetadataItem);
|
||||
} else {
|
||||
onSelectSubField(fieldMetadataItem.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCompositeSubField = (compositeSubFieldName: string) => {
|
||||
if (!isDefined(selectedCompositeField)) {
|
||||
return;
|
||||
}
|
||||
onSelectSubField(`${selectedCompositeField.name}.${compositeSubFieldName}`);
|
||||
};
|
||||
|
||||
const handleBackFromComposite = () => {
|
||||
setSelectedCompositeField(null);
|
||||
};
|
||||
|
||||
const [currentNestedFieldName, currentNestedSubFieldName] =
|
||||
currentSubFieldName?.split('.') ?? [];
|
||||
|
||||
if (isDefined(selectedCompositeField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionCompositeFieldView
|
||||
compositeField={selectedCompositeField}
|
||||
currentSubFieldName={currentNestedSubFieldName}
|
||||
onBack={handleBackFromComposite}
|
||||
onSelectSubField={handleSelectCompositeSubField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={onBack}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{headerLabel}
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t`Search fields`}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
value={searchQuery}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{availableFields.length === 0 ? (
|
||||
<MenuItem text={t`No fields available`} />
|
||||
) : (
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={availableFields.map((field) => field.id)}
|
||||
>
|
||||
{availableFields.map((fieldMetadataItem) => (
|
||||
<SelectableListItem
|
||||
key={fieldMetadataItem.id}
|
||||
itemId={fieldMetadataItem.id}
|
||||
onEnter={() => {
|
||||
handleSelectField(fieldMetadataItem);
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={fieldMetadataItem.label}
|
||||
selected={
|
||||
!isCompositeFieldType(fieldMetadataItem.type) &&
|
||||
currentNestedFieldName === fieldMetadataItem.name
|
||||
}
|
||||
focused={selectedItemId === fieldMetadataItem.id}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
|
||||
onClick={() => {
|
||||
handleSelectField(fieldMetadataItem);
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+89
@@ -13,6 +13,7 @@ import {
|
||||
BarChartLayout,
|
||||
FieldMetadataType,
|
||||
GraphOrderBy,
|
||||
RelationType,
|
||||
WidgetConfigurationType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useChartSettingsValues } from '@/side-panel/pages/page-layout/hooks/useChartSettingsValues';
|
||||
@@ -355,6 +356,94 @@ describe('useChartSettingsValues', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Morph relation group-by', () => {
|
||||
// The collapsed morph field's own id is the first target ('field-owner-company');
|
||||
// the chart stores the per-target id of the other target ('field-owner-person'),
|
||||
// which is only reachable via morphRelations[].sourceFieldMetadata.id.
|
||||
const morphObjectMetadataItem: EnrichedObjectMetadataItem = {
|
||||
id: 'obj-morph',
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
labelSingular: 'Opportunity',
|
||||
labelPlural: 'Opportunities',
|
||||
fields: [
|
||||
{
|
||||
id: 'field-amount',
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
},
|
||||
{
|
||||
id: 'field-owner-company',
|
||||
name: 'owner',
|
||||
label: 'Owner',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphRelations: [
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
sourceFieldMetadata: { id: 'field-owner-company', name: 'owner' },
|
||||
targetObjectMetadata: { nameSingular: 'company' },
|
||||
},
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
sourceFieldMetadata: { id: 'field-owner-person', name: 'owner' },
|
||||
targetObjectMetadata: { nameSingular: 'person' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as EnrichedObjectMetadataItem;
|
||||
|
||||
const renderWithMorphObject = (configuration: ChartConfiguration) => {
|
||||
setTestObjectMetadataItemsInMetadataStore(jotaiStore, [
|
||||
morphObjectMetadataItem,
|
||||
]);
|
||||
|
||||
return renderHook(
|
||||
() =>
|
||||
useChartSettingsValues({
|
||||
objectMetadataId: morphObjectMetadataItem.id,
|
||||
configuration,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it('should resolve label when grouping by the collapsed morph target', () => {
|
||||
const config = buildBarChartConfiguration({
|
||||
aggregateFieldMetadataId: 'field-amount',
|
||||
primaryAxisGroupByFieldMetadataId: 'field-owner-company',
|
||||
});
|
||||
|
||||
const { result } = renderWithMorphObject(config);
|
||||
|
||||
const value = result.current.getChartSettingsValues(
|
||||
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
|
||||
);
|
||||
|
||||
expect(value).toBe('Owner');
|
||||
});
|
||||
|
||||
it('should resolve label when grouping by the non-collapsed morph target', () => {
|
||||
const config = buildBarChartConfiguration({
|
||||
aggregateFieldMetadataId: 'field-amount',
|
||||
primaryAxisGroupByFieldMetadataId: 'field-owner-person',
|
||||
});
|
||||
|
||||
const { result } = renderWithMorphObject(config);
|
||||
|
||||
const value = result.current.getChartSettingsValues(
|
||||
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
|
||||
);
|
||||
|
||||
expect(value).toBe('Owner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('No configuration', () => {
|
||||
it('should return undefined function when configuration is undefined', () => {
|
||||
const { result } = renderUseChartSettingsValues(undefined as any);
|
||||
|
||||
+13
-5
@@ -2,6 +2,7 @@ import { useGraphGroupBySortOptionLabels } from '@/side-panel/pages/page-layout/
|
||||
import { useGraphXSortOptionLabels } from '@/side-panel/pages/page-layout/hooks/useGraphXSortOptionLabels';
|
||||
import { type ChartConfiguration } from '@/side-panel/pages/page-layout/types/ChartConfiguration';
|
||||
import { CHART_CONFIGURATION_SETTING_IDS } from '@/side-panel/pages/page-layout/types/ChartConfigurationSettingIds';
|
||||
import { findChartGroupByFieldMetadataItem } from '@/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem';
|
||||
import { getChartAxisNameDisplayOptions } from '@/side-panel/pages/page-layout/utils/getChartAxisNameDisplayOptions';
|
||||
import { getChartFilterRulesCount } from '@/side-panel/pages/page-layout/utils/getChartFilterRulesCount';
|
||||
import { getDateGranularityLabel } from '@/side-panel/pages/page-layout/utils/getDateGranularityLabel';
|
||||
@@ -74,11 +75,17 @@ export const useChartSettingsValues = ({
|
||||
}
|
||||
|
||||
const groupByFieldX = isDefined(groupByFieldXId)
|
||||
? objectMetadataItem?.fields.find((field) => field.id === groupByFieldXId)
|
||||
? findChartGroupByFieldMetadataItem({
|
||||
fields: objectMetadataItem?.fields,
|
||||
fieldMetadataId: groupByFieldXId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const groupByFieldY = isDefined(groupByFieldYId)
|
||||
? objectMetadataItem?.fields.find((field) => field.id === groupByFieldYId)
|
||||
? findChartGroupByFieldMetadataItem({
|
||||
fields: objectMetadataItem?.fields,
|
||||
fieldMetadataId: groupByFieldYId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const groupBySubFieldNameXLabel =
|
||||
@@ -199,9 +206,10 @@ export const useChartSettingsValues = ({
|
||||
}
|
||||
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_PIE_CHART: {
|
||||
const pieChartGroupByField = isDefined(finalGroupByFieldYId)
|
||||
? objectMetadataItem?.fields.find(
|
||||
(field) => field.id === finalGroupByFieldYId,
|
||||
)
|
||||
? findChartGroupByFieldMetadataItem({
|
||||
fields: objectMetadataItem?.fields,
|
||||
fieldMetadataId: finalGroupByFieldYId,
|
||||
})
|
||||
: undefined;
|
||||
const pieChartGroupBySubFieldNameLabel =
|
||||
isDefined(finalGroupBySubFieldNameY) &&
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
|
||||
import { dropChartRecordFiltersWithDeletedFields } from '@/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields';
|
||||
|
||||
describe('dropChartRecordFiltersWithDeletedFields', () => {
|
||||
it('should drop record filters referencing fields that no longer exist', () => {
|
||||
const chartFilters: ChartFilters = {
|
||||
recordFilters: [
|
||||
{ id: 'filter-1', fieldMetadataId: 'valid-field' },
|
||||
{ id: 'filter-2', fieldMetadataId: 'deleted-field' },
|
||||
],
|
||||
recordFilterGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
parentRecordFilterGroupId: undefined,
|
||||
logicalOperator: RecordFilterGroupLogicalOperator.AND,
|
||||
},
|
||||
],
|
||||
} as ChartFilters;
|
||||
|
||||
const result = dropChartRecordFiltersWithDeletedFields({
|
||||
chartFilters,
|
||||
validFieldMetadataIds: new Set(['valid-field']),
|
||||
});
|
||||
|
||||
expect(result.recordFilters).toEqual([
|
||||
{ id: 'filter-1', fieldMetadataId: 'valid-field' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve record filter groups', () => {
|
||||
const chartFilters: ChartFilters = {
|
||||
recordFilters: [],
|
||||
recordFilterGroups: [
|
||||
{
|
||||
id: 'root',
|
||||
parentRecordFilterGroupId: undefined,
|
||||
logicalOperator: RecordFilterGroupLogicalOperator.AND,
|
||||
},
|
||||
],
|
||||
} as ChartFilters;
|
||||
|
||||
const result = dropChartRecordFiltersWithDeletedFields({
|
||||
chartFilters,
|
||||
validFieldMetadataIds: new Set(),
|
||||
});
|
||||
|
||||
expect(result.recordFilterGroups).toEqual([
|
||||
{
|
||||
id: 'root',
|
||||
parentRecordFilterGroupId: undefined,
|
||||
logicalOperator: RecordFilterGroupLogicalOperator.AND,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep all record filters when every field is still valid', () => {
|
||||
const chartFilters: ChartFilters = {
|
||||
recordFilters: [
|
||||
{ id: 'filter-1', fieldMetadataId: 'field-a' },
|
||||
{ id: 'filter-2', fieldMetadataId: 'field-b' },
|
||||
],
|
||||
} as ChartFilters;
|
||||
|
||||
const result = dropChartRecordFiltersWithDeletedFields({
|
||||
chartFilters,
|
||||
validFieldMetadataIds: new Set(['field-a', 'field-b']),
|
||||
});
|
||||
|
||||
expect(result.recordFilters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle undefined record filters', () => {
|
||||
const chartFilters: ChartFilters = {} as ChartFilters;
|
||||
|
||||
const result = dropChartRecordFiltersWithDeletedFields({
|
||||
chartFilters,
|
||||
validFieldMetadataIds: new Set(['field-a']),
|
||||
});
|
||||
|
||||
expect(result.recordFilters).toEqual([]);
|
||||
});
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
|
||||
|
||||
export const dropChartRecordFiltersWithDeletedFields = ({
|
||||
chartFilters,
|
||||
validFieldMetadataIds,
|
||||
}: {
|
||||
chartFilters: ChartFilters;
|
||||
validFieldMetadataIds: Set<string>;
|
||||
}): ChartFilters => ({
|
||||
...chartFilters,
|
||||
recordFilters: (chartFilters.recordFilters ?? []).filter((recordFilter) =>
|
||||
validFieldMetadataIds.has(recordFilter.fieldMetadataId),
|
||||
),
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
|
||||
export const findChartGroupByFieldMetadataItem = ({
|
||||
fields,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
fields: FieldMetadataItem[] | undefined;
|
||||
fieldMetadataId: string;
|
||||
}): FieldMetadataItem | undefined =>
|
||||
fields?.find(
|
||||
(field) =>
|
||||
field.id === fieldMetadataId ||
|
||||
(field.morphRelations ?? []).some(
|
||||
(morphRelation) =>
|
||||
morphRelation.sourceFieldMetadata.id === fieldMetadataId,
|
||||
),
|
||||
);
|
||||
+13
-2
@@ -1,5 +1,7 @@
|
||||
import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
|
||||
export const isRelationNestedFieldSupportedInGroupBy = ({
|
||||
nestedFieldName,
|
||||
@@ -12,5 +14,14 @@ export const isRelationNestedFieldSupportedInGroupBy = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
return isFlatFieldMetadataSupportedInGroupBy(nestedFieldMetadata);
|
||||
const relationType = isMorphOrRelationFlatFieldMetadata(nestedFieldMetadata)
|
||||
? nestedFieldMetadata.settings.relationType
|
||||
: null;
|
||||
|
||||
return isFieldMetadataSupportedInGroupBy({
|
||||
type: nestedFieldMetadata.type,
|
||||
name: nestedFieldMetadata.name,
|
||||
isSystem: nestedFieldMetadata.isSystem,
|
||||
relationType,
|
||||
});
|
||||
};
|
||||
|
||||
+23
-9
@@ -1,25 +1,28 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, isPlainObject } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
isPlainObject,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
|
||||
import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
|
||||
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
|
||||
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import {
|
||||
ObjectRecordGroupByForAtomicField,
|
||||
ObjectRecordGroupByForCompositeField,
|
||||
ObjectRecordGroupByForDateField,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
@@ -136,7 +139,18 @@ const validateAndTransformSingleGroupByFieldOrThrow = ({
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
const relationType = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
? fieldMetadata.settings.relationType
|
||||
: null;
|
||||
|
||||
if (
|
||||
!isFieldMetadataSupportedInGroupBy({
|
||||
type: fieldMetadata.type,
|
||||
name: fieldMetadata.name,
|
||||
isSystem: fieldMetadata.isSystem,
|
||||
relationType,
|
||||
})
|
||||
) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Field "${fieldName}" is not supported in groupBy`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
|
||||
+6
-10
@@ -87,11 +87,9 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
authContext,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
let queryBuilder = repository.createQueryBuilder(
|
||||
objectMetadataNameSingular,
|
||||
);
|
||||
let queryBuilder = repository.createQueryBuilder(objectAlias);
|
||||
|
||||
const groupByFields =
|
||||
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
|
||||
@@ -101,8 +99,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
this.addJoinForGroupByOnRelationFields({
|
||||
queryBuilder,
|
||||
groupByFields,
|
||||
@@ -126,12 +122,12 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
|
||||
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
|
||||
queryBuilder,
|
||||
objectMetadataNameSingular,
|
||||
objectMetadataNameSingular: objectAlias,
|
||||
});
|
||||
|
||||
const groupByDefinitions = getGroupByDefinitions({
|
||||
groupByFields,
|
||||
objectMetadataNameSingular,
|
||||
objectMetadataNameSingular: objectAlias,
|
||||
});
|
||||
|
||||
groupByDefinitions.forEach((groupByColumn, index) => {
|
||||
@@ -308,7 +304,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
workspaceId: string;
|
||||
commonQueryParser: GraphqlQueryParser;
|
||||
}): Promise<void> {
|
||||
const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
if (args.viewId) {
|
||||
appliedFilters = await this.addFiltersFromView({
|
||||
@@ -322,7 +318,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
objectMetadataNameSingular,
|
||||
objectAlias,
|
||||
appliedFilters,
|
||||
);
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
type GroupByDateField,
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
|
||||
import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
|
||||
|
||||
// The query builder aliases the FROM table with `getObjectAlias` (= nameSingular),
|
||||
// NOT the physical table name (which is `_`-prefixed for custom objects via
|
||||
// `computeTableName`). These tests pin order-by clauses to that alias so the
|
||||
// parser stays in sync if the alias convention ever changes.
|
||||
describe('GraphqlQueryOrderGroupByParser - object alias in order-by clauses', () => {
|
||||
const OBJECT_ID = 'rocket-object-id';
|
||||
const OBJECT_UNIVERSAL_ID = 'rocket-object-universal-id';
|
||||
const NAME_FIELD_ID = 'rocket-name-field-id';
|
||||
const NAME_FIELD_UNIVERSAL_ID = 'rocket-name-field-universal-id';
|
||||
const DATE_FIELD_ID = 'rocket-launched-at-field-id';
|
||||
const DATE_FIELD_UNIVERSAL_ID = 'rocket-launched-at-field-universal-id';
|
||||
|
||||
const nameField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_ID,
|
||||
objectMetadataId: OBJECT_ID,
|
||||
type: FieldMetadataType.TEXT,
|
||||
id: NAME_FIELD_ID,
|
||||
name: 'name',
|
||||
});
|
||||
|
||||
const launchedAtField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: DATE_FIELD_UNIVERSAL_ID,
|
||||
objectMetadataId: OBJECT_ID,
|
||||
type: FieldMetadataType.DATE,
|
||||
id: DATE_FIELD_ID,
|
||||
name: 'launchedAt',
|
||||
});
|
||||
|
||||
// Custom object: physical table is `_rocket`, while the alias is `rocket`.
|
||||
const flatObjectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: OBJECT_UNIVERSAL_ID,
|
||||
id: OBJECT_ID,
|
||||
nameSingular: 'rocket',
|
||||
namePlural: 'rockets',
|
||||
isCustom: true,
|
||||
fieldIds: [NAME_FIELD_ID, DATE_FIELD_ID],
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[NAME_FIELD_UNIVERSAL_ID]: nameField,
|
||||
[DATE_FIELD_UNIVERSAL_ID]: launchedAtField,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[NAME_FIELD_ID]: NAME_FIELD_UNIVERSAL_ID,
|
||||
[DATE_FIELD_ID]: DATE_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
|
||||
|
||||
const flatObjectMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[OBJECT_UNIVERSAL_ID]: flatObjectMetadata,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[OBJECT_ID]: OBJECT_UNIVERSAL_ID,
|
||||
},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
|
||||
|
||||
const objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
const physicalTableName = computeTableName(
|
||||
flatObjectMetadata.nameSingular,
|
||||
flatObjectMetadata.isCustom,
|
||||
);
|
||||
|
||||
const buildParser = () =>
|
||||
new GraphqlQueryOrderGroupByParser(
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
it('uses the object alias as the table prefix for a custom object', () => {
|
||||
// Guards the assumption being tested: a custom object has a `_`-prefixed
|
||||
// physical table that is different from its query alias.
|
||||
expect(physicalTableName).toBe('_rocket');
|
||||
expect(objectAlias).toBe('rocket');
|
||||
expect(physicalTableName).not.toBe(objectAlias);
|
||||
});
|
||||
|
||||
it('prefixes a scalar group-by order-by with the alias, not the physical table', () => {
|
||||
const parser = buildParser();
|
||||
|
||||
const groupByField: GroupByRegularField = { fieldMetadata: nameField };
|
||||
|
||||
const result = parser.parse({
|
||||
orderBy: [{ name: OrderByDirection.AscNullsFirst }],
|
||||
groupByFields: [groupByField],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const orderByKey = Object.keys(result[0])[0];
|
||||
|
||||
expect(orderByKey).toBe(`"${objectAlias}"."name"`);
|
||||
expect(orderByKey).not.toContain(physicalTableName);
|
||||
});
|
||||
|
||||
it('prefixes a date-granularity group-by order-by with the alias, not the physical table', () => {
|
||||
const parser = buildParser();
|
||||
|
||||
const groupByField: GroupByDateField = {
|
||||
fieldMetadata: launchedAtField,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
};
|
||||
|
||||
const result = parser.parse({
|
||||
orderBy: [
|
||||
{
|
||||
launchedAt: {
|
||||
orderBy: OrderByDirection.AscNullsFirst,
|
||||
granularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
},
|
||||
},
|
||||
],
|
||||
groupByFields: [groupByField],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const orderByKey = Object.keys(result[0])[0];
|
||||
|
||||
expect(orderByKey).toContain(`"${objectAlias}".`);
|
||||
expect(orderByKey).not.toContain(physicalTableName);
|
||||
});
|
||||
});
|
||||
+7
-12
@@ -18,6 +18,7 @@ import {
|
||||
type GroupByRegularField,
|
||||
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
|
||||
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
|
||||
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
@@ -45,6 +46,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
private fieldIdByName: Record<string, string>;
|
||||
private objectAlias: string;
|
||||
|
||||
constructor(
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
@@ -54,6 +56,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.flatObjectMetadata = flatObjectMetadata;
|
||||
this.flatObjectMetadataMaps = flatObjectMetadataMaps;
|
||||
this.flatFieldMetadataMaps = flatFieldMetadataMaps;
|
||||
this.objectAlias = getObjectAlias(flatObjectMetadata);
|
||||
|
||||
const fieldMaps = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
@@ -85,7 +88,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
|
||||
availableAggregations,
|
||||
orderByArg,
|
||||
this.flatObjectMetadata,
|
||||
);
|
||||
|
||||
parsedOrderBy.push(parsedAggregateOrderBy);
|
||||
@@ -114,7 +116,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.parseObjectRecordOrderByForScalarField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
@@ -170,7 +171,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
this.parseObjectRecordOrderByForCompositeField({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata: this.flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
});
|
||||
|
||||
@@ -339,7 +339,6 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseAggregateOrderByArg = (
|
||||
availableAggregations: Record<string, AggregationField>,
|
||||
orderByArg: AggregateOrderByWithGroupByField,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
): Record<string, OrderByClause> => {
|
||||
const aggregate = orderByArg.aggregate;
|
||||
|
||||
@@ -359,7 +358,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
|
||||
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregateField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
this.objectAlias,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateExpression)) {
|
||||
@@ -379,12 +378,10 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseObjectRecordOrderByForScalarField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForScalarField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const groupByField = groupByFields.find(
|
||||
@@ -404,7 +401,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"`;
|
||||
const columnNameWithQuotes = `"${this.objectAlias}"."${fieldMetadata.name}"`;
|
||||
|
||||
const expression = getGroupByOrderExpression({
|
||||
groupByField,
|
||||
@@ -420,12 +417,10 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
private parseObjectRecordOrderByForCompositeField = ({
|
||||
groupByFields,
|
||||
orderByArg,
|
||||
flatObjectMetadata,
|
||||
fieldMetadata,
|
||||
}: {
|
||||
groupByFields: GroupByField[];
|
||||
orderByArg: ObjectRecordOrderByForCompositeField;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
}): Record<string, OrderByClause> | null => {
|
||||
const fieldName = Object.keys(orderByArg)[0];
|
||||
@@ -458,7 +453,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
return parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
orderBySubField,
|
||||
flatObjectMetadata.nameSingular,
|
||||
this.objectAlias,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -497,7 +492,7 @@ export class GraphqlQueryOrderGroupByParser {
|
||||
);
|
||||
}
|
||||
|
||||
const columnNameWithQuotes = `"${
|
||||
const columnNameWithQuotes = `"${this.objectAlias}"."${
|
||||
formatColumnNamesFromCompositeFieldAndSubfields(
|
||||
associatedGroupByField.fieldMetadata.name,
|
||||
associatedGroupByField.subFieldName
|
||||
|
||||
+6
-3
@@ -9,7 +9,11 @@ import {
|
||||
isInputObjectType,
|
||||
} from 'graphql';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, pascalCase } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
pascalCase,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
|
||||
import { GROUP_BY_DATE_GRANULARITY_INPUT_KEY } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator';
|
||||
@@ -20,7 +24,6 @@ import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-s
|
||||
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
|
||||
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
@@ -63,7 +66,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
|
||||
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
|
||||
|
||||
for (const fieldMetadata of fields) {
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
if (!isFieldMetadataSupportedInGroupBy(fieldMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -6,7 +6,10 @@ import {
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
import {
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
} from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
@@ -15,7 +18,6 @@ import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/ut
|
||||
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
const dateGranularityValues = Object.values(
|
||||
@@ -62,7 +64,7 @@ const buildGroupByEntriesAndDescriptions = (
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isFlatFieldMetadataSupportedInGroupBy(field)) {
|
||||
if (!isFieldMetadataSupportedInGroupBy(field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -3,11 +3,11 @@ import {
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
// Builds the per-field filter shape and full recursive filter schema
|
||||
@@ -28,11 +28,11 @@ export const generateRecordFilterSchema = ({
|
||||
|
||||
objectMetadata.fields.forEach((field) => {
|
||||
if (
|
||||
shouldExcludeFieldFromAgentToolSchema(
|
||||
field,
|
||||
true,
|
||||
shouldExcludeFieldFromAgentToolSchema({
|
||||
fieldName: field.name,
|
||||
isSystem: field.isSystem,
|
||||
additionalExcludedFieldNames,
|
||||
)
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
-89
@@ -1,15 +1,7 @@
|
||||
import { type CompositeProperty, FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
|
||||
import { isCompositePropertySupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-property-supported-in-group-by.util';
|
||||
import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
|
||||
|
||||
const buildFlatFieldMetadata = (
|
||||
type: FieldMetadataType,
|
||||
name = 'field',
|
||||
isSystem = false,
|
||||
): FlatFieldMetadata => ({ type, name, isSystem }) as FlatFieldMetadata;
|
||||
|
||||
const buildCompositeProperty = (
|
||||
type: FieldMetadataType,
|
||||
@@ -21,87 +13,6 @@ const buildCompositeProperty = (
|
||||
isRequired: false,
|
||||
});
|
||||
|
||||
describe('isFlatFieldMetadataSupportedInGroupBy', () => {
|
||||
it('returns false for low-level field types', () => {
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.RAW_JSON),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.FILES),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.POSITION),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for regular field types', () => {
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.TEXT),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for internal/system field names', () => {
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.TEXT, 'id'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'deletedAt'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR, 'searchVector'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.ACTOR, 'createdBy'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for createdAt and updatedAt date fields even if system', () => {
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'createdAt', true),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'updatedAt', true),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for other system fields', () => {
|
||||
expect(
|
||||
isFlatFieldMetadataSupportedInGroupBy(
|
||||
buildFlatFieldMetadata(
|
||||
FieldMetadataType.TEXT,
|
||||
'customSystemField',
|
||||
true,
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompositePropertySupportedInGroupBy', () => {
|
||||
it('returns false for hidden or raw_json composite properties', () => {
|
||||
expect(
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
|
||||
|
||||
const NON_GROUPABLE_FIELD_TYPES = new Set<FieldMetadataType>([
|
||||
FieldMetadataType.TS_VECTOR,
|
||||
FieldMetadataType.RAW_JSON,
|
||||
FieldMetadataType.FILES,
|
||||
FieldMetadataType.POSITION,
|
||||
]);
|
||||
|
||||
const ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES = new Set([
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]);
|
||||
|
||||
export const isFlatFieldMetadataSupportedInGroupBy = (
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
): boolean => {
|
||||
const isAlwaysGroupableSystemDateField =
|
||||
ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES.has(fieldMetadata.name) &&
|
||||
isFieldMetadataDateKind(fieldMetadata.type);
|
||||
|
||||
if (
|
||||
!isAlwaysGroupableSystemDateField &&
|
||||
shouldExcludeFieldFromAgentToolSchema(fieldMetadata)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !NON_GROUPABLE_FIELD_TYPES.has(fieldMetadata.type);
|
||||
};
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const shouldExcludeFieldFromAgentToolSchema = (
|
||||
field: FieldMetadataEntity | FlatFieldMetadata,
|
||||
excludeId = true,
|
||||
additionalExcludedFieldNames: string[] = [],
|
||||
): boolean => {
|
||||
const excludedFieldNames = [
|
||||
'deletedAt',
|
||||
'searchVector',
|
||||
'createdBy',
|
||||
...additionalExcludedFieldNames,
|
||||
];
|
||||
|
||||
if (excludeId) {
|
||||
excludedFieldNames.push('id');
|
||||
}
|
||||
|
||||
return excludedFieldNames.includes(field.name) || field.isSystem;
|
||||
};
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { validateRelationSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util';
|
||||
|
||||
const PET_OBJECT_ID = 'pet-object-id';
|
||||
const ROCKET_OBJECT_ID = 'rocket-object-id';
|
||||
const SURVEY_RESULT_OBJECT_ID = 'survey-result-object-id';
|
||||
|
||||
const buildFieldsByObjectId = (
|
||||
fields: FlatFieldMetadata[],
|
||||
): Map<string, FlatFieldMetadata[]> => {
|
||||
const map = new Map<string, FlatFieldMetadata[]>();
|
||||
|
||||
for (const field of fields) {
|
||||
const existing = map.get(field.objectMetadataId) ?? [];
|
||||
|
||||
existing.push(field);
|
||||
map.set(field.objectMetadataId, existing);
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
describe('validateRelationSubfield', () => {
|
||||
it('should resolve the concrete target for a per-target morph field with multiple targets', () => {
|
||||
const morphId = 'polymorphic-owner-morph-id';
|
||||
|
||||
const morphToRocket = getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'morph-rocket',
|
||||
objectMetadataId: PET_OBJECT_ID,
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
name: 'polymorphicOwnerRocket',
|
||||
morphId,
|
||||
relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
|
||||
});
|
||||
const morphToSurveyResult = getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'morph-survey-result',
|
||||
objectMetadataId: PET_OBJECT_ID,
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
name: 'polymorphicOwnerSurveyResult',
|
||||
morphId,
|
||||
relationTargetObjectMetadataId: SURVEY_RESULT_OBJECT_ID,
|
||||
});
|
||||
const rocketNameField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'rocket-name',
|
||||
objectMetadataId: ROCKET_OBJECT_ID,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'name',
|
||||
});
|
||||
|
||||
const allFields = [morphToRocket, morphToSurveyResult, rocketNameField];
|
||||
|
||||
expect(() =>
|
||||
validateRelationSubfield({
|
||||
field: morphToRocket,
|
||||
subFieldName: 'name',
|
||||
paramName: morphToRocket.name,
|
||||
allFields,
|
||||
fieldsByObjectId: buildFieldsByObjectId(allFields),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when the nested subfield does not exist on the resolved target', () => {
|
||||
const morphId = 'polymorphic-owner-morph-id';
|
||||
|
||||
const morphToRocket = getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'morph-rocket',
|
||||
objectMetadataId: PET_OBJECT_ID,
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
name: 'polymorphicOwnerRocket',
|
||||
morphId,
|
||||
relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
|
||||
});
|
||||
const rocketNameField = getFlatFieldMetadataMock({
|
||||
universalIdentifier: 'rocket-name',
|
||||
objectMetadataId: ROCKET_OBJECT_ID,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'name',
|
||||
});
|
||||
|
||||
const allFields = [morphToRocket, rocketNameField];
|
||||
|
||||
expect(() =>
|
||||
validateRelationSubfield({
|
||||
field: morphToRocket,
|
||||
subFieldName: 'nonExistingField',
|
||||
paramName: morphToRocket.name,
|
||||
allFields,
|
||||
fieldsByObjectId: buildFieldsByObjectId(allFields),
|
||||
}),
|
||||
).toThrow('not found');
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -40,7 +40,10 @@ export const validateRelationSubfield = ({
|
||||
|
||||
let targetObjectId = field.relationTargetObjectMetadataId ?? null;
|
||||
|
||||
if (field.type === FieldMetadataType.MORPH_RELATION) {
|
||||
if (
|
||||
field.type === FieldMetadataType.MORPH_RELATION &&
|
||||
!isDefined(targetObjectId)
|
||||
) {
|
||||
targetObjectId = resolveMorphTargetObjectId({ field, allFields });
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -11,6 +11,7 @@ export enum ChartDataExceptionCode {
|
||||
FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND',
|
||||
QUERY_EXECUTION_FAILED = 'QUERY_EXECUTION_FAILED',
|
||||
TRANSFORMATION_FAILED = 'TRANSFORMATION_FAILED',
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
}
|
||||
|
||||
const getChartDataExceptionUserFriendlyMessage = (
|
||||
@@ -29,6 +30,8 @@ const getChartDataExceptionUserFriendlyMessage = (
|
||||
return msg`Query execution failed.`;
|
||||
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
|
||||
return msg`Transformation failed.`;
|
||||
case ChartDataExceptionCode.PERMISSION_DENIED:
|
||||
return msg`You do not have permission to view this data.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
@@ -58,6 +61,7 @@ export const generateChartDataExceptionMessage = (
|
||||
[ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND]: `Field metadata not found${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.QUERY_EXECUTION_FAILED]: `Query execution failed${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.TRANSFORMATION_FAILED]: `Transformation failed${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.PERMISSION_DENIED]: `Permission denied${context ? `: ${context}` : ''}`,
|
||||
};
|
||||
|
||||
return messages[code];
|
||||
|
||||
+2
-11
@@ -37,6 +37,7 @@ import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/u
|
||||
import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
|
||||
import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
|
||||
|
||||
type GetBarChartDataParams = {
|
||||
workspaceId: string;
|
||||
@@ -188,17 +189,7 @@ export class BarChartDataService {
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Bar chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
throw wrapChartDataQueryError(error, 'Bar chart data retrieval failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-11
@@ -36,6 +36,7 @@ import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/u
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
|
||||
import { buildLineChartSeriesIdPrefix } from 'src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util';
|
||||
import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
|
||||
|
||||
type GetLineChartDataParams = {
|
||||
workspaceId: string;
|
||||
@@ -196,17 +197,7 @@ export class LineChartDataService {
|
||||
seriesIdPrefix,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Line chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
throw wrapChartDataQueryError(error, 'Line chart data retrieval failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-11
@@ -26,6 +26,7 @@ import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-fie
|
||||
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
|
||||
import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
|
||||
|
||||
type GetPieChartDataParams = {
|
||||
workspaceId: string;
|
||||
@@ -143,17 +144,7 @@ export class PieChartDataService {
|
||||
CalendarStartDay.MONDAY,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Pie chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
throw wrapChartDataQueryError(error, 'Pie chart data retrieval failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
|
||||
|
||||
describe('wrapChartDataQueryError', () => {
|
||||
it('should pass through an existing ChartDataException unchanged', () => {
|
||||
const original = new ChartDataException(
|
||||
'Object metadata not found',
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
|
||||
const result = wrapChartDataQueryError(original, 'Bar chart');
|
||||
|
||||
expect(result).toBe(original);
|
||||
});
|
||||
|
||||
it('should map a record-level permission error to PERMISSION_DENIED', () => {
|
||||
const permissionError = new PermissionsException(
|
||||
'Entity performing the request does not have permission',
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
|
||||
const result = wrapChartDataQueryError(permissionError, 'Bar chart');
|
||||
|
||||
expect(result).toBeInstanceOf(ChartDataException);
|
||||
expect(result.code).toBe(ChartDataExceptionCode.PERMISSION_DENIED);
|
||||
});
|
||||
|
||||
it('should wrap an unknown error as QUERY_EXECUTION_FAILED with the context prefix', () => {
|
||||
const result = wrapChartDataQueryError(
|
||||
new Error('boom'),
|
||||
'Bar chart data retrieval failed',
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(ChartDataException);
|
||||
expect(result.code).toBe(ChartDataExceptionCode.QUERY_EXECUTION_FAILED);
|
||||
expect(result.message).toContain('Bar chart data retrieval failed: boom');
|
||||
});
|
||||
|
||||
it('should not map a non-permission-denied PermissionsException to PERMISSION_DENIED', () => {
|
||||
const otherPermissionError = new PermissionsException(
|
||||
'Method not allowed',
|
||||
PermissionsExceptionCode.METHOD_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
const result = wrapChartDataQueryError(otherPermissionError, 'Bar chart');
|
||||
|
||||
expect(result.code).toBe(ChartDataExceptionCode.QUERY_EXECUTION_FAILED);
|
||||
});
|
||||
});
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
@@ -19,6 +20,8 @@ export const chartDataGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new NotFoundError(error.message);
|
||||
case ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION:
|
||||
throw new UserInputError(error.message);
|
||||
case ChartDataExceptionCode.PERMISSION_DENIED:
|
||||
throw new ForbiddenError(error.message);
|
||||
case ChartDataExceptionCode.QUERY_EXECUTION_FAILED:
|
||||
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
|
||||
throw new InternalServerError(error.message);
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
generateChartDataExceptionMessage,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
|
||||
export const wrapChartDataQueryError = (
|
||||
error: unknown,
|
||||
contextPrefix: string,
|
||||
): ChartDataException => {
|
||||
if (error instanceof ChartDataException) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof PermissionsException &&
|
||||
error.code === PermissionsExceptionCode.PERMISSION_DENIED
|
||||
) {
|
||||
return new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.PERMISSION_DENIED,
|
||||
error.message,
|
||||
),
|
||||
ChartDataExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
|
||||
return new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`${contextPrefix}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
};
|
||||
+40
@@ -1147,6 +1147,46 @@ describe('group-by resolver (integration)', () => {
|
||||
expect(wednesdayGroup.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('orders by a base-object date field while grouping by a relation field without ambiguous column error', async () => {
|
||||
// Grouping by the company relation joins the company table (which also
|
||||
// has a createdAt column). Ordering by the base object's own createdAt
|
||||
// must qualify the column with the object table name, otherwise the
|
||||
// "createdAt" reference is ambiguous across the joined tables.
|
||||
const response = await makeGraphqlAPIRequest(
|
||||
groupByOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
groupBy: [
|
||||
{
|
||||
createdAt: {
|
||||
granularity: 'DAY_OF_THE_WEEK',
|
||||
},
|
||||
},
|
||||
{
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: 'DAY_OF_THE_WEEK',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: {
|
||||
granularity: 'DAY_OF_THE_WEEK',
|
||||
orderBy: 'AscNullsFirst',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: filter2025,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.peopleGroupBy).toBeDefined();
|
||||
expect(Array.isArray(response.body.data.peopleGroupBy)).toBe(true);
|
||||
});
|
||||
|
||||
it('groups by one relation field - company createdAt with WEEK granularity and weekStartDay SUNDAY', async () => {
|
||||
const response = await makeGraphqlAPIRequest(
|
||||
groupByOperationFactory({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA = [
|
||||
'deletedAt',
|
||||
'searchVector',
|
||||
'createdBy',
|
||||
] as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FieldMetadataType } from '@/types';
|
||||
|
||||
export const FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY =
|
||||
new Set<FieldMetadataType>([
|
||||
FieldMetadataType.TS_VECTOR,
|
||||
FieldMetadataType.RAW_JSON,
|
||||
FieldMetadataType.FILES,
|
||||
FieldMetadataType.POSITION,
|
||||
]);
|
||||
@@ -29,8 +29,10 @@ export type { DocumentationPath } from './DocumentationPaths';
|
||||
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
|
||||
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
|
||||
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
|
||||
export { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from './ExcludedFieldNamesFromAgentToolSchema';
|
||||
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
|
||||
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
|
||||
export { FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY } from './FieldMetadataTypesNotSupportedInGroupBy';
|
||||
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
|
||||
export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues';
|
||||
export { GIN_COMPATIBLE_FIELD_TYPES } from './GinCompatibleFieldTypes';
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { FieldMetadataType, RelationType } from '@/types';
|
||||
import { isFieldMetadataSupportedInGroupBy } from '@/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy';
|
||||
|
||||
describe('isFieldMetadataSupportedInGroupBy', () => {
|
||||
it('returns false for field types not supported in groupBy', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.RAW_JSON,
|
||||
name: 'rawJsonField',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.TS_VECTOR,
|
||||
name: 'tsVectorField',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.FILES,
|
||||
name: 'filesField',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.POSITION,
|
||||
name: 'position',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for regular field types', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'stage',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for ONE_TO_MANY relation fields', () => {
|
||||
// ONE_TO_MANY relations keep their foreign key on the target object, so
|
||||
// there is no column on this object to group by.
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
name: 'polymorphicHelperRockets',
|
||||
isSystem: false,
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'opportunities',
|
||||
isSystem: false,
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for MANY_TO_ONE relation fields', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
name: 'polymorphicOwnerRocket',
|
||||
isSystem: false,
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.RELATION,
|
||||
name: 'company',
|
||||
isSystem: false,
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for internal field names', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'id',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'deletedAt',
|
||||
isSystem: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for system fields', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'customSystemField',
|
||||
isSystem: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for createdAt and updatedAt even when system', () => {
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'createdAt',
|
||||
isSystem: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
name: 'updatedAt',
|
||||
isSystem: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,4 +3,6 @@ export * from './isFieldMetadataDateKind';
|
||||
export * from './isFieldMetadataEligibleForFieldsWidget';
|
||||
export * from './isFieldMetadataNumericKind';
|
||||
export * from './isFieldMetadataSelectKind';
|
||||
export * from './isFieldMetadataSupportedInGroupBy';
|
||||
export * from './isFieldMetadataTextKind';
|
||||
export * from './shouldExcludeFieldFromAgentToolSchema';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY } from '@/constants';
|
||||
import { FieldMetadataType, RelationType } from '@/types';
|
||||
import { isFieldMetadataDateKind } from '@/utils/fieldMetadata/isFieldMetadataDateKind';
|
||||
import { shouldExcludeFieldFromAgentToolSchema } from '@/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
|
||||
|
||||
const ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES = new Set([
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]);
|
||||
|
||||
const RELATION_FIELD_METADATA_TYPES = new Set<FieldMetadataType>([
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
]);
|
||||
|
||||
export const isFieldMetadataSupportedInGroupBy = ({
|
||||
type,
|
||||
name,
|
||||
isSystem,
|
||||
relationType,
|
||||
}: {
|
||||
type: FieldMetadataType;
|
||||
name: string;
|
||||
isSystem: boolean;
|
||||
relationType?: RelationType | null;
|
||||
}): boolean => {
|
||||
const isAlwaysGroupableSystemDateField =
|
||||
ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES.has(name) &&
|
||||
isFieldMetadataDateKind(type);
|
||||
|
||||
if (
|
||||
!isAlwaysGroupableSystemDateField &&
|
||||
shouldExcludeFieldFromAgentToolSchema({ fieldName: name, isSystem })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
RELATION_FIELD_METADATA_TYPES.has(type) &&
|
||||
relationType === RelationType.ONE_TO_MANY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY.has(type);
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from '@/constants';
|
||||
|
||||
export const shouldExcludeFieldFromAgentToolSchema = ({
|
||||
fieldName,
|
||||
isSystem,
|
||||
excludeId = true,
|
||||
additionalExcludedFieldNames = [],
|
||||
}: {
|
||||
fieldName: string;
|
||||
isSystem: boolean;
|
||||
excludeId?: boolean;
|
||||
additionalExcludedFieldNames?: string[];
|
||||
}): boolean => {
|
||||
const excludedFieldNames = [
|
||||
...EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA,
|
||||
...additionalExcludedFieldNames,
|
||||
];
|
||||
|
||||
if (excludeId) {
|
||||
excludedFieldNames.push('id');
|
||||
}
|
||||
|
||||
return excludedFieldNames.includes(fieldName) || isSystem;
|
||||
};
|
||||
@@ -55,7 +55,9 @@ export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind
|
||||
export { isFieldMetadataEligibleForFieldsWidget } from './fieldMetadata/isFieldMetadataEligibleForFieldsWidget';
|
||||
export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind';
|
||||
export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
|
||||
export { isFieldMetadataSupportedInGroupBy } from './fieldMetadata/isFieldMetadataSupportedInGroupBy';
|
||||
export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';
|
||||
export { shouldExcludeFieldFromAgentToolSchema } from './fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
|
||||
export { extractFolderPathFilenameAndTypeOrThrow } from './files/extractFolderPathFilenameAndTypeOrThrow.util';
|
||||
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
|
||||
export { computeGqlOperationFilterForEmails } from './filter/compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForEmails';
|
||||
|
||||
Reference in New Issue
Block a user