From 2968085e738cc319f2a8a3e9c7e8e5d6d8e91654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Wed, 20 Nov 2024 17:03:18 +0100 Subject: [PATCH] Feat: revamp group by settings (#8503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fix #8202 that is revamping the `Options` settings for board and table. Screenshot 2024-11-15 at 11 47 52 AM Screenshot 2024-11-15 at 11 47 59 AM Screenshot 2024-11-15 at 11 48 10 AM Screenshot 2024-11-15 at 11 48 37 AM Screenshot 2024-11-15 at 11 48 44 AM Screenshot 2024-11-15 at 11 48 51 AM Screenshot 2024-11-15 at 11 49 00 AM --------- Co-authored-by: Charles Bochet --- .../components/ExportRecordsActionEffect.tsx | 5 +- .../components/ObjectOptionsDropdown.tsx | 57 +++ .../ObjectOptionsDropdownButton.tsx} | 6 +- .../ObjectOptionsDropdownContent.tsx | 32 ++ .../ObjectOptionsDropdownFieldsContent.tsx | 77 ++++ ...jectOptionsDropdownHiddenFieldsContent.tsx | 100 +++++ ...tionsDropdownHiddenRecordGroupsContent.tsx | 103 +++++ .../ObjectOptionsDropdownMenuContent.tsx | 145 +++++++ ...ptionsDropdownRecordGroupFieldsContent.tsx | 118 ++++++ ...tOptionsDropdownRecordGroupSortContent.tsx | 79 ++++ ...jectOptionsDropdownRecordGroupsContent.tsx | 138 +++++++ ...jectOptionsDropdownViewSettingsContent.tsx | 50 +++ .../ObjectOptionsDropdownContent.stories.tsx | 123 ++++++ .../ExportTableDataDefaultPageSize.ts | 0 .../constants/ObjectOptionsDropdownId.ts | 1 + .../useExportProcessRecordsForCSV.test.ts | 59 +++ .../useObjectOptionsForBoard.test.tsx | 104 +++++ .../useObjectOptionsForTable.test.tsx | 89 ++++ .../__tests__/useOptionsDropdown.test.tsx | 91 +++++ .../useSearchRecordGroupField.test.tsx | 65 +++ .../hooks/useExportProcessRecordsForCSV.ts | 39 ++ .../hooks/useObjectOptionsForBoard.ts} | 6 +- .../hooks/useObjectOptionsForTable.ts} | 2 +- .../hooks/useOptionsDropdown.ts | 26 ++ .../hooks/useSearchRecordGroupField.ts | 29 ++ .../contexts/ObjectOptionsDropdownContext.ts | 18 + ...ptionsDropdownSearchInputComponentState.ts | 9 + .../states/recordDeleteProgressState.ts | 0 .../types/ObjectOptionsContentId.ts | 8 + .../internal/useSetRecordBoardColumns.ts | 17 +- .../RecordBoardColumnDropdownMenu.tsx | 5 +- .../RecordGroupMenuItemDraggable.tsx | 65 +++ .../RecordGroupsVisibilityDropdownSection.tsx | 106 +++++ .../hooks/useRecordGroupActions.ts | 10 +- .../hooks/useRecordGroupVisibility.ts | 129 +++++- .../record-group/hooks/useRecordGroups.ts | 16 +- .../record-group/types/RecordGroupSort.ts | 5 + .../utils/sortRecordGroupDefinitions.ts | 31 ++ .../RecordIndexBoardDataLoaderEffect.tsx | 6 +- .../components/RecordIndexContainer.tsx | 4 +- .../__tests__/useExportFetchRecords.test.ts | 6 +- .../hooks/__tests__/useExportRecords.test.ts | 4 +- .../export/hooks/useExportFetchRecords.ts | 6 +- .../export/hooks/useExportRecords.ts | 11 +- .../hooks/useHandleRecordGroupField.ts | 110 +++++ .../hooks/useLoadRecordIndexBoard.ts | 6 +- .../hooks/useLoadRecordIndexTable.ts | 4 +- .../components/RecordIndexOptionsDropdown.tsx | 36 -- .../RecordIndexOptionsDropdownContent.tsx | 379 ------------------ .../RecordIndexBoardOptionsDropdownId.ts | 2 - .../constants/RecordIndexOptionsDropdownId.ts | 2 - ...ecordIndexRecordGroupHideComponentState.ts | 9 + ...ecordIndexRecordGroupSortComponentState.ts | 10 + ...rdGroupIsDraggableSortComponentSelector.ts | 21 + .../components/RecordTableRecordGroupRows.tsx | 13 +- .../RecordTableRecordGroupBodyEffect.tsx | 13 +- .../RecordTableRecordGroupBodyEffects.tsx | 1 + .../RecordTableRecordGroupsBody.tsx | 1 + ...ectRecordsSpreadsheetImportDialog.test.ts} | 4 +- ...enObjectRecordsSpreadsheetImportDialog.ts} | 2 +- .../settings/utils/getSettingsPagePath.ts | 16 +- .../src/modules/types/ExtractPathParams.ts | 6 + .../components/DropdownMenuItemsContainer.tsx | 3 +- .../utils/createComponentFamilySelectorV2.ts | 4 +- .../ViewGroupsVisibilityDropdownSection.tsx | 191 --------- .../internal/usePersistViewGroupRecords.ts | 39 ++ .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../enums/feature-flag-key.enum.ts | 1 + .../display/icon/components/TablerIcons.ts | 4 + .../menu-item/components/MenuItem.tsx | 8 +- .../components/MenuItemLeftContent.tsx | 62 +-- .../components/StyledMenuItemBase.tsx | 2 + .../src/theme/constants/SecondaryColors.ts | 1 + 73 files changed, 2222 insertions(+), 731 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx rename packages/twenty-front/src/modules/object-record/{record-index/options/components/RecordIndexOptionsDropdownButton.tsx => object-options-dropdown/components/ObjectOptionsDropdownButton.tsx} (64%) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx rename packages/twenty-front/src/modules/object-record/{record-index/options => object-options-dropdown}/constants/ExportTableDataDefaultPageSize.ts (100%) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForBoard.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForTable.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useSearchRecordGroupField.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts rename packages/twenty-front/src/modules/object-record/{record-index/options/hooks/useRecordIndexOptionsForBoard.ts => object-options-dropdown/hooks/useObjectOptionsForBoard.ts} (97%) rename packages/twenty-front/src/modules/object-record/{record-index/options/hooks/useRecordIndexOptionsForTable.ts => object-options-dropdown/hooks/useObjectOptionsForTable.ts} (95%) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState.ts rename packages/twenty-front/src/modules/object-record/{record-index/options => object-options-dropdown}/states/recordDeleteProgressState.ts (100%) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupSort.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-group/utils/sortRecordGroupDefinitions.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupSortComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector.ts rename packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/{useOpenObjectRecordsSpreasheetImportDialog.test.ts => useOpenObjectRecordsSpreadsheetImportDialog.test.ts} (98%) rename packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/{useOpenObjectRecordsSpreasheetImportDialog.ts => useOpenObjectRecordsSpreadsheetImportDialog.ts} (97%) create mode 100644 packages/twenty-front/src/modules/types/ExtractPathParams.ts delete mode 100644 packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx index 4ca2af1d5..602aa9623 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx @@ -35,10 +35,7 @@ export const ExportRecordsActionEffect = ({ scope: 'record-selection', key: 'export', position, - label: displayedExportProgress( - contextStoreNumberOfSelectedRecords > 0 ? 'selection' : 'all', - progress, - ), + label: displayedExportProgress(progress), Icon: IconDatabaseExport, accent: 'default', onClick: () => download(), diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx new file mode 100644 index 000000000..dd5890c71 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx @@ -0,0 +1,57 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectOptionsDropdownButton } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton'; +import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent'; +import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; +import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; +import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { ViewType } from '@/views/types/ViewType'; +import { useCallback, useState } from 'react'; + +type ObjectOptionsDropdownProps = { + viewType: ViewType; + objectMetadataItem: ObjectMetadataItem; + recordIndexId: string; +}; + +export const ObjectOptionsDropdown = ({ + recordIndexId, + objectMetadataItem, + viewType, +}: ObjectOptionsDropdownProps) => { + const [currentContentId, setCurrentContentId] = + useState(null); + + const handleContentChange = useCallback((key: ObjectOptionsContentId) => { + setCurrentContentId(key); + }, []); + + const handleResetContent = useCallback(() => { + setCurrentContentId(null); + }, []); + + return ( + } + dropdownMenuWidth={'200px'} + dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} + dropdownOffset={{ y: 8 }} + dropdownComponents={ + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx similarity index 64% rename from packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx rename to packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx index 6c47f815f..ecb46aa88 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx @@ -1,10 +1,10 @@ -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -export const RecordIndexOptionsDropdownButton = () => { +export const ObjectOptionsDropdownButton = () => { const { isDropdownOpen, toggleDropdown } = useDropdown( - RECORD_INDEX_OPTIONS_DROPDOWN_ID, + OBJECT_OPTIONS_DROPDOWN_ID, ); return ( diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx new file mode 100644 index 000000000..6cbaed731 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx @@ -0,0 +1,32 @@ +import { ObjectOptionsDropdownFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent'; +import { ObjectOptionsDropdownHiddenFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent'; +import { ObjectOptionsDropdownHiddenRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent'; +import { ObjectOptionsDropdownMenuContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent'; +import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent'; +import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent'; +import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent'; +import { ObjectOptionsDropdownViewSettingsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; + +export const ObjectOptionsDropdownContent = () => { + const { currentContentId } = useOptionsDropdown(); + + switch (currentContentId) { + case 'viewSettings': + return ; + case 'fields': + return ; + case 'hiddenFields': + return ; + case 'recordGroups': + return ; + case 'recordGroupFields': + return ; + case 'recordGroupSort': + return ; + case 'hiddenRecordGroups': + return ; + default: + return ; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx new file mode 100644 index 000000000..c6063a7f5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx @@ -0,0 +1,77 @@ +import { IconChevronLeft, IconEyeOff, MenuItemNavigate } from 'twenty-ui'; + +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; +import { ViewType } from '@/views/types/ViewType'; + +export const ObjectOptionsDropdownFieldsContent = () => { + const { + viewType, + recordIndexId, + objectMetadataItem, + onContentChange, + resetContent, + } = useOptionsDropdown(); + + const { + handleColumnVisibilityChange, + handleReorderColumns, + visibleTableColumns, + } = useObjectOptionsForTable(recordIndexId); + + const { + visibleBoardFields, + handleReorderBoardFields, + handleBoardFieldVisibilityChange, + } = useObjectOptionsForBoard({ + objectNameSingular: objectMetadataItem.nameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + const visibleRecordFields = + viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; + + const handleReorderFields = + viewType === ViewType.Kanban + ? handleReorderBoardFields + : handleReorderColumns; + + const handleChangeFieldVisibility = + viewType === ViewType.Kanban + ? handleBoardFieldVisibilityChange + : handleColumnVisibilityChange; + + return ( + <> + + Fields + + + + + + + onContentChange('hiddenFields')} + LeftIcon={IconEyeOff} + text="Hidden Fields" + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx new file mode 100644 index 000000000..c19550505 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx @@ -0,0 +1,100 @@ +import { useLocation } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; +import { + IconChevronLeft, + IconSettings, + MenuItem, + UndecoratedLink, +} from 'twenty-ui'; + +import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; + +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; +import { ViewType } from '@/views/types/ViewType'; + +export const ObjectOptionsDropdownHiddenFieldsContent = () => { + const { + viewType, + recordIndexId, + objectMetadataItem, + onContentChange, + closeDropdown, + } = useOptionsDropdown(); + + const { objectNamePlural } = useObjectNamePluralFromSingular({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const settingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, { + objectSlug: objectNamePlural, + }); + + const { handleColumnVisibilityChange, hiddenTableColumns } = + useObjectOptionsForTable(recordIndexId); + + const { hiddenBoardFields, handleBoardFieldVisibilityChange } = + useObjectOptionsForBoard({ + objectNameSingular: objectMetadataItem.nameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + const hiddenRecordFields = + viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns; + + const handleChangeFieldVisibility = + viewType === ViewType.Kanban + ? handleBoardFieldVisibilityChange + : handleColumnVisibilityChange; + + const location = useLocation(); + const setNavigationMemorizedUrl = useSetRecoilState( + navigationMemorizedUrlState, + ); + + return ( + <> + onContentChange('fields')} + > + Hidden Fields + + {hiddenRecordFields.length > 0 && ( + + + + )} + + + { + setNavigationMemorizedUrl(location.pathname + location.search); + closeDropdown(); + }} + > + + + + + + ); +}; 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 new file mode 100644 index 000000000..5e6810cfb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { + IconChevronLeft, + IconSettings, + MenuItem, + UndecoratedLink, +} from 'twenty-ui'; + +import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; + +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useLocation } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; + +export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => { + const { + currentContentId, + viewType, + recordIndexId, + objectMetadataItem, + onContentChange, + closeDropdown, + } = useOptionsDropdown(); + + const { objectNamePlural } = useObjectNamePluralFromSingular({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { hiddenRecordGroups, viewGroupFieldMetadataItem } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { handleVisibilityChange: handleRecordGroupVisibilityChange } = + useRecordGroupVisibility({ + viewBarId: recordIndexId, + viewType, + }); + + const viewGroupSettingsUrl = getSettingsPagePath( + SettingsPath.ObjectFieldEdit, + { + objectSlug: objectNamePlural, + fieldSlug: viewGroupFieldMetadataItem?.name ?? '', + }, + ); + + const location = useLocation(); + const setNavigationMemorizedUrl = useSetRecoilState( + navigationMemorizedUrlState, + ); + + useEffect(() => { + if ( + currentContentId === 'hiddenRecordGroups' && + hiddenRecordGroups.length === 0 + ) { + onContentChange('recordGroups'); + } + }, [hiddenRecordGroups, currentContentId, onContentChange]); + + return ( + <> + + onContentChange('recordGroups')} + > + Hidden {viewGroupFieldMetadataItem?.label} + + + + + + { + setNavigationMemorizedUrl(location.pathname + location.search); + closeDropdown(); + }} + > + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx new file mode 100644 index 000000000..56d5d7cc1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx @@ -0,0 +1,145 @@ +import { Key } from 'ts-key-enum'; +import { + IconFileExport, + IconFileImport, + IconLayout, + IconLayoutList, + IconList, + IconRotate2, + IconTag, + MenuItem, +} from 'twenty-ui'; + +import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; +import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; + +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { + displayedExportProgress, + useExportRecords, +} from '@/object-record/record-index/export/hooks/useExportRecords'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { ViewType } from '@/views/types/ViewType'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +export const ObjectOptionsDropdownMenuContent = () => { + const { + recordIndexId, + objectMetadataItem, + viewType, + onContentChange, + closeDropdown, + } = useOptionsDropdown(); + + const isViewGroupEnabled = useIsFeatureEnabled('IS_VIEW_GROUPS_ENABLED'); + + const { objectNamePlural } = useObjectNamePluralFromSingular({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + useScopedHotkeys( + [Key.Escape], + () => { + closeDropdown(); + }, + TableOptionsHotkeyScope.Dropdown, + ); + + const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } = + useHandleToggleTrashColumnFilter({ + objectNameSingular: objectMetadataItem.nameSingular, + viewBarId: recordIndexId, + }); + + const { visibleBoardFields } = useObjectOptionsForBoard({ + objectNameSingular: objectMetadataItem.nameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + const { viewGroupFieldMetadataItem } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { openObjectRecordsSpreasheetImportDialog } = + useOpenObjectRecordsSpreadsheetImportDialog( + objectMetadataItem.nameSingular, + ); + + const { progress, download } = useExportRecords({ + delayMs: 100, + filename: `${objectMetadataItem.nameSingular}.csv`, + objectMetadataItem, + recordIndexId, + viewType, + }); + + return ( + <> + + {objectMetadataItem.labelPlural} + + {/** TODO: Should be removed when view settings contains more options */} + {viewType === ViewType.Kanban && ( + <> + + onContentChange('viewSettings')} + LeftIcon={IconLayout} + text="View settings" + hasSubMenu + /> + + + + )} + + onContentChange('fields')} + LeftIcon={IconTag} + text="Fields" + contextualText={`${visibleBoardFields.length} shown`} + hasSubMenu + /> + {(viewType === ViewType.Kanban || isViewGroupEnabled) && ( + onContentChange('recordGroups')} + LeftIcon={IconLayoutList} + text="Group by" + contextualText={viewGroupFieldMetadataItem?.label} + hasSubMenu + /> + )} + + + + + openObjectRecordsSpreasheetImportDialog()} + LeftIcon={IconFileImport} + text="Import" + /> + { + handleToggleTrashColumnFilter(); + toggleSoftDeleteFilterState(true); + closeDropdown(); + }} + LeftIcon={IconRotate2} + text={`Deleted ${objectNamePlural}`} + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx new file mode 100644 index 000000000..bb8660242 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx @@ -0,0 +1,118 @@ +import { useEffect } from 'react'; +import { + IconChevronLeft, + IconSettings, + MenuItem, + UndecoratedLink, + useIcons, +} from 'twenty-ui'; + +import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; + +import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { useHandleRecordGroupField } from '@/object-record/record-index/hooks/useHandleRecordGroupField'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useLocation } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; + +export const ObjectOptionsDropdownRecordGroupFieldsContent = () => { + const { getIcon } = useIcons(); + + const { + currentContentId, + recordIndexId, + objectMetadataItem, + onContentChange, + closeDropdown, + } = useOptionsDropdown(); + + const { objectNamePlural } = useObjectNamePluralFromSingular({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { hiddenRecordGroups } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { + recordGroupFieldSearchInput, + setRecordGroupFieldSearchInput, + filteredRecordGroupFieldMetadataItems, + } = useSearchRecordGroupField(); + + const { handleRecordGroupFieldChange, resetRecordGroupField } = + useHandleRecordGroupField({ + viewBarComponentId: recordIndexId, + }); + + const newFieldSettingsUrl = getSettingsPagePath( + SettingsPath.ObjectNewFieldSelect, + { + objectSlug: objectNamePlural, + }, + ); + + const location = useLocation(); + const setNavigationMemorizedUrl = useSetRecoilState( + navigationMemorizedUrlState, + ); + + useEffect(() => { + if ( + currentContentId === 'hiddenRecordGroups' && + hiddenRecordGroups.length === 0 + ) { + onContentChange('recordGroups'); + } + }, [hiddenRecordGroups, currentContentId, onContentChange]); + + return ( + <> + onContentChange('recordGroups')} + > + Group by + + setRecordGroupFieldSearchInput(event.target.value)} + /> + + + {filteredRecordGroupFieldMetadataItems.map((fieldMetadataItem) => ( + { + handleRecordGroupFieldChange(fieldMetadataItem); + }} + LeftIcon={getIcon(fieldMetadataItem.icon)} + text={fieldMetadataItem.label} + /> + ))} + + + + { + setNavigationMemorizedUrl(location.pathname + location.search); + closeDropdown(); + }} + > + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx new file mode 100644 index 000000000..fcfeea029 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; +import { + IconChevronLeft, + IconHandMove, + IconSortAZ, + IconSortZA, + MenuItem, +} from 'twenty-ui'; + +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; + +export const ObjectOptionsDropdownRecordGroupSortContent = () => { + const { + currentContentId, + objectMetadataItem, + onContentChange, + closeDropdown, + } = useOptionsDropdown(); + + const { hiddenRecordGroups } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const setRecordGroupSort = useSetRecoilComponentStateV2( + recordIndexRecordGroupSortComponentState, + ); + + const handleRecordGroupSortChange = (sort: RecordGroupSort) => { + setRecordGroupSort(sort); + closeDropdown(); + }; + + useEffect(() => { + if ( + currentContentId === 'hiddenRecordGroups' && + hiddenRecordGroups.length === 0 + ) { + onContentChange('recordGroups'); + } + }, [hiddenRecordGroups, currentContentId, onContentChange]); + + return ( + <> + onContentChange('recordGroups')} + > + Sort + + + handleRecordGroupSortChange(RecordGroupSort.Manual)} + LeftIcon={IconHandMove} + text={RecordGroupSort.Manual} + /> + + handleRecordGroupSortChange(RecordGroupSort.Alphabetical) + } + LeftIcon={IconSortAZ} + text={RecordGroupSort.Alphabetical} + /> + + handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical) + } + LeftIcon={IconSortZA} + text={RecordGroupSort.ReverseAlphabetical} + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx new file mode 100644 index 000000000..94db1268f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import { + IconChevronLeft, + IconCircleOff, + IconEyeOff, + IconLayoutList, + IconSortDescending, + MenuItem, + MenuItemNavigate, + MenuItemToggle, +} from 'twenty-ui'; + +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; +import { useRecordGroupReorder } from '@/object-record/record-group/hooks/useRecordGroupReorder'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; +import { recordIndexRecordGroupHideComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupHideComponentState'; +import { recordIndexRecordGroupIsDraggableSortComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +export const ObjectOptionsDropdownRecordGroupsContent = () => { + const isViewGroupEnabled = useIsFeatureEnabled('IS_VIEW_GROUPS_ENABLED'); + + const { + currentContentId, + viewType, + recordIndexId, + objectMetadataItem, + onContentChange, + resetContent, + } = useOptionsDropdown(); + + const { + hiddenRecordGroups, + visibleRecordGroups, + viewGroupFieldMetadataItem, + } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const isDragableSortRecordGroup = useRecoilComponentValueV2( + recordIndexRecordGroupIsDraggableSortComponentSelector, + ); + + const hideEmptyRecordGroup = useRecoilComponentValueV2( + recordIndexRecordGroupHideComponentState, + ); + + const { + handleVisibilityChange: handleRecordGroupVisibilityChange, + handleHideEmptyRecordGroupChange, + } = useRecordGroupVisibility({ + viewBarId: recordIndexId, + viewType, + }); + + const { handleOrderChange: handleRecordGroupOrderChange } = + useRecordGroupReorder({ + objectNameSingular: objectMetadataItem.nameSingular, + viewBarId: recordIndexId, + }); + + useEffect(() => { + if ( + currentContentId === 'hiddenRecordGroups' && + hiddenRecordGroups.length === 0 + ) { + onContentChange('recordGroups'); + } + }, [hiddenRecordGroups, currentContentId, onContentChange]); + + return ( + <> + + Group by + + + {isViewGroupEnabled && ( + <> + onContentChange('recordGroupFields')} + LeftIcon={IconLayoutList} + text={ + !viewGroupFieldMetadataItem + ? 'Group by' + : `Group by "${viewGroupFieldMetadataItem.label}"` + } + hasSubMenu + /> + onContentChange('recordGroupSort')} + LeftIcon={IconSortDescending} + text="Sort" + hasSubMenu + /> + + )} + + + {visibleRecordGroups.length > 0 && ( + <> + + + + )} + {hiddenRecordGroups.length > 0 && ( + <> + + + onContentChange('hiddenRecordGroups')} + LeftIcon={IconEyeOff} + text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`} + /> + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent.tsx new file mode 100644 index 000000000..e5947e6e0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent.tsx @@ -0,0 +1,50 @@ +import { + IconBaselineDensitySmall, + IconChevronLeft, + MenuItemToggle, +} from 'twenty-ui'; + +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { ViewType } from '@/views/types/ViewType'; + +export const ObjectOptionsDropdownViewSettingsContent = () => { + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + + const { recordIndexId, objectMetadataItem, viewType, resetContent } = + useOptionsDropdown(); + + const { isCompactModeActive, setAndPersistIsCompactModeActive } = + useObjectOptionsForBoard({ + objectNameSingular: objectMetadataItem.nameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + return ( + <> + + View settings + + + {viewType === ViewType.Kanban && ( + + setAndPersistIsCompactModeActive( + !isCompactModeActive, + currentViewWithCombinedFiltersAndSorts, + ) + } + toggled={isCompactModeActive} + text="Compact view" + toggleSize="small" + /> + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx new file mode 100644 index 000000000..e4d33d839 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx @@ -0,0 +1,123 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui'; + +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent'; +import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; +import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { ViewType } from '@/views/types/ViewType'; +import { useEffect } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; +import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; + +const instanceId = 'entity-options-scope'; + +const meta: Meta = { + title: + 'Modules/ObjectRecord/ObjectOptionsDropdown/ObjectOptionsDropdownContent', + component: ObjectOptionsDropdownContent, + decorators: [ + (Story) => { + const setObjectMetadataItems = useSetRecoilState( + objectMetadataItemsState, + ); + + useEffect(() => { + setObjectMetadataItems(generatedMockObjectMetadataItems); + }, [setObjectMetadataItems]); + + return ( + {} }} + > + + + + + + + + + ); + }, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + ComponentDecorator, + IconsProviderDecorator, + ], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +const createStory = (contentId: ObjectOptionsContentId | null): Story => ({ + decorators: [ + (Story) => { + const companyObjectMetadataItem = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === 'company', + )!; + + return ( + '', + onIndexRecordsLoaded: () => {}, + onCreateRecord: () => {}, + objectNamePlural: 'companies', + objectNameSingular: 'company', + objectMetadataItem: companyObjectMetadataItem, + recordIndexId: instanceId, + }} + > + {}, + resetContent: () => {}, + }} + > + + + + + + ); + }, + ], +}); + +export const Default = createStory(null); + +export const ViewSettings = createStory('viewSettings'); + +export const Fields = createStory('fields'); + +export const HiddenFields = createStory('hiddenFields'); + +export const RecordGroups = createStory('recordGroups'); + +export const RecordGroupFields = createStory('recordGroupFields'); + +export const RecordGroupSort = createStory('recordGroupSort'); + +export const HiddenRecordGroups = createStory('hiddenRecordGroups'); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/ExportTableDataDefaultPageSize.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-index/options/constants/ExportTableDataDefaultPageSize.ts rename to packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize.ts diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId.ts new file mode 100644 index 000000000..1ca41ec6a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId.ts @@ -0,0 +1 @@ +export const OBJECT_OPTIONS_DROPDOWN_ID = 'object-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts new file mode 100644 index 000000000..4b243a928 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts @@ -0,0 +1,59 @@ +import { useExportProcessRecordsForCSV } from '@/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { FieldMetadataType } from '~/generated/graphql'; + +jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({ + useObjectMetadataItem: jest.fn(() => ({ + objectMetadataItem: { + fields: [ + { type: FieldMetadataType.Currency, name: 'price' }, + { type: FieldMetadataType.Text, name: 'name' }, + ], + }, + })), +})); + +describe('useExportProcessRecordsForCSV', () => { + it('processes records with currency fields correctly', () => { + const { result } = renderHook(() => + useExportProcessRecordsForCSV('someObject'), + ); + + const records = [ + { + __typename: 'ObjectRecord', + id: '1', + price: { amountMicros: 123456, currencyCode: 'USD' }, + name: 'Item 1', + }, + { + __typename: 'ObjectRecord', + id: '2', + price: { amountMicros: 789012, currencyCode: 'EUR' }, + name: 'Item 2', + }, + ]; + + let processedRecords; + + act(() => { + processedRecords = result.current.processRecordsForCSVExport(records); + }); + + expect(processedRecords).toEqual([ + { + __typename: 'ObjectRecord', + id: '1', + price: { amountMicros: 0.123456, currencyCode: 'USD' }, + name: 'Item 1', + }, + { + __typename: 'ObjectRecord', + id: '2', + price: { amountMicros: 0.789012, currencyCode: 'EUR' }, + name: 'Item 2', + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForBoard.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForBoard.test.tsx new file mode 100644 index 000000000..055e62271 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForBoard.test.tsx @@ -0,0 +1,104 @@ +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; +import { DropResult, ResponderProvided } from '@hello-pangea/dnd'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; + +jest.mock('@/views/hooks/useSaveCurrentViewFields', () => ({ + useSaveCurrentViewFields: jest.fn(() => ({ + saveViewFields: jest.fn(), + })), +})); + +jest.mock('@/views/hooks/useUpdateCurrentView', () => ({ + useUpdateCurrentView: jest.fn(() => ({ + updateCurrentView: jest.fn(), + })), +})); + +jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({ + useObjectMetadataItem: jest.fn(() => ({ + objectMetadataItem: { + fields: [ + { + id: 'field1', + name: 'field1', + label: 'Field 1', + isVisible: true, + position: 0, + }, + { + id: 'field2', + name: 'field2', + label: 'Field 2', + isVisible: true, + position: 1, + }, + ], + }, + })), +})); + +describe('useObjectOptionsForBoard', () => { + const initialRecoilState = [ + { fieldMetadataId: 'field1', isVisible: true, position: 0 }, + { fieldMetadataId: 'field2', isVisible: true, position: 1 }, + ]; + + const renderWithRecoil = () => + renderHook( + () => + useObjectOptionsForBoard({ + objectNameSingular: 'object', + recordBoardId: 'boardId', + viewBarId: 'viewBarId', + }), + { + wrapper: ({ children }) => ( + { + set(recordIndexFieldDefinitionsState, initialRecoilState as any); + }} + > + {children} + + ), + }, + ); + + it('reorders fields correctly', () => { + const { result } = renderWithRecoil(); + + const dropResult: DropResult = { + source: { droppableId: 'droppable', index: 1 }, + destination: { droppableId: 'droppable', index: 2 }, + draggableId: 'field1', + type: 'TYPE', + mode: 'FLUID', + reason: 'DROP', + combine: null, + }; + + const responderProvided: ResponderProvided = { + announce: jest.fn(), + }; + + act(() => { + result.current.handleReorderBoardFields(dropResult, responderProvided); + }); + + expect(result.current.visibleBoardFields).toEqual([ + { + fieldMetadataId: 'field2', + isVisible: true, + position: 0, + }, + { + fieldMetadataId: 'field1', + isVisible: true, + position: 1, + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForTable.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForTable.test.tsx new file mode 100644 index 000000000..989ab9d46 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useObjectOptionsForTable.test.tsx @@ -0,0 +1,89 @@ +import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable'; +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; +import { DropResult, ResponderProvided } from '@hello-pangea/dnd'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; + +describe('useObjectOptionsForTable', () => { + const initialRecoilState = [ + { fieldMetadataId: 'field1', isVisible: true, position: 0 }, + { fieldMetadataId: 'field2', isVisible: true, position: 1 }, + { fieldMetadataId: 'field3', isVisible: true, position: 2 }, + { fieldMetadataId: 'field4', isVisible: true, position: 3 }, + { fieldMetadataId: 'field5', isVisible: true, position: 4 }, + ]; + + const renderWithRecoil = () => + renderHook(() => useObjectOptionsForTable('instance-id'), { + wrapper: ({ children }) => ( + + { + set( + tableColumnsComponentState.atomFamily({ + instanceId: 'instance-id', + }), + initialRecoilState as any, + ); + }} + > + {children} + + + ), + }); + + it('reorders table columns correctly', () => { + const { result } = renderWithRecoil(); + + const dropResult = { + source: { droppableId: 'droppable', index: 2 }, + destination: { droppableId: 'droppable', index: 3 }, + draggableId: 'field3', + type: 'TYPE', + mode: 'FLUID', + reason: 'DROP', + combine: null, + } as DropResult; + + const responderProvided = { + announce: jest.fn(), + } as ResponderProvided; + + act(() => { + result.current.handleReorderColumns(dropResult, responderProvided); + }); + + expect(result.current.visibleTableColumns).toEqual([ + { + fieldMetadataId: 'field1', + isVisible: true, + position: 0, + }, + { + fieldMetadataId: 'field3', + isVisible: true, + position: 1, + }, + { + fieldMetadataId: 'field2', + isVisible: true, + position: 2, + }, + { + fieldMetadataId: 'field4', + isVisible: true, + position: 3, + }, + { + fieldMetadataId: 'field5', + isVisible: true, + position: 4, + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx new file mode 100644 index 000000000..763fd02d2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { ViewType } from '@/views/types/ViewType'; + +jest.mock('@/ui/layout/dropdown/hooks/useDropdown', () => ({ + useDropdown: jest.fn(() => ({ + closeDropdown: jest.fn(), + })), +})); + +describe('useOptionsDropdown', () => { + const mockOnContentChange = jest.fn(); + const mockCloseDropdown = jest.fn(); + const mockResetContent = jest.fn(); + + beforeEach(() => { + jest.mocked(useDropdown).mockReturnValue({ + scopeId: 'mock-scope', + isDropdownOpen: false, + closeDropdown: mockCloseDropdown, + toggleDropdown: jest.fn(), + openDropdown: jest.fn(), + dropdownWidth: undefined, + setDropdownWidth: jest.fn(), + dropdownPlacement: null, + setDropdownPlacement: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderWithProvider = (contextValue: Partial = {}) => { + const wrapper = ({ children }: any) => ( + + {children} + + ); + return renderHook(() => useOptionsDropdown(), { wrapper }); + }; + + it('provides closeDropdown functionality from the context', () => { + const { result } = renderWithProvider(); + + act(() => { + result.current.closeDropdown(); + }); + + expect(mockResetContent).toHaveBeenCalled(); + expect(mockCloseDropdown).toHaveBeenCalled(); + }); + + it('returns all context values', () => { + const { result } = renderWithProvider({ + currentContentId: 'fields', + }); + + expect(result.current).toHaveProperty('currentContentId', 'fields'); + expect(result.current).toHaveProperty( + 'onContentChange', + mockOnContentChange, + ); + expect(result.current).toHaveProperty('closeDropdown'); + expect(result.current).toHaveProperty('resetContent', mockResetContent); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useSearchRecordGroupField.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useSearchRecordGroupField.test.tsx new file mode 100644 index 000000000..caa57250e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useSearchRecordGroupField.test.tsx @@ -0,0 +1,65 @@ +import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; +import { FieldMetadataType } from '~/generated/graphql'; + +describe('useSearchRecordGroupField', () => { + const renderWithContext = (contextValue: any) => + renderHook(() => useSearchRecordGroupField(), { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); + + it('filters fields correctly based on input', () => { + const mockContextValue = { + objectMetadataItem: { + fields: [ + { type: FieldMetadataType.Select, label: 'First' }, + { type: FieldMetadataType.Select, label: 'Second' }, + { type: FieldMetadataType.Text, label: 'Third' }, + ], + }, + }; + + const { result } = renderWithContext(mockContextValue); + + act(() => { + result.current.setRecordGroupFieldSearchInput('First'); + }); + + expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([ + { type: FieldMetadataType.Select, label: 'First' }, + ]); + }); + + it('returns all select fields when search input is empty', () => { + const mockContextValue = { + objectMetadataItem: { + fields: [ + { type: FieldMetadataType.Select, label: 'First' }, + { type: FieldMetadataType.Select, label: 'Second' }, + { type: FieldMetadataType.Text, label: 'Third' }, + ], + }, + }; + + const { result } = renderWithContext(mockContextValue); + + expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([ + { type: FieldMetadataType.Select, label: 'First' }, + { type: FieldMetadataType.Select, label: 'Second' }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts new file mode 100644 index 000000000..491b96bb3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts @@ -0,0 +1,39 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from 'twenty-ui'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { convertCurrencyMicrosToCurrencyAmount } from '~/utils/convertCurrencyToCurrencyMicros'; + +export const useExportProcessRecordsForCSV = (objectNameSingular: string) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const processRecordsForCSVExport = (records: ObjectRecord[]) => { + return records.map((record) => { + const currencyFields = objectMetadataItem.fields.filter( + (field) => field.type === FieldMetadataType.Currency, + ); + + const processedRecord = { + ...record, + }; + + for (const currencyField of currencyFields) { + if (isDefined(record[currencyField.name])) { + processedRecord[currencyField.name] = { + amountMicros: convertCurrencyMicrosToCurrencyAmount( + record[currencyField.name].amountMicros, + ), + currencyCode: record[currencyField.name].currencyCode, + } satisfies FieldCurrencyValue; + } + } + + return processedRecord; + }); + }; + + return { processRecordsForCSVExport }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts similarity index 97% rename from packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts rename to packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts index c3d5e87d9..ccb19275b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard.ts @@ -16,17 +16,17 @@ import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -type useRecordIndexOptionsForBoardParams = { +type useObjectOptionsForBoardParams = { objectNameSingular: string; recordBoardId: string; viewBarId: string; }; -export const useRecordIndexOptionsForBoard = ({ +export const useObjectOptionsForBoard = ({ objectNameSingular, recordBoardId, viewBarId, -}: useRecordIndexOptionsForBoardParams) => { +}: useObjectOptionsForBoardParams) => { const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] = useRecoilState(recordIndexFieldDefinitionsState); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForTable.ts similarity index 95% rename from packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts rename to packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForTable.ts index 7e07284ed..4c8a5e1c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForTable.ts @@ -7,7 +7,7 @@ import { visibleTableColumnsComponentSelector } from '@/object-record/record-tab import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; -export const useRecordIndexOptionsForTable = (recordTableId: string) => { +export const useObjectOptionsForTable = (recordTableId: string) => { const hiddenTableColumns = useRecoilComponentValueV2( hiddenTableColumnsComponentSelector, recordTableId, diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts new file mode 100644 index 000000000..8c3bda6fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts @@ -0,0 +1,26 @@ +import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; +import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { useCallback, useContext } from 'react'; + +export const useOptionsDropdown = () => { + const { closeDropdown } = useDropdown(OBJECT_OPTIONS_DROPDOWN_ID); + + const context = useContext(ObjectOptionsDropdownContext); + + if (!context) { + throw new Error( + 'useOptionsDropdown must be used within a ObjectOptionsDropdownContext.Provider', + ); + } + + const handleCloseDropdown = useCallback(() => { + context.resetContent(); + closeDropdown(); + }, [closeDropdown, context]); + + return { + ...context, + closeDropdown: handleCloseDropdown, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts new file mode 100644 index 000000000..250554f52 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts @@ -0,0 +1,29 @@ +import { objectOptionsDropdownSearchInputComponentState } from '@/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useContext, useMemo } from 'react'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useSearchRecordGroupField = () => { + const { objectMetadataItem } = useContext(RecordIndexRootPropsContext); + + const [recordGroupFieldSearchInput, setRecordGroupFieldSearchInput] = + useRecoilComponentStateV2(objectOptionsDropdownSearchInputComponentState); + + const filteredRecordGroupFieldMetadataItems = useMemo(() => { + const searchInputLowerCase = + recordGroupFieldSearchInput.toLocaleLowerCase(); + + return objectMetadataItem.fields.filter( + (field) => + field.type === FieldMetadataType.Select && + field.label.toLocaleLowerCase().includes(searchInputLowerCase), + ); + }, [objectMetadataItem.fields, recordGroupFieldSearchInput]); + + return { + recordGroupFieldSearchInput, + setRecordGroupFieldSearchInput, + filteredRecordGroupFieldMetadataItems, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts new file mode 100644 index 000000000..c3abb0d67 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts @@ -0,0 +1,18 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; +import { ViewType } from '@/views/types/ViewType'; +import { createContext } from 'react'; + +export type ObjectOptionsDropdownContextValue = { + recordIndexId: string; + objectMetadataItem: ObjectMetadataItem; + viewType: ViewType; + currentContentId: ObjectOptionsContentId | null; + onContentChange: (key: ObjectOptionsContentId) => void; + resetContent: () => void; +}; + +export const ObjectOptionsDropdownContext = + createContext( + {} as ObjectOptionsDropdownContextValue, + ); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState.ts new file mode 100644 index 000000000..9852bad83 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState.ts @@ -0,0 +1,9 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const objectOptionsDropdownSearchInputComponentState = + createComponentStateV2({ + key: 'objectOptionsDropdownSearchInputComponentState', + defaultValue: '', + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/states/recordDeleteProgressState.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/recordDeleteProgressState.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-index/options/states/recordDeleteProgressState.ts rename to packages/twenty-front/src/modules/object-record/object-options-dropdown/states/recordDeleteProgressState.ts diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts new file mode 100644 index 000000000..2fe12df81 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts @@ -0,0 +1,8 @@ +export type ObjectOptionsContentId = + | 'viewSettings' + | 'fields' + | 'hiddenFields' + | 'recordGroups' + | 'hiddenRecordGroups' + | 'recordGroupFields' + | 'recordGroupSort'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts index ec2dffc65..58e00b990 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts @@ -2,12 +2,20 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { sortRecordGroupDefinitions } from '@/object-record/record-group/utils/sortRecordGroupDefinitions'; +import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useSetRecordBoardColumns = (recordBoardId?: string) => { const { scopeId, columnIdsState, columnsFamilySelector } = useRecordBoardStates(recordBoardId); + const recordGroupSort = useRecoilComponentValueV2( + recordIndexRecordGroupSortComponentState, + recordBoardId, + ); + const setColumns = useRecoilCallback( ({ set, snapshot }) => (columns: RecordGroupDefinition[]) => { @@ -15,7 +23,12 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { .getLoadable(columnIdsState) .getValue(); - const columnIds = columns + const sortedColumns = sortRecordGroupDefinitions( + columns, + recordGroupSort, + ); + + const columnIds = sortedColumns .filter(({ isVisible }) => isVisible) .map(({ id }) => id); @@ -35,7 +48,7 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { set(columnsFamilySelector(column.id), column); }); }, - [columnsFamilySelector, columnIdsState], + [columnIdsState, recordGroupSort, columnsFamilySelector], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx index bfbfb7e9d..ed6045033 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx @@ -5,6 +5,7 @@ import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRec import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { ViewType } from '@/views/types/ViewType'; import { MenuItem } from 'twenty-ui'; const StyledMenuContainer = styled.div` @@ -25,7 +26,9 @@ export const RecordBoardColumnDropdownMenu = ({ }: RecordBoardColumnDropdownMenuProps) => { const boardColumnMenuRef = useRef(null); - const recordGroupActions = useRecordGroupActions(); + const recordGroupActions = useRecordGroupActions({ + viewType: ViewType.Kanban, + }); const closeMenu = useCallback(() => { onClose(); diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx new file mode 100644 index 000000000..8aa81fe1b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx @@ -0,0 +1,65 @@ +import { IconEye, IconEyeOff, MenuItemDraggable, Tag } from 'twenty-ui'; + +import { + RecordGroupDefinition, + RecordGroupDefinitionType, +} from '@/object-record/record-group/types/RecordGroupDefinition'; +import { isDefined } from '~/utils/isDefined'; + +type RecordGroupMenuItemDraggableProps = { + recordGroup: RecordGroupDefinition; + showDragGrip?: boolean; + isDraggable?: boolean; + onVisibilityChange: (viewGroup: RecordGroupDefinition) => void; +}; + +export const RecordGroupMenuItemDraggable = ({ + recordGroup, + showDragGrip, + isDraggable, + onVisibilityChange, +}: RecordGroupMenuItemDraggableProps) => { + const isNoValue = recordGroup.type === RecordGroupDefinitionType.NoValue; + + const getIconButtons = (recordGroup: RecordGroupDefinition) => { + const iconButtons = [ + { + Icon: recordGroup.isVisible ? IconEyeOff : IconEye, + onClick: () => onVisibilityChange(recordGroup), + }, + ].filter(isDefined); + + return iconButtons.length ? iconButtons : undefined; + }; + + return ( + + } + accent={isNoValue || showDragGrip ? 'placeholder' : 'default'} + iconButtons={!isNoValue ? getIconButtons(recordGroup) : undefined} + showGrip={isNoValue ? true : showDragGrip} + isDragDisabled={isNoValue ? true : !isDraggable} + isHoverDisabled={isNoValue} + /> + ); +}; 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 new file mode 100644 index 000000000..4b742a50c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupsVisibilityDropdownSection.tsx @@ -0,0 +1,106 @@ +import { + DropResult, + OnDragEndResponder, + ResponderProvided, +} from '@hello-pangea/dnd'; +import { useRef } from 'react'; + +import { RecordGroupMenuItemDraggable } from '@/object-record/record-group/components/RecordGroupMenuItemDraggable'; +import { + RecordGroupDefinition, + RecordGroupDefinitionType, +} from '@/object-record/record-group/types/RecordGroupDefinition'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; + +type RecordGroupsVisibilityDropdownSectionProps = { + recordGroups: RecordGroupDefinition[]; + isDraggable: boolean; + onDragEnd?: OnDragEndResponder; + onVisibilityChange: (viewGroup: RecordGroupDefinition) => void; + title: string; + showSubheader?: boolean; + showDragGrip: boolean; +}; + +export const RecordGroupsVisibilityDropdownSection = ({ + recordGroups, + isDraggable, + onDragEnd, + onVisibilityChange, + title, + showSubheader = true, + showDragGrip, +}: RecordGroupsVisibilityDropdownSectionProps) => { + const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { + onDragEnd?.(result, provided); + }; + + const noValueRecordGroups = + recordGroups.filter( + (recordGroup) => recordGroup.type === RecordGroupDefinitionType.NoValue, + ) ?? []; + + const recordGroupsWithoutNoValueGroups = recordGroups.filter( + (recordGroup) => recordGroup.type !== RecordGroupDefinitionType.NoValue, + ); + + const ref = useRef(null); + + return ( +
+ {showSubheader && ( + {title} + )} + + {!!recordGroups.length && ( + <> + {!isDraggable ? ( + recordGroupsWithoutNoValueGroups.map((recordGroup) => ( + + )) + ) : ( + + {recordGroupsWithoutNoValueGroups.map( + (recordGroup, index) => ( + + } + /> + ), + )} + + } + /> + )} + {noValueRecordGroups.map((recordGroup) => ( + + ))} + + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts index 2fa75b470..42ad8b3f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts @@ -8,12 +8,19 @@ import { RecordGroupAction } from '@/object-record/record-group/types/RecordGrou import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { ViewType } from '@/views/types/ViewType'; import { useCallback, useContext, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { IconEyeOff, IconSettings, isDefined } from 'twenty-ui'; -export const useRecordGroupActions = () => { +type UseRecordGroupActionsParams = { + viewType: ViewType; +}; + +export const useRecordGroupActions = ({ + viewType, +}: UseRecordGroupActionsParams) => { const navigate = useNavigate(); const location = useLocation(); @@ -36,6 +43,7 @@ export const useRecordGroupActions = () => { const { handleVisibilityChange: handleRecordGroupVisibilityChange } = useRecordGroupVisibility({ viewBarId: recordIndexId, + viewType, }); const setNavigationMemorizedUrl = useSetRecoilState( diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts index c9ecbd776..5f877bff3 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts @@ -1,45 +1,130 @@ -import { useCallback } from 'react'; - +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; -import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { recordIndexRecordGroupHideComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupHideComponentState'; +import { tableRowIdsByGroupComponentFamilyState } from '@/object-record/record-table/states/tableRowIdsByGroupComponentFamilyState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups'; +import { ViewType } from '@/views/types/ViewType'; import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups'; +import { useRecoilCallback } from 'recoil'; type UseRecordGroupVisibilityParams = { viewBarId: string; + viewType: ViewType; }; export const useRecordGroupVisibility = ({ viewBarId, + viewType, }: UseRecordGroupVisibilityParams) => { - const [recordGroupDefinitions, setRecordGroupDefinitions] = - useRecoilComponentStateV2(recordGroupDefinitionsComponentState); + const recordGroupDefinitionsState = useRecoilComponentCallbackStateV2( + recordGroupDefinitionsComponentState, + ); + + const tableRowIdsByGroupFamilyState = useRecoilComponentCallbackStateV2( + tableRowIdsByGroupComponentFamilyState, + viewBarId, + ); + + const { recordIdsByColumnIdFamilyState } = useRecordBoardStates(viewBarId); + + const objectOptionsDropdownRecordGroupHideState = + useRecoilComponentCallbackStateV2(recordIndexRecordGroupHideComponentState); const { saveViewGroups } = useSaveCurrentViewGroups(viewBarId); - const handleVisibilityChange = useCallback( - async (updatedRecordGroupDefinition: RecordGroupDefinition) => { - const updatedRecordGroupDefinitions = recordGroupDefinitions.map( - (groupDefinition) => - groupDefinition.id === updatedRecordGroupDefinition.id - ? { - ...groupDefinition, - isVisible: !groupDefinition.isVisible, - } - : groupDefinition, - ); + const handleVisibilityChange = useRecoilCallback( + ({ snapshot, set }) => + async (updatedRecordGroupDefinition: RecordGroupDefinition) => { + const recordGroupDefinitions = getSnapshotValue( + snapshot, + recordGroupDefinitionsState, + ); - setRecordGroupDefinitions(updatedRecordGroupDefinitions); + const updatedRecordGroupDefinitions = recordGroupDefinitions.map( + (groupDefinition) => + groupDefinition.id === updatedRecordGroupDefinition.id + ? { + ...groupDefinition, + isVisible: !groupDefinition.isVisible, + } + : groupDefinition, + ); - saveViewGroups( - mapRecordGroupDefinitionsToViewGroups(updatedRecordGroupDefinitions), - ); - }, - [recordGroupDefinitions, setRecordGroupDefinitions, saveViewGroups], + set(recordGroupDefinitionsState, updatedRecordGroupDefinitions); + + saveViewGroups( + mapRecordGroupDefinitionsToViewGroups(updatedRecordGroupDefinitions), + ); + + // If visibility is manually toggled, we should reset the hideEmptyRecordGroup state + set(objectOptionsDropdownRecordGroupHideState, false); + }, + [ + objectOptionsDropdownRecordGroupHideState, + recordGroupDefinitionsState, + saveViewGroups, + ], + ); + + const handleHideEmptyRecordGroupChange = useRecoilCallback( + ({ snapshot, set }) => + async () => { + const recordGroupDefinitions = getSnapshotValue( + snapshot, + recordGroupDefinitionsState, + ); + + const currentHideState = getSnapshotValue( + snapshot, + objectOptionsDropdownRecordGroupHideState, + ); + + set(objectOptionsDropdownRecordGroupHideState, !currentHideState); + + const updatedRecordGroupDefinitions = recordGroupDefinitions.map( + (recordGroup) => { + // TODO: Maybe we can improve that and only use one state for both table and board + const recordGroupRowIds = + viewType === ViewType.Table + ? getSnapshotValue( + snapshot, + tableRowIdsByGroupFamilyState(recordGroup.id), + ) + : getSnapshotValue( + snapshot, + recordIdsByColumnIdFamilyState(recordGroup.id), + ); + + if (recordGroupRowIds.length > 0) { + return recordGroup; + } + + return { + ...recordGroup, + isVisible: currentHideState, + }; + }, + ); + + saveViewGroups( + mapRecordGroupDefinitionsToViewGroups(updatedRecordGroupDefinitions), + ); + }, + [ + recordGroupDefinitionsState, + objectOptionsDropdownRecordGroupHideState, + saveViewGroups, + viewType, + tableRowIdsByGroupFamilyState, + recordIdsByColumnIdFamilyState, + ], ); return { handleVisibilityChange, + handleHideEmptyRecordGroupChange, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts index 8dcea64d7..8f638dbbe 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts @@ -2,6 +2,8 @@ import { useMemo } from 'react'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { sortRecordGroupDefinitions } from '@/object-record/record-group/utils/sortRecordGroupDefinitions'; +import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; type UseRecordGroupsParams = { @@ -15,6 +17,10 @@ export const useRecordGroups = ({ recordGroupDefinitionsComponentState, ); + const recordGroupSort = useRecoilComponentValueV2( + recordIndexRecordGroupSortComponentState, + ); + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -35,14 +41,8 @@ export const useRecordGroups = ({ }, [objectMetadataItem, recordGroupDefinitions]); const visibleRecordGroups = useMemo( - () => - recordGroupDefinitions - .filter((boardGroup) => boardGroup.isVisible) - .sort( - (boardGroupA, boardGroupB) => - boardGroupA.position - boardGroupB.position, - ), - [recordGroupDefinitions], + () => sortRecordGroupDefinitions(recordGroupDefinitions, recordGroupSort), + [recordGroupDefinitions, recordGroupSort], ); const hiddenRecordGroups = useMemo( diff --git a/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupSort.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupSort.ts new file mode 100644 index 000000000..9ac2ccc18 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupSort.ts @@ -0,0 +1,5 @@ +export enum RecordGroupSort { + Manual = 'Manual', + Alphabetical = 'Alphabetical', + ReverseAlphabetical = 'Reverse Alphabetical', +} diff --git a/packages/twenty-front/src/modules/object-record/record-group/utils/sortRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-group/utils/sortRecordGroupDefinitions.ts new file mode 100644 index 000000000..45c3a68b6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/utils/sortRecordGroupDefinitions.ts @@ -0,0 +1,31 @@ +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; + +export const sortRecordGroupDefinitions = ( + recordGroupDefinitions: RecordGroupDefinition[], + recordGroupSort: RecordGroupSort, +) => { + const visibleGroups = recordGroupDefinitions.filter( + (boardGroup) => boardGroup.isVisible, + ); + + const compareAlphabetical = (a: string, b: string, reverse = false) => { + if (a < b) return reverse ? 1 : -1; + if (a > b) return reverse ? -1 : 1; + return 0; + }; + + switch (recordGroupSort) { + case RecordGroupSort.Alphabetical: + return visibleGroups.sort((a, b) => + compareAlphabetical(a.title.toLowerCase(), b.title.toLowerCase()), + ); + case RecordGroupSort.ReverseAlphabetical: + return visibleGroups.sort((a, b) => + compareAlphabetical(a.title.toLowerCase(), b.title.toLowerCase(), true), + ); + case RecordGroupSort.Manual: + default: + return visibleGroups.sort((a, b) => a.position - b.position); + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx index d1ef9eef4..02834d1e7 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx @@ -30,7 +30,7 @@ export const RecordIndexBoardDataLoaderEffect = ({ recordIndexFieldDefinitionsState, ); - const recordIndexGroupDefinitions = useRecoilComponentValueV2( + const recordGroupDefinitions = useRecoilComponentValueV2( recordGroupDefinitionsComponentState, ); @@ -67,8 +67,8 @@ export const RecordIndexBoardDataLoaderEffect = ({ }, [objectNameSingular, setObjectSingularName]); useEffect(() => { - setColumns(recordIndexGroupDefinitions); - }, [recordIndexGroupDefinitions, setColumns]); + setColumns(recordGroupDefinitions); + }, [recordGroupDefinitions, setColumns]); // TODO: Remove this duplicate useEffect by ensuring it's not here because // We want it to be triggered by a change of objectMetadataItem, which would be an anti-pattern diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 68a3b8e65..feda1b8b9 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -2,13 +2,13 @@ import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; +import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown'; import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader'; import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect'; import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; -import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; @@ -162,7 +162,7 @@ export const RecordIndexContainer = () => { { }), useRecordBoardHook: useRecordBoard(recordIndexId), kanbanFieldName: useRecoilValue(kanbanFieldNameState), - kanbanData: useRecordIndexOptionsForBoard({ + kanbanData: useObjectOptionsForBoard({ objectNameSingular: objectMetadataItem.nameSingular, recordBoardId: recordIndexId, viewBarId: recordIndexId, @@ -338,7 +338,7 @@ describe('useRecordData', () => { }), setKanbanFieldName: useRecordBoard(recordIndexId), kanbanFieldName: useRecoilValue(kanbanFieldNameState), - kanbanData: useRecordIndexOptionsForBoard({ + kanbanData: useObjectOptionsForBoard({ objectNameSingular: objectMetadataItem.nameSingular, recordBoardId: recordIndexId, viewBarId: recordIndexId, diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts index 4ec40323f..9c0c17294 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts @@ -86,7 +86,7 @@ describe('csvDownloader', () => { describe('displayedExportProgress', () => { it.each([ - [undefined, undefined, 'percentage', 'Export View as CSV'], + [undefined, undefined, 'percentage', 'Export'], [20, 50, 'percentage', 'Export (40%)'], [0, 100, 'number', 'Export (0)'], [10, 10, 'percentage', 'Export (100%)'], @@ -96,7 +96,7 @@ describe('displayedExportProgress', () => { 'displays the export progress', (exportedRecordCount, totalRecordCount, displayType, expected) => { expect( - displayedExportProgress('all', { + displayedExportProgress({ exportedRecordCount, totalRecordCount, displayType: displayType as 'percentage' | 'number', diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts index 469173de4..bcc82de30 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts @@ -11,10 +11,10 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; +import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize'; +import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useFindManyParams } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; -import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize'; -import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewType } from '@/views/types/ViewType'; @@ -62,7 +62,7 @@ export const useExportFetchRecords = ({ }); const [previousRecordCount, setPreviousRecordCount] = useState(0); - const { hiddenBoardFields } = useRecordIndexOptionsForBoard({ + const { hiddenBoardFields } = useObjectOptionsForBoard({ objectNameSingular: objectMetadataItem.nameSingular, recordBoardId: recordIndexId, viewBarId: recordIndexId, diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts index 1ecadcc07..8053db0c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts @@ -1,13 +1,13 @@ import { json2csv } from 'json-2-csv'; import { useMemo } from 'react'; +import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize'; +import { useExportProcessRecordsForCSV } from '@/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { UseRecordDataOptions, useExportFetchRecords, } from '@/object-record/record-index/export/hooks/useExportFetchRecords'; -import { useExportProcessRecordsForCSV } from '@/object-record/record-index/export/hooks/useExportProcessRecordsForCSV'; -import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { RelationDefinitionType } from '~/generated-metadata/graphql'; @@ -107,12 +107,9 @@ const percentage = (part: number, whole: number): number => { return Math.round((part / whole) * 100); }; -export const displayedExportProgress = ( - mode: 'all' | 'selection' = 'all', - progress?: ExportProgress, -): string => { +export const displayedExportProgress = (progress?: ExportProgress): string => { if (isUndefinedOrNull(progress?.exportedRecordCount)) { - return mode === 'all' ? 'Export View as CSV' : 'Export Selection as CSV'; + return 'Export'; } if ( diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts new file mode 100644 index 000000000..5e4300f0d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -0,0 +1,110 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords'; +import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { ViewGroup } from '@/views/types/ViewGroup'; +import { useRecoilCallback } from 'recoil'; +import { v4 } from 'uuid'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +type UseHandleRecordGroupFieldParams = { + viewBarComponentId: string; +}; + +export const useHandleRecordGroupField = ({ + viewBarComponentId, +}: UseHandleRecordGroupFieldParams) => { + const { createViewGroupRecords, deleteViewGroupRecords } = + usePersistViewGroupRecords(); + + const currentViewIdCallbackState = useRecoilComponentCallbackStateV2( + currentViewIdComponentState, + viewBarComponentId, + ); + + const { getViewFromCache } = useGetViewFromCache(); + + const handleRecordGroupFieldChange = useRecoilCallback( + ({ snapshot }) => + async (fieldMetadataItem: FieldMetadataItem) => { + const currentViewId = snapshot + .getLoadable(currentViewIdCallbackState) + .getValue(); + + if (!currentViewId) { + return; + } + + const view = await getViewFromCache(currentViewId); + + if (isUndefinedOrNull(view)) { + return; + } + + if ( + isUndefinedOrNull(fieldMetadataItem.options) || + fieldMetadataItem.options.length === 0 + ) { + return; + } + + const existingGroupKeys = new Set( + view.viewGroups.map( + (group) => `${group.fieldMetadataId}:${group.fieldValue}`, + ), + ); + + const viewGroupsToCreate = fieldMetadataItem.options + // Avoid creation of already existing view groups + .filter( + (option) => + !existingGroupKeys.has(`${fieldMetadataItem.id}:${option.value}`), + ) + .map( + (option, index) => + ({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: option.value, + isVisible: true, + position: index, + fieldMetadataId: fieldMetadataItem.id, + }) satisfies ViewGroup, + ); + + if (viewGroupsToCreate.length > 0) { + await createViewGroupRecords(viewGroupsToCreate, view); + } + }, + [createViewGroupRecords, currentViewIdCallbackState, getViewFromCache], + ); + + const resetRecordGroupField = useRecoilCallback( + ({ snapshot }) => + async () => { + const currentViewId = snapshot + .getLoadable(currentViewIdCallbackState) + .getValue(); + + if (!currentViewId) { + return; + } + + const view = await getViewFromCache(currentViewId); + + if (isUndefinedOrNull(view)) { + return; + } + + if (view.viewGroups.length === 0) { + return; + } + + await deleteViewGroupRecords(view.viewGroups); + }, + [deleteViewGroupRecords, currentViewIdCallbackState, getViewFromCache], + ); + + return { handleRecordGroupFieldChange, resetRecordGroupField }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts index 1dc4612d3..7b241909d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts @@ -50,13 +50,13 @@ export const useLoadRecordIndexBoard = ({ recordIndexViewFilterGroupsState, ); - const recordIndexGroupDefinitions = useRecoilComponentValueV2( + const recordGroupDefinitions = useRecoilComponentValueV2( recordGroupDefinitionsComponentState, ); useEffect(() => { - setColumns(recordIndexGroupDefinitions); - }, [recordIndexGroupDefinitions, setColumns]); + setColumns(recordGroupDefinitions); + }, [recordGroupDefinitions, setColumns]); const recordIndexFilters = useRecoilValue(recordIndexFiltersState); const recordIndexSorts = useRecoilValue(recordIndexSortsState); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts index 22f2043cf..8162bb7df 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts @@ -57,7 +57,9 @@ export const useFindManyParams = ( ); if (!fieldMetadataItem) { - return {}; + throw new Error( + `Field metadata item with id ${currentRecordGroupDefinition.fieldMetadataId} not found`, + ); } return { diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx deleted file mode 100644 index 3c2f5b2ba..000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton'; -import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent'; -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; -import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { ViewType } from '@/views/types/ViewType'; - -type RecordIndexOptionsDropdownProps = { - viewType: ViewType; - objectMetadataItem: ObjectMetadataItem; - recordIndexId: string; -}; - -export const RecordIndexOptionsDropdown = ({ - recordIndexId, - objectMetadataItem, - viewType, -}: RecordIndexOptionsDropdownProps) => { - return ( - } - dropdownMenuWidth={'200px'} - dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} - dropdownOffset={{ y: 8 }} - dropdownComponents={ - - } - /> - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx deleted file mode 100644 index 882d4bf1c..000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Key } from 'ts-key-enum'; -import { - IconBaselineDensitySmall, - IconChevronLeft, - IconEyeOff, - IconFileExport, - IconFileImport, - IconRotate2, - IconSettings, - IconTag, - MenuItem, - MenuItemNavigate, - MenuItemToggle, - UndecoratedLink, - useIcons, -} from 'twenty-ui'; - -import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; -import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; - -import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useRecordGroupReorder } from '@/object-record/record-group/hooks/useRecordGroupReorder'; -import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; -import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; -import { - displayedExportProgress, - useExportRecords, -} from '@/object-record/record-index/export/hooks/useExportRecords'; -import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; -import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; -import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { useOpenObjectRecordsSpreasheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog'; -import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; -import { SettingsPath } from '@/types/SettingsPath'; -import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; -import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; -import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; -import { ViewGroupsVisibilityDropdownSection } from '@/views/components/ViewGroupsVisibilityDropdownSection'; -import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; -import { ViewType } from '@/views/types/ViewType'; -import { useLocation } from 'react-router-dom'; -import { useSetRecoilState } from 'recoil'; - -type RecordIndexOptionsMenu = - | 'viewGroups' - | 'hiddenViewGroups' - | 'fields' - | 'hiddenFields'; - -type RecordIndexOptionsDropdownContentProps = { - recordIndexId: string; - objectMetadataItem: ObjectMetadataItem; - viewType: ViewType; -}; - -// TODO: Break this component down -export const RecordIndexOptionsDropdownContent = ({ - viewType, - recordIndexId, - objectMetadataItem, -}: RecordIndexOptionsDropdownContentProps) => { - const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); - - const { getIcon } = useIcons(); - - const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); - - const [currentMenu, setCurrentMenu] = useState< - RecordIndexOptionsMenu | undefined - >(undefined); - - const resetMenu = () => setCurrentMenu(undefined); - - const handleSelectMenu = (option: RecordIndexOptionsMenu) => { - setCurrentMenu(option); - }; - - const { objectNamePlural } = useObjectNamePluralFromSingular({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - - const settingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, { - objectSlug: objectNamePlural, - }); - - useScopedHotkeys( - [Key.Escape], - () => { - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - - const { - handleColumnVisibilityChange, - handleReorderColumns, - visibleTableColumns, - hiddenTableColumns, - } = useRecordIndexOptionsForTable(recordIndexId); - - const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } = - useHandleToggleTrashColumnFilter({ - objectNameSingular: objectMetadataItem.nameSingular, - viewBarId: recordIndexId, - }); - - const { - visibleBoardFields, - hiddenBoardFields, - handleReorderBoardFields, - handleBoardFieldVisibilityChange, - isCompactModeActive, - setAndPersistIsCompactModeActive, - } = useRecordIndexOptionsForBoard({ - objectNameSingular: objectMetadataItem.nameSingular, - recordBoardId: recordIndexId, - viewBarId: recordIndexId, - }); - - const { - hiddenRecordGroups, - visibleRecordGroups, - viewGroupFieldMetadataItem, - } = useRecordGroups({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - const { handleVisibilityChange: handleRecordGroupVisibilityChange } = - useRecordGroupVisibility({ - viewBarId: recordIndexId, - }); - const { handleOrderChange: handleRecordGroupOrderChange } = - useRecordGroupReorder({ - objectNameSingular: objectMetadataItem.nameSingular, - viewBarId: recordIndexId, - }); - - const viewGroupSettingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, { - id: viewGroupFieldMetadataItem?.name, - objectSlug: objectNamePlural, - }); - - const visibleRecordFields = - viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; - - const hiddenRecordFields = - viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns; - - const handleReorderFields = - viewType === ViewType.Kanban - ? handleReorderBoardFields - : handleReorderColumns; - - const handleChangeFieldVisibility = - viewType === ViewType.Kanban - ? handleBoardFieldVisibilityChange - : handleColumnVisibilityChange; - - const { openObjectRecordsSpreasheetImportDialog } = - useOpenObjectRecordsSpreasheetImportDialog(objectMetadataItem.nameSingular); - - const { progress, download } = useExportRecords({ - delayMs: 100, - filename: `${objectMetadataItem.nameSingular}.csv`, - objectMetadataItem, - recordIndexId, - viewType, - }); - - const location = useLocation(); - const setNavigationMemorizedUrl = useSetRecoilState( - navigationMemorizedUrlState, - ); - - const isViewGroupMenuItemVisible = - viewGroupFieldMetadataItem && - (visibleRecordGroups.length > 0 || hiddenRecordGroups.length > 0); - - const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( - contextStoreNumberOfSelectedRecordsComponentState, - ); - - const mode = contextStoreNumberOfSelectedRecords > 0 ? 'selection' : 'all'; - - useEffect(() => { - if (currentMenu === 'hiddenViewGroups' && hiddenRecordGroups.length === 0) { - setCurrentMenu('viewGroups'); - } - }, [hiddenRecordGroups, currentMenu]); - - return ( - <> - {!currentMenu && ( - - {isViewGroupMenuItemVisible && ( - handleSelectMenu('viewGroups')} - LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)} - text={viewGroupFieldMetadataItem.label} - hasSubMenu - /> - )} - handleSelectMenu('fields')} - LeftIcon={IconTag} - text="Fields" - hasSubMenu - /> - openObjectRecordsSpreasheetImportDialog()} - LeftIcon={IconFileImport} - text="Import" - /> - - { - handleToggleTrashColumnFilter(); - toggleSoftDeleteFilterState(true); - closeDropdown(); - }} - LeftIcon={IconRotate2} - text={`Deleted ${objectNamePlural}`} - /> - - )} - {currentMenu === 'viewGroups' && ( - <> - - {viewGroupFieldMetadataItem?.label} - - - {hiddenRecordGroups.length > 0 && ( - <> - - - handleSelectMenu('hiddenViewGroups')} - LeftIcon={IconEyeOff} - text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`} - /> - - - )} - - )} - {currentMenu === 'fields' && ( - <> - - Fields - - - - - - - handleSelectMenu('hiddenFields')} - LeftIcon={IconEyeOff} - text="Hidden Fields" - /> - - - )} - {currentMenu === 'hiddenViewGroups' && ( - <> - setCurrentMenu('viewGroups')} - > - Hidden {viewGroupFieldMetadataItem?.label} - - - - { - setNavigationMemorizedUrl(location.pathname + location.search); - closeDropdown(); - }} - > - - - - - - )} - {currentMenu === 'hiddenFields' && ( - <> - setCurrentMenu('fields')} - > - Hidden Fields - - {hiddenRecordFields.length > 0 && ( - - - - )} - - - { - setNavigationMemorizedUrl(location.pathname + location.search); - closeDropdown(); - }} - > - - - - - - )} - - {viewType === ViewType.Kanban && !currentMenu && ( - <> - - - - setAndPersistIsCompactModeActive( - !isCompactModeActive, - currentViewWithCombinedFiltersAndSorts, - ) - } - toggled={isCompactModeActive} - text="Compact view" - toggleSize="small" - /> - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts deleted file mode 100644 index 2ccb636a1..000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const RECORD_INDEX_BOARD_OPTIONS_DROPDOWN_ID = - 'record-index-table-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts deleted file mode 100644 index ec21fa258..000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const RECORD_INDEX_OPTIONS_DROPDOWN_ID = - 'record-index-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts new file mode 100644 index 000000000..3500e331e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts @@ -0,0 +1,9 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const recordIndexRecordGroupHideComponentState = + createComponentStateV2({ + key: 'recordIndexRecordGroupHideComponentState', + defaultValue: false, + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupSortComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupSortComponentState.ts new file mode 100644 index 000000000..6f1a1e00e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupSortComponentState.ts @@ -0,0 +1,10 @@ +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const recordIndexRecordGroupSortComponentState = + createComponentStateV2({ + key: 'recordIndexRecordGroupSortComponentState', + defaultValue: RecordGroupSort.Manual, + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector.ts new file mode 100644 index 000000000..077216691 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector.ts @@ -0,0 +1,21 @@ +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; +import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const recordIndexRecordGroupIsDraggableSortComponentSelector = + createComponentSelectorV2({ + key: 'recordIndexRecordGroupIsDraggableSortComponentSelector', + componentInstanceContext: ViewComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + return ( + get( + recordIndexRecordGroupSortComponentState.atomFamily({ + instanceId, + }), + ) === RecordGroupSort.Manual + ); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx index 74cafa957..52fd15c04 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx @@ -4,6 +4,7 @@ import { tableAllRowIdsComponentState } from '@/object-record/record-table/state import { tableRowIdsByGroupComponentFamilyState } from '@/object-record/record-table/states/tableRowIdsByGroupComponentFamilyState'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useMemo } from 'react'; export const RecordTableRecordGroupRows = () => { const recordGroupId = useCurrentRecordGroupId(); @@ -15,9 +16,17 @@ export const RecordTableRecordGroupRows = () => { recordGroupId, ); + const rowIndexMap = useMemo( + () => new Map(allRowIds.map((id, index) => [id, index])), + [allRowIds], + ); + return recordGroupRowIds.map((recordId) => { - // Find the index of the recordId in allRowIds - const rowIndex = allRowIds.indexOf(recordId); + const rowIndex = rowIndexMap.get(recordId); + + if (!rowIndex) { + throw new Error(`Row index for record id ${recordId} not found`); + } return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx index 831f1dc4f..155a0dcc6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx @@ -26,23 +26,17 @@ export const RecordTableRecordGroupBodyEffect = () => { hasRecordTableFetchedAllRecordsComponentStateV2, ); - const [lastShowPageRecordId, setLastShowPageRecordId] = useRecoilState( - lastShowPageRecordIdState, - ); + const [lastShowPageRecordId] = useRecoilState(lastShowPageRecordIdState); const { scrollToPosition } = useScrollToPosition(); useEffect(() => { if (isNonEmptyString(lastShowPageRecordId) && !hasInitializedScroll) { - const isRecordAlreadyFetched = records.some( + const recordPosition = records.findIndex( (record) => record.id === lastShowPageRecordId, ); - if (isRecordAlreadyFetched) { - const recordPosition = records.findIndex( - (record) => record.id === lastShowPageRecordId, - ); - + if (recordPosition !== -1) { const positionInPx = recordPosition * ROW_HEIGHT; scrollToPosition(positionInPx); @@ -56,7 +50,6 @@ export const RecordTableRecordGroupBodyEffect = () => { records, scrollToPosition, hasInitializedScroll, - setLastShowPageRecordId, ]); useEffect(() => { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects.tsx index c3579d2f2..9efde27e3 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects.tsx @@ -10,6 +10,7 @@ export const RecordTableRecordGroupBodyEffects = () => { return recordGroupDefinitions.map((recordGroupDefinition) => ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index 7f807346b..d1f6ccb2f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -36,6 +36,7 @@ export const RecordTableRecordGroupsBody = ({ {visibleRecordGroups.map((recordGroupDefinition) => ( diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts similarity index 98% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts index f3926196a..0fa098ca3 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreasheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts @@ -6,7 +6,7 @@ import { useRecoilValue } from 'recoil'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; -import { useOpenObjectRecordsSpreasheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog'; +import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; @@ -330,7 +330,7 @@ describe('useSpreadsheetCompanyImport', () => { ); const { openObjectRecordsSpreasheetImportDialog: openRecordSpreadsheetImport, - } = useOpenObjectRecordsSpreasheetImportDialog( + } = useOpenObjectRecordsSpreadsheetImportDialog( CoreObjectNameSingular.Company, ); return { diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts similarity index 97% rename from packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts rename to packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts index d90dfff54..fe5972792 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreasheetImportDialog.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts @@ -11,7 +11,7 @@ import { RelationDefinitionType, } from '~/generated-metadata/graphql'; -export const useOpenObjectRecordsSpreasheetImportDialog = ( +export const useOpenObjectRecordsSpreadsheetImportDialog = ( objectNameSingular: string, ) => { const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog(); diff --git a/packages/twenty-front/src/modules/settings/utils/getSettingsPagePath.ts b/packages/twenty-front/src/modules/settings/utils/getSettingsPagePath.ts index 97fa703c2..cc62b1bea 100644 --- a/packages/twenty-front/src/modules/settings/utils/getSettingsPagePath.ts +++ b/packages/twenty-front/src/modules/settings/utils/getSettingsPagePath.ts @@ -1,19 +1,25 @@ +import { ExtractPathParams } from '@/types/ExtractPathParams'; import { SettingsPath } from '@/types/SettingsPath'; import { isDefined } from '~/utils/isDefined'; -type PathParams = { +type Params = { + [K in ExtractPathParams]: string; +} & { id?: string; - objectSlug?: string; }; export const getSettingsPagePath = ( path: Path, - params?: PathParams, + params?: Params, ) => { let resultPath = `/settings/${path}`; - if (isDefined(params?.objectSlug)) { - resultPath = resultPath.replace(':objectSlug', params.objectSlug); + if (isDefined(params)) { + resultPath = resultPath.replace(/:([a-zA-Z]+)/g, (_, key) => { + const value = params[key as keyof Params]; + + return value; + }); } if (isDefined(params?.id)) { diff --git a/packages/twenty-front/src/modules/types/ExtractPathParams.ts b/packages/twenty-front/src/modules/types/ExtractPathParams.ts new file mode 100644 index 000000000..d9c14ef2c --- /dev/null +++ b/packages/twenty-front/src/modules/types/ExtractPathParams.ts @@ -0,0 +1,6 @@ +export type ExtractPathParams = + V extends `${string}:${infer Param}/${infer Rest}` + ? Param | ExtractPathParams<`/${Rest}`> + : V extends `${string}:${infer Param}` + ? Param + : never; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx index 8558249f0..304fd01bb 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuItemsContainer.tsx @@ -11,7 +11,6 @@ const StyledDropdownMenuItemsExternalContainer = styled.div<{ display: flex; flex-direction: column; - gap: 2px; max-height: ${({ hasMaxHeight }) => (hasMaxHeight ? '188px' : 'none')}; padding: var(--padding); @@ -24,7 +23,7 @@ const StyledScrollWrapper = styled(ScrollWrapper)` `; const StyledDropdownMenuItemsInternalContainer = styled.div` - align-items: flex-start; + align-items: stretch; display: flex; flex-direction: column; diff --git a/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2.ts b/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2.ts index 8662ca1e8..33010ba9f 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2.ts @@ -18,7 +18,7 @@ export function createComponentFamilySelectorV2< key: string; get: SelectorGetter>; componentInstanceContext: ComponentInstanceStateContext | null; -}): ComponentFamilySelectorV2; +}): ComponentFamilyReadOnlySelectorV2; export function createComponentFamilySelectorV2< ValueType, @@ -28,7 +28,7 @@ export function createComponentFamilySelectorV2< get: SelectorGetter>; set: SelectorSetter>; componentInstanceContext: ComponentInstanceStateContext | null; -}): ComponentFamilyReadOnlySelectorV2; +}): ComponentFamilySelectorV2; export function createComponentFamilySelectorV2< ValueType, diff --git a/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx deleted file mode 100644 index 795639f51..000000000 --- a/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { - DropResult, - OnDragEndResponder, - ResponderProvided, -} from '@hello-pangea/dnd'; -import { useRef } from 'react'; -import { IconEye, IconEyeOff, MenuItemDraggable, Tag } from 'twenty-ui'; - -import { - RecordGroupDefinition, - RecordGroupDefinitionType, -} from '@/object-record/record-group/types/RecordGroupDefinition'; -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; -import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; -import { isDefined } from '~/utils/isDefined'; - -type ViewGroupsVisibilityDropdownSectionProps = { - viewGroups: RecordGroupDefinition[]; - isDraggable: boolean; - onDragEnd?: OnDragEndResponder; - onVisibilityChange: (viewGroup: RecordGroupDefinition) => void; - title: string; - showSubheader: boolean; - showDragGrip: boolean; -}; - -export const ViewGroupsVisibilityDropdownSection = ({ - viewGroups, - isDraggable, - onDragEnd, - onVisibilityChange, - title, - showSubheader = true, - showDragGrip, -}: ViewGroupsVisibilityDropdownSectionProps) => { - const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { - onDragEnd?.(result, provided); - }; - - const getIconButtons = (index: number, viewGroup: RecordGroupDefinition) => { - const iconButtons = [ - { - Icon: viewGroup.isVisible ? IconEyeOff : IconEye, - onClick: () => onVisibilityChange(viewGroup), - }, - ].filter(isDefined); - - return iconButtons.length ? iconButtons : undefined; - }; - - const noValueViewGroups = - viewGroups.filter( - (viewGroup) => viewGroup.type === RecordGroupDefinitionType.NoValue, - ) ?? []; - - const viewGroupsWithoutNoValueGroups = viewGroups.filter( - (viewGroup) => viewGroup.type !== RecordGroupDefinitionType.NoValue, - ); - - const ref = useRef(null); - - return ( -
- {showSubheader && ( - {title} - )} - - {!!viewGroups.length && ( - <> - {!isDraggable ? ( - viewGroupsWithoutNoValueGroups.map( - (viewGroup, viewGroupIndex) => ( - - } - iconButtons={getIconButtons(viewGroupIndex, viewGroup)} - accent={showDragGrip ? 'placeholder' : 'default'} - showGrip={showDragGrip} - isDragDisabled={!isDraggable} - /> - ), - ) - ) : ( - - {viewGroupsWithoutNoValueGroups.map( - (viewGroup, viewGroupIndex) => ( - - } - iconButtons={getIconButtons( - viewGroupIndex, - viewGroup, - )} - accent={showDragGrip ? 'placeholder' : 'default'} - showGrip={showDragGrip} - isDragDisabled={!isDraggable} - /> - } - /> - ), - )} - - } - /> - )} - {noValueViewGroups.map((viewGroup) => ( - - } - accent={showDragGrip ? 'placeholder' : 'default'} - showGrip={true} - isDragDisabled={true} - isHoverDisabled - /> - ))} - - )} - -
- ); -}; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts index 5582c771f..7018eceb9 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts @@ -7,6 +7,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; +import { useDeleteOneRecordMutation } from '@/object-record/hooks/useDeleteOneRecordMutation'; import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation'; import { GraphQLView } from '@/views/types/GraphQLView'; import { ViewGroup } from '@/views/types/ViewGroup'; @@ -24,6 +25,10 @@ export const usePersistViewGroupRecords = () => { objectNameSingular: CoreObjectNameSingular.ViewGroup, }); + const { deleteOneRecordMutation } = useDeleteOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewGroup, + }); + const { objectMetadataItems } = useObjectMetadataItems(); const apolloClient = useApolloClient(); @@ -111,8 +116,42 @@ export const usePersistViewGroupRecords = () => { [apolloClient, updateOneRecordMutation], ); + const deleteViewGroupRecords = useCallback( + async (viewGroupsToDelete: ViewGroup[]) => { + if (!viewGroupsToDelete.length) return; + + const mutationPromises = viewGroupsToDelete.map((viewGroup) => + apolloClient.mutate<{ deleteViewGroup: ViewGroup }>({ + mutation: deleteOneRecordMutation, + variables: { + idToDelete: viewGroup.id, + }, + // Avoid cache being updated with stale data + fetchPolicy: 'no-cache', + }), + ); + + const mutationResults = await Promise.all(mutationPromises); + + mutationResults.forEach(({ data }) => { + const record = data?.['deleteViewGroup']; + + if (!record) return; + + apolloClient.cache.evict({ + id: apolloClient.cache.identify({ + __typename: 'ViewGroup', + id: record.id, + }), + }); + }); + }, + [apolloClient, deleteOneRecordMutation], + ); + return { createViewGroupRecords, updateViewGroupRecords, + deleteViewGroupRecords, }; }; diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index b224ce729..e22e68358 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -17,4 +17,5 @@ export type FeatureFlagKey = | 'IS_MICROSOFT_SYNC_ENABLED' | 'IS_ADVANCED_FILTERS_ENABLED' | 'IS_AGGREGATE_QUERY_ENABLED' - | 'IS_FAVORITE_FOLDER_ENABLED'; + | 'IS_FAVORITE_FOLDER_ENABLED' + | 'IS_VIEW_GROUPS_ENABLED'; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index a7fa1ac1a..8f2f8ee9b 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -17,4 +17,5 @@ export enum FeatureFlagKey { IsAggregateQueryEnabled = 'IS_AGGREGATE_QUERY_ENABLED', IsFavoriteFolderEnabled = 'IS_FAVORITE_FOLDER_ENABLED', IsFavoriteFolderEntityEnabled = 'IS_FAVORITE_FOLDER_ENTITY_ENABLED', + IsViewGroupsEnabled = 'IS_VIEW_GROUPS_ENABLED', } diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 9b14477b2..2f29a2788 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -155,7 +155,9 @@ export { IconKey, IconLanguage, IconLayersLinked, + IconLayout, IconLayoutKanban, + IconLayoutList, IconLayoutSidebarLeftCollapse, IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, @@ -215,8 +217,10 @@ export { IconSend, IconSettings, IconSettingsAutomation, + IconSortAZ, IconSlash, IconSortDescending, + IconSortZA, IconSparkles, IconSql, IconSquare, diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx index 8115b1570..49c6ae372 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx @@ -30,6 +30,7 @@ export type MenuItemProps = { onMouseLeave?: (event: MouseEvent) => void; testId?: string; text: ReactNode; + contextualText?: ReactNode; hasSubMenu?: boolean; }; @@ -44,6 +45,7 @@ export const MenuItem = ({ onMouseLeave, testId, text, + contextualText, hasSubMenu = false, }: MenuItemProps) => { const theme = useTheme(); @@ -68,7 +70,11 @@ export const MenuItem = ({ onMouseLeave={onMouseLeave} > - +
{showIconButtons && ( diff --git a/packages/twenty-ui/src/navigation/menu-item/internals/components/MenuItemLeftContent.tsx b/packages/twenty-ui/src/navigation/menu-item/internals/components/MenuItemLeftContent.tsx index 89e142a81..7ab51f794 100644 --- a/packages/twenty-ui/src/navigation/menu-item/internals/components/MenuItemLeftContent.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/internals/components/MenuItemLeftContent.tsx @@ -2,6 +2,7 @@ import { useTheme } from '@emotion/react'; import { isString } from '@sniptt/guards'; import { ReactNode } from 'react'; +import styled from '@emotion/styled'; import { IconComponent, IconGripVertical, @@ -13,18 +14,37 @@ import { StyledMenuItemLeftContent, } from './StyledMenuItemBase'; +const StyledContextualText = styled.div` + color: ${({ theme }) => theme.color.gray35}; + font-family: inherit; + + font-size: inherit; + font-weight: inherit; + max-width: 100%; + overflow: hidden; + + text-decoration: inherit; + text-overflow: ellipsis; + + white-space: nowrap; + + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + type MenuItemLeftContentProps = { className?: string; LeftIcon: IconComponent | null | undefined; showGrip?: boolean; isDisabled?: boolean; text: ReactNode; + contextualText?: ReactNode; }; export const MenuItemLeftContent = ({ className, LeftIcon, text, + contextualText, showGrip = false, isDisabled = false, }: MenuItemLeftContentProps) => { @@ -32,37 +52,27 @@ export const MenuItemLeftContent = ({ return ( - {showGrip && - (isDisabled ? ( - - - - ) : ( - - - - ))} + {showGrip && ( + + + + )} {LeftIcon && ( )} {isString(text) ? : text} + {isString(contextualText) ? ( + {`· ${contextualText}`} + ) : ( + contextualText + )} ); diff --git a/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx b/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx index 8bfe6d220..b713e5b72 100644 --- a/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx @@ -69,6 +69,8 @@ export const StyledMenuItemBase = styled.div` `; export const StyledMenuItemLabel = styled.div<{ hasLeftIcon: boolean }>` + display: flex; + flex-direction: row; font-size: ${({ theme }) => theme.font.size.md}; font-weight: ${({ theme }) => theme.font.weight.regular}; diff --git a/packages/twenty-ui/src/theme/constants/SecondaryColors.ts b/packages/twenty-ui/src/theme/constants/SecondaryColors.ts index f023f8e74..a44505ef1 100644 --- a/packages/twenty-ui/src/theme/constants/SecondaryColors.ts +++ b/packages/twenty-ui/src/theme/constants/SecondaryColors.ts @@ -88,6 +88,7 @@ export const SECONDARY_COLORS = { gray60: GRAY_SCALE.gray55, gray50: GRAY_SCALE.gray40, gray40: GRAY_SCALE.gray25, + gray35: GRAY_SCALE.gray35, gray30: GRAY_SCALE.gray20, gray20: GRAY_SCALE.gray15, gray10: GRAY_SCALE.gray10,