diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx index a2c3b3ecfc..493b90064e 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx @@ -6,6 +6,7 @@ import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdow import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; @@ -13,10 +14,12 @@ import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useLingui } from '@lingui/react/macro'; import { useLocation } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; +import { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from 'twenty-shared/constants'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { IconChevronLeft, IconSettings } from 'twenty-ui/display'; @@ -29,6 +32,7 @@ export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => { objectMetadataItem, onContentChange, closeDropdown, + viewType, } = useObjectOptionsDropdown(); const recordGroupFieldMetadata = useRecoilComponentValue( @@ -39,6 +43,14 @@ export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => { hiddenRecordGroupIdsComponentSelector, ); + const visibleRecordGroupIds = useRecoilComponentFamilyValue( + visibleRecordGroupIdsComponentFamilySelector, + viewType, + ); + + const isVisibleLimitReached = + visibleRecordGroupIds.length >= VIEW_GROUP_VISIBLE_OPTIONS_MAX; + const { objectNamePlural } = useObjectNamePluralFromSingular({ objectNameSingular: objectMetadataItem.nameSingular, }); @@ -84,6 +96,7 @@ export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => { isDraggable={false} showSubheader={false} showDragGrip={false} + isVisibleLimitReached={isVisibleLimitReached} /> void; + isVisibleLimitReached?: boolean; }; export const RecordGroupMenuItemDraggable = ({ @@ -22,6 +23,7 @@ export const RecordGroupMenuItemDraggable = ({ showDragGrip, isDraggable, onVisibilityChange, + isVisibleLimitReached = false, }: RecordGroupMenuItemDraggableProps) => { const recordGroup = useRecoilValue( recordGroupDefinitionFamilyState(recordGroupId), @@ -35,6 +37,11 @@ export const RecordGroupMenuItemDraggable = ({ const getIconButtons = (recordGroup: RecordGroupDefinition) => { const groupValue = recordGroup.value; + + if (!recordGroup.isVisible && isVisibleLimitReached) { + return undefined; + } + const iconButtons = [ { Icon: recordGroup.isVisible ? IconEyeOff : IconEye, diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx index 3aa25a3c15..1220197726 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx @@ -20,6 +20,7 @@ type RecordGroupsVisibilityDropdownSectionProps = { title: string; showSubheader?: boolean; showDragGrip: boolean; + isVisibleLimitReached?: boolean; }; export const RecordGroupsVisibilityDropdownSection = ({ @@ -30,6 +31,7 @@ export const RecordGroupsVisibilityDropdownSection = ({ title, showSubheader = true, showDragGrip, + isVisibleLimitReached = false, }: RecordGroupsVisibilityDropdownSectionProps) => { const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { onDragEnd?.(result, provided); @@ -53,6 +55,7 @@ export const RecordGroupsVisibilityDropdownSection = ({ onVisibilityChange={onVisibilityChange} showDragGrip={showDragGrip} isDraggable={isDraggable} + isVisibleLimitReached={isVisibleLimitReached} /> )) ) : ( @@ -71,6 +74,7 @@ export const RecordGroupsVisibilityDropdownSection = ({ onVisibilityChange={onVisibilityChange} showDragGrip={showDragGrip} isDraggable={isDraggable} + isVisibleLimitReached={isVisibleLimitReached} /> } /> diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts index b7591c308a..3c1c509632 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts @@ -4,6 +4,7 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record- import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; @@ -11,6 +12,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { combineFilters, computeRecordGqlOperationFilter, + isDefined, turnAnyFieldFilterIntoRecordGqlFilter, } from 'twenty-shared/utils'; @@ -59,11 +61,38 @@ export const useRecordIndexGroupCommonQueryVariables = () => { additionalFieldMetadataId: recordGroupFieldMetadata?.id, }); - const combinedFilters = combineFilters([anyFieldFilter, requestFilters]); + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + + const visibleRecordGroupDefinitions = recordGroupDefinitions.filter( + (recordGroupDefinition) => recordGroupDefinition.isVisible, + ); + + const recordGroupValues = visibleRecordGroupDefinitions.map( + (recordGroupDefinition) => recordGroupDefinition.value, + ); + + const recordGroupOptionsFilter = isDefined(recordGroupFieldMetadata) + ? { + [recordGroupFieldMetadata.name]: { + in: recordGroupValues, + }, + } + : {}; + + const combinedFilters = combineFilters([ + anyFieldFilter, + requestFilters, + recordGroupOptionsFilter, + ]); + + const recordGroupsLimit = visibleRecordGroupDefinitions.length; return { combinedFilters, recordGqlFields, orderBy, + recordGroupsLimit, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts index 998d6554df..a2aa2d21cf 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts @@ -31,7 +31,7 @@ export const useRecordIndexGroupsRecordsLazyGroupBy = ({ const { objectMetadataItems } = useObjectMetadataItems(); const apolloCoreClient = useApolloCoreClient(); - const { combinedFilters, orderBy, recordGqlFields } = + const { combinedFilters, orderBy, recordGqlFields, recordGroupsLimit } = useRecordIndexGroupCommonQueryVariables(); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); @@ -55,11 +55,12 @@ export const useRecordIndexGroupsRecordsLazyGroupBy = ({ recordIndexGroupsRecordGroupsGroupByQuery, { variables: { - filter: { ...combinedFilters }, + filter: combinedFilters, groupBy: { ...groupByGqlInput, }, orderByForRecords: orderBy, + limit: recordGroupsLimit, }, client: apolloCoreClient, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util.ts index 5f52d88681..cb204167dc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util.ts @@ -1,3 +1,4 @@ +import { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from 'twenty-shared/constants'; import { type EnumFieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -77,16 +78,29 @@ export const recomputeViewGroupsOnFlatFieldMetadataOptionsUpdate = ({ ), ); + const remainingFlatViewGroups = flatViewGroups.filter( + (flatViewGroup) => + !flatViewGroupsToDelete.some( + (flatViewGroupToDelete) => + flatViewGroupToDelete.id === flatViewGroup.id, + ), + ); + const viewGroupsByViewId = reduceFlatViewGroupsByViewId({ - flatViewGroups: flatViewGroups.filter( - (flatViewGroup) => - !flatViewGroupsToDelete.some( - (flatViewGroupToDelete) => - flatViewGroupToDelete.id === flatViewGroup.id, - ), - ), + flatViewGroups: remainingFlatViewGroups, }); + // Count visible view groups per view to enforce the limit + const visibleViewGroupCountByViewId = remainingFlatViewGroups.reduce< + Record + >((acc, flatViewGroup) => { + if (flatViewGroup.isVisible) { + acc[flatViewGroup.viewId] = (acc[flatViewGroup.viewId] ?? 0) + 1; + } + + return acc; + }, {}); + const viewIds = Object.keys(viewGroupsByViewId.flatViewGroupRecordByViewId); const createdAt = new Date().toISOString(); @@ -103,6 +117,14 @@ export const recomputeViewGroupsOnFlatFieldMetadataOptionsUpdate = ({ ); } + const currentVisibleCount = visibleViewGroupCountByViewId[viewId] ?? 0; + const isVisible = currentVisibleCount < VIEW_GROUP_VISIBLE_OPTIONS_MAX; + + // Increment the count for future iterations if this group will be visible + if (isVisible) { + visibleViewGroupCountByViewId[viewId] = currentVisibleCount + 1; + } + const viewGroupId = v4(); return { @@ -114,7 +136,7 @@ export const recomputeViewGroupsOnFlatFieldMetadataOptionsUpdate = ({ updatedAt: createdAt, deletedAt: null, universalIdentifier: viewGroupId, - isVisible: true, + isVisible, fieldValue: option.value, position: viewGroupHighestPosition + createdOptionIndex + 1, applicationId: fromFlatFieldMetadata.applicationId, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-group/utils/compute-flat-view-groups-on-view-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-group/utils/compute-flat-view-groups-on-view-create.util.ts index 71f1ec3b88..23acf58c86 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-view-group/utils/compute-flat-view-groups-on-view-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-group/utils/compute-flat-view-groups-on-view-create.util.ts @@ -1,3 +1,4 @@ +import { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -44,7 +45,7 @@ export const computeFlatViewGroupsOnViewCreate = ({ updatedAt: createdAt, deletedAt: null, universalIdentifier: viewGroupId, - isVisible: true, + isVisible: index < VIEW_GROUP_VISIBLE_OPTIONS_MAX, fieldValue: option.value, position: index, applicationId: mainGroupByFieldMetadata.applicationId, @@ -55,6 +56,7 @@ export const computeFlatViewGroupsOnViewCreate = ({ if (mainGroupByFieldMetadata.isNullable === true) { const emptyGroupId = v4(); + const emptyGroupPosition = flatViewGroupsFromOptions.length; flatViewGroups.push({ id: emptyGroupId, @@ -64,9 +66,9 @@ export const computeFlatViewGroupsOnViewCreate = ({ updatedAt: createdAt, deletedAt: null, universalIdentifier: emptyGroupId, - isVisible: true, + isVisible: emptyGroupPosition < VIEW_GROUP_VISIBLE_OPTIONS_MAX, fieldValue: '', - position: flatViewGroupsFromOptions.length, + position: emptyGroupPosition, applicationId: mainGroupByFieldMetadata.applicationId, }); } diff --git a/packages/twenty-shared/src/constants/ViewGroupVisibleOptionsMax.ts b/packages/twenty-shared/src/constants/ViewGroupVisibleOptionsMax.ts new file mode 100644 index 0000000000..526701992e --- /dev/null +++ b/packages/twenty-shared/src/constants/ViewGroupVisibleOptionsMax.ts @@ -0,0 +1 @@ +export const VIEW_GROUP_VISIBLE_OPTIONS_MAX = 40; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 1f4db06df9..f716f5dfab 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -33,3 +33,4 @@ export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl'; +export { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from './ViewGroupVisibleOptionsMax';