Feat: revamp group by settings (#8503)

This PR fix #8202 that is revamping the `Options` settings for board and
table.

<img width="221" alt="Screenshot 2024-11-15 at 11 47 52 AM"
src="https://github.com/user-attachments/assets/0b143c95-810d-408b-b19e-c2678cd5653a">
<img width="214" alt="Screenshot 2024-11-15 at 11 47 59 AM"
src="https://github.com/user-attachments/assets/3468734a-8174-4e36-a8ee-08dad6c56227">
<img width="210" alt="Screenshot 2024-11-15 at 11 48 10 AM"
src="https://github.com/user-attachments/assets/300628f5-6645-4f1c-af8a-befce2714716">
<img width="212" alt="Screenshot 2024-11-15 at 11 48 37 AM"
src="https://github.com/user-attachments/assets/37a3db40-2146-45eb-bea4-44e1041f5bcf">
<img width="214" alt="Screenshot 2024-11-15 at 11 48 44 AM"
src="https://github.com/user-attachments/assets/42d2adcc-8f03-4f28-928b-d3c3d54d388a">
<img width="213" alt="Screenshot 2024-11-15 at 11 48 51 AM"
src="https://github.com/user-attachments/assets/90824568-b979-46a7-9841-ab8b9978e138">
<img width="211" alt="Screenshot 2024-11-15 at 11 49 00 AM"
src="https://github.com/user-attachments/assets/fa22446a-b1db-4d97-9a45-0778bf09ae3c">

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-11-20 17:03:18 +01:00
committed by GitHub
parent 0f7ebd3026
commit 2968085e73
73 changed files with 2222 additions and 731 deletions

View File

@ -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<ObjectOptionsContentId | null>(null);
const handleContentChange = useCallback((key: ObjectOptionsContentId) => {
setCurrentContentId(key);
}, []);
const handleResetContent = useCallback(() => {
setCurrentContentId(null);
}, []);
return (
<Dropdown
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
clickableComponent={<ObjectOptionsDropdownButton />}
dropdownMenuWidth={'200px'}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: 8 }}
dropdownComponents={
<ObjectOptionsDropdownContext.Provider
value={{
viewType,
objectMetadataItem,
recordIndexId,
currentContentId,
onContentChange: handleContentChange,
resetContent: handleResetContent,
}}
>
<ObjectOptionsDropdownContent />
</ObjectOptionsDropdownContext.Provider>
}
/>
);
};

View File

@ -0,0 +1,18 @@
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 ObjectOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown(
OBJECT_OPTIONS_DROPDOWN_ID,
);
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={toggleDropdown}
>
Options
</StyledHeaderDropdownButton>
);
};

View File

@ -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 <ObjectOptionsDropdownViewSettingsContent />;
case 'fields':
return <ObjectOptionsDropdownFieldsContent />;
case 'hiddenFields':
return <ObjectOptionsDropdownHiddenFieldsContent />;
case 'recordGroups':
return <ObjectOptionsDropdownRecordGroupsContent />;
case 'recordGroupFields':
return <ObjectOptionsDropdownRecordGroupFieldsContent />;
case 'recordGroupSort':
return <ObjectOptionsDropdownRecordGroupSortContent />;
case 'hiddenRecordGroups':
return <ObjectOptionsDropdownHiddenRecordGroupsContent />;
default:
return <ObjectOptionsDropdownMenuContent />;
}
};

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
Fields
</DropdownMenuHeader>
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer">
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleRecordFields}
isDraggable
onDragEnd={handleReorderFields}
onVisibilityChange={handleChangeFieldVisibility}
showSubheader={false}
showDragGrip={true}
/>
</ScrollWrapper>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}
text="Hidden Fields"
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('fields')}
>
Hidden Fields
</DropdownMenuHeader>
{hiddenRecordFields.length > 0 && (
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer">
<ViewFieldsVisibilityDropdownSection
title="Hidden"
fields={hiddenRecordFields}
isDraggable={false}
onVisibilityChange={handleChangeFieldVisibility}
showSubheader={false}
showDragGrip={false}
/>
</ScrollWrapper>
)}
<DropdownMenuSeparator />
<UndecoratedLink
to={settingsUrl}
onClick={() => {
setNavigationMemorizedUrl(location.pathname + location.search);
closeDropdown();
}}
>
<DropdownMenuItemsContainer>
<MenuItem LeftIcon={IconSettings} text="Edit Fields" />
</DropdownMenuItemsContainer>
</UndecoratedLink>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuItemsContainer>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('recordGroups')}
>
Hidden {viewGroupFieldMetadataItem?.label}
</DropdownMenuHeader>
</DropdownMenuItemsContainer>
<RecordGroupsVisibilityDropdownSection
title={`Hidden ${viewGroupFieldMetadataItem?.label}`}
recordGroups={hiddenRecordGroups}
onVisibilityChange={handleRecordGroupVisibilityChange}
isDraggable={false}
showSubheader={false}
showDragGrip={false}
/>
<DropdownMenuSeparator />
<UndecoratedLink
to={viewGroupSettingsUrl}
onClick={() => {
setNavigationMemorizedUrl(location.pathname + location.search);
closeDropdown();
}}
>
<DropdownMenuItemsContainer>
<MenuItem LeftIcon={IconSettings} text="Edit field values" />
</DropdownMenuItemsContainer>
</UndecoratedLink>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconList}>
{objectMetadataItem.labelPlural}
</DropdownMenuHeader>
{/** TODO: Should be removed when view settings contains more options */}
{viewType === ViewType.Kanban && (
<>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => onContentChange('viewSettings')}
LeftIcon={IconLayout}
text="View settings"
hasSubMenu
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => onContentChange('fields')}
LeftIcon={IconTag}
text="Fields"
contextualText={`${visibleBoardFields.length} shown`}
hasSubMenu
/>
{(viewType === ViewType.Kanban || isViewGroupEnabled) && (
<MenuItem
onClick={() => onContentChange('recordGroups')}
LeftIcon={IconLayoutList}
text="Group by"
contextualText={viewGroupFieldMetadataItem?.label}
hasSubMenu
/>
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItem
onClick={download}
LeftIcon={IconFileExport}
text={displayedExportProgress(progress)}
/>
<MenuItem
onClick={() => openObjectRecordsSpreasheetImportDialog()}
LeftIcon={IconFileImport}
text="Import"
/>
<MenuItem
onClick={() => {
handleToggleTrashColumnFilter();
toggleSoftDeleteFilterState(true);
closeDropdown();
}}
LeftIcon={IconRotate2}
text={`Deleted ${objectNamePlural}`}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('recordGroups')}
>
Group by
</DropdownMenuHeader>
<StyledInput
autoFocus
value={recordGroupFieldSearchInput}
placeholder="Search fields"
onChange={(event) => setRecordGroupFieldSearchInput(event.target.value)}
/>
<DropdownMenuItemsContainer>
<MenuItem text="None" onClick={resetRecordGroupField} />
{filteredRecordGroupFieldMetadataItems.map((fieldMetadataItem) => (
<MenuItem
key={fieldMetadataItem.id}
onClick={() => {
handleRecordGroupFieldChange(fieldMetadataItem);
}}
LeftIcon={getIcon(fieldMetadataItem.icon)}
text={fieldMetadataItem.label}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<UndecoratedLink
to={newFieldSettingsUrl}
onClick={() => {
setNavigationMemorizedUrl(location.pathname + location.search);
closeDropdown();
}}
>
<MenuItem LeftIcon={IconSettings} text="Create select field" />
</UndecoratedLink>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('recordGroups')}
>
Sort
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleRecordGroupSortChange(RecordGroupSort.Manual)}
LeftIcon={IconHandMove}
text={RecordGroupSort.Manual}
/>
<MenuItem
onClick={() =>
handleRecordGroupSortChange(RecordGroupSort.Alphabetical)
}
LeftIcon={IconSortAZ}
text={RecordGroupSort.Alphabetical}
/>
<MenuItem
onClick={() =>
handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical)
}
LeftIcon={IconSortZA}
text={RecordGroupSort.ReverseAlphabetical}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
Group by
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{isViewGroupEnabled && (
<>
<MenuItem
onClick={() => onContentChange('recordGroupFields')}
LeftIcon={IconLayoutList}
text={
!viewGroupFieldMetadataItem
? 'Group by'
: `Group by "${viewGroupFieldMetadataItem.label}"`
}
hasSubMenu
/>
<MenuItem
onClick={() => onContentChange('recordGroupSort')}
LeftIcon={IconSortDescending}
text="Sort"
hasSubMenu
/>
</>
)}
<MenuItemToggle
LeftIcon={IconCircleOff}
onToggleChange={handleHideEmptyRecordGroupChange}
toggled={hideEmptyRecordGroup}
text="Hide empty groups"
toggleSize="small"
/>
</DropdownMenuItemsContainer>
{visibleRecordGroups.length > 0 && (
<>
<DropdownMenuSeparator />
<RecordGroupsVisibilityDropdownSection
title="Visible groups"
recordGroups={visibleRecordGroups}
onDragEnd={handleRecordGroupOrderChange}
onVisibilityChange={handleRecordGroupVisibilityChange}
isDraggable={isDragableSortRecordGroup}
showDragGrip={true}
/>
</>
)}
{hiddenRecordGroups.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => onContentChange('hiddenRecordGroups')}
LeftIcon={IconEyeOff}
text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`}
/>
</DropdownMenuItemsContainer>
</>
)}
</>
);
};

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
View settings
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{viewType === ViewType.Kanban && (
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={() =>
setAndPersistIsCompactModeActive(
!isCompactModeActive,
currentViewWithCombinedFiltersAndSorts,
)
}
toggled={isCompactModeActive}
text="Compact view"
toggleSize="small"
/>
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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<typeof ObjectOptionsDropdownContent> = {
title:
'Modules/ObjectRecord/ObjectOptionsDropdown/ObjectOptionsDropdownContent',
component: ObjectOptionsDropdownContent,
decorators: [
(Story) => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
useEffect(() => {
setObjectMetadataItems(generatedMockObjectMetadataItems);
}, [setObjectMetadataItems]);
return (
<RecordTableComponentInstanceContext.Provider
value={{ instanceId, onColumnsChange: () => {} }}
>
<ViewComponentInstanceContext.Provider value={{ instanceId }}>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId }}
>
<MemoryRouter
initialEntries={['/one', '/two', { pathname: '/three' }]}
initialIndex={1}
>
<Story />
</MemoryRouter>
</ContextStoreComponentInstanceContext.Provider>
</ViewComponentInstanceContext.Provider>
</RecordTableComponentInstanceContext.Provider>
);
},
ObjectMetadataItemsDecorator,
SnackBarDecorator,
ComponentDecorator,
IconsProviderDecorator,
],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof ObjectOptionsDropdownContent>;
const createStory = (contentId: ObjectOptionsContentId | null): Story => ({
decorators: [
(Story) => {
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
return (
<RecordIndexRootPropsContext.Provider
value={{
indexIdentifierUrl: () => '',
onIndexRecordsLoaded: () => {},
onCreateRecord: () => {},
objectNamePlural: 'companies',
objectNameSingular: 'company',
objectMetadataItem: companyObjectMetadataItem,
recordIndexId: instanceId,
}}
>
<ObjectOptionsDropdownContext.Provider
value={{
viewType: ViewType.Table,
objectMetadataItem: companyObjectMetadataItem,
recordIndexId: instanceId,
currentContentId: contentId,
onContentChange: () => {},
resetContent: () => {},
}}
>
<DropdownMenu>
<Story />
</DropdownMenu>
</ObjectOptionsDropdownContext.Provider>
</RecordIndexRootPropsContext.Provider>
);
},
],
});
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');

View File

@ -0,0 +1 @@
export const EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE = 200;

View File

@ -0,0 +1 @@
export const OBJECT_OPTIONS_DROPDOWN_ID = 'object-options-dropdown-id';

View File

@ -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',
},
]);
});
});

View File

@ -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 }) => (
<RecoilRoot
initializeState={({ set }) => {
set(recordIndexFieldDefinitionsState, initialRecoilState as any);
}}
>
{children}
</RecoilRoot>
),
},
);
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,
},
]);
});
});

View File

@ -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 }) => (
<RecordTableComponentInstanceContext.Provider
value={{ instanceId: 'instance-id', onColumnsChange: jest.fn() }}
>
<RecoilRoot
initializeState={({ set }) => {
set(
tableColumnsComponentState.atomFamily({
instanceId: 'instance-id',
}),
initialRecoilState as any,
);
}}
>
{children}
</RecoilRoot>
</RecordTableComponentInstanceContext.Provider>
),
});
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,
},
]);
});
});

View File

@ -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<any> = {}) => {
const wrapper = ({ children }: any) => (
<ObjectOptionsDropdownContext.Provider
value={{
viewType: ViewType.Table,
objectMetadataItem: {
__typename: 'object',
id: '1',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
icon: 'IconBuildingSkyscraper',
fields: [{}],
} as ObjectMetadataItem,
recordIndexId: 'test-record-index',
currentContentId: 'recordGroups',
onContentChange: mockOnContentChange,
resetContent: mockResetContent,
...contextValue,
}}
>
{children}
</ObjectOptionsDropdownContext.Provider>
);
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);
});
});

View File

@ -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 }) => (
<RecoilRoot>
<RecordIndexRootPropsContext.Provider value={contextValue}>
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'myViewInstanceId' }}
>
{children}
</ViewComponentInstanceContext.Provider>
</RecordIndexRootPropsContext.Provider>
</RecoilRoot>
),
});
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' },
]);
});
});

View File

@ -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 };
};

View File

@ -0,0 +1,202 @@
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useCallback, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { GraphQLView } from '@/views/types/GraphQLView';
import { mapBoardFieldDefinitionsToViewFields } from '@/views/utils/mapBoardFieldDefinitionsToViewFields';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type useObjectOptionsForBoardParams = {
objectNameSingular: string;
recordBoardId: string;
viewBarId: string;
};
export const useObjectOptionsForBoard = ({
objectNameSingular,
recordBoardId,
viewBarId,
}: useObjectOptionsForBoardParams) => {
const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] =
useRecoilState(recordIndexFieldDefinitionsState);
const { saveViewFields } = useSaveCurrentViewFields(viewBarId);
const { updateCurrentView } = useUpdateCurrentView(viewBarId);
const { isCompactModeActiveState } = useRecordBoard(recordBoardId);
const [isCompactModeActive, setIsCompactModeActive] = useRecoilState(
isCompactModeActiveState,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const availableColumnDefinitions = useMemo(
() =>
columnDefinitions.filter(({ isLabelIdentifier }) => !isLabelIdentifier),
[columnDefinitions],
);
const recordIndexFieldDefinitionsByKey = useMemo(
() =>
mapArrayToObject(
recordIndexFieldDefinitions,
({ fieldMetadataId }) => fieldMetadataId,
),
[recordIndexFieldDefinitions],
);
const visibleBoardFields = useMemo(
() =>
recordIndexFieldDefinitions
.filter((boardField) => boardField.isVisible)
.sort(
(boardFieldA, boardFieldB) =>
boardFieldA.position - boardFieldB.position,
),
[recordIndexFieldDefinitions],
);
const hiddenBoardFields = useMemo(
() =>
availableColumnDefinitions
.filter(
({ fieldMetadataId }) =>
!recordIndexFieldDefinitionsByKey[fieldMetadataId]?.isVisible,
)
.map((availableColumnDefinition) => {
const { fieldMetadataId } = availableColumnDefinition;
const existingBoardField =
recordIndexFieldDefinitionsByKey[fieldMetadataId];
return {
...(existingBoardField || availableColumnDefinition),
isVisible: false,
};
}),
[availableColumnDefinitions, recordIndexFieldDefinitionsByKey],
);
const handleReorderBoardFields: OnDragEndResponder = useCallback(
(result) => {
if (!result.destination) {
return;
}
const reorderedVisibleBoardFields = moveArrayItem(visibleBoardFields, {
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
if (isDeeplyEqual(visibleBoardFields, reorderedVisibleBoardFields))
return;
const updatedFields = [...reorderedVisibleBoardFields].map(
(field, index) => ({ ...field, position: index }),
);
setRecordIndexFieldDefinitions(updatedFields);
saveViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields));
},
[saveViewFields, setRecordIndexFieldDefinitions, visibleBoardFields],
);
// Todo : this seems over complex and should at least be extracted to an util with unit test.
// Let's refactor this as we introduce the new viewBar
const handleBoardFieldVisibilityChange = useCallback(
async (
updatedFieldDefinition: Omit<
ColumnDefinition<FieldMetadata>,
'size' | 'position'
>,
) => {
const isNewViewField = !(
updatedFieldDefinition.fieldMetadataId in
recordIndexFieldDefinitionsByKey
);
let updatedFieldsDefinitions: ColumnDefinition<FieldMetadata>[];
if (isNewViewField) {
const correspondingFieldDefinition = availableColumnDefinitions.find(
(availableColumnDefinition) =>
availableColumnDefinition.fieldMetadataId ===
updatedFieldDefinition.fieldMetadataId,
);
if (!correspondingFieldDefinition) return;
const lastVisibleBoardField =
visibleBoardFields[visibleBoardFields.length - 1];
updatedFieldsDefinitions = [
...recordIndexFieldDefinitions,
{
...correspondingFieldDefinition,
position: (lastVisibleBoardField?.position || 0) + 1,
isVisible: true,
},
];
} else {
updatedFieldsDefinitions = recordIndexFieldDefinitions.map(
(existingFieldDefinition) =>
existingFieldDefinition.fieldMetadataId ===
updatedFieldDefinition.fieldMetadataId
? {
...existingFieldDefinition,
isVisible: !existingFieldDefinition.isVisible,
}
: existingFieldDefinition,
);
}
setRecordIndexFieldDefinitions(updatedFieldsDefinitions);
saveViewFields(
mapBoardFieldDefinitionsToViewFields(updatedFieldsDefinitions),
);
},
[
recordIndexFieldDefinitionsByKey,
setRecordIndexFieldDefinitions,
saveViewFields,
availableColumnDefinitions,
visibleBoardFields,
recordIndexFieldDefinitions,
],
);
const setAndPersistIsCompactModeActive = useCallback(
(isCompactModeActive: boolean, view: GraphQLView | undefined) => {
if (!view) return;
setIsCompactModeActive(isCompactModeActive);
updateCurrentView({
isCompact: isCompactModeActive,
});
},
[setIsCompactModeActive, updateCurrentView],
);
return {
handleReorderBoardFields,
handleBoardFieldVisibilityChange,
visibleBoardFields,
hiddenBoardFields,
isCompactModeActive,
setAndPersistIsCompactModeActive,
};
};

View File

@ -0,0 +1,50 @@
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns';
import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
export const useObjectOptionsForTable = (recordTableId: string) => {
const hiddenTableColumns = useRecoilComponentValueV2(
hiddenTableColumnsComponentSelector,
recordTableId,
);
const visibleTableColumns = useRecoilComponentValueV2(
visibleTableColumnsComponentSelector,
recordTableId,
);
const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns(
{ recordTableId: recordTableId },
);
const handleReorderColumns: OnDragEndResponder = useCallback(
(result) => {
if (
!result.destination ||
result.destination.index === 1 ||
result.source.index === 1
) {
return;
}
const reorderedFields = moveArrayItem(visibleTableColumns, {
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
handleColumnReorder(reorderedFields);
},
[visibleTableColumns, handleColumnReorder],
);
return {
handleReorderColumns,
handleColumnVisibilityChange,
visibleTableColumns,
hiddenTableColumns,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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<ObjectOptionsDropdownContextValue>(
{} as ObjectOptionsDropdownContextValue,
);

View File

@ -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<string>({
key: 'objectOptionsDropdownSearchInputComponentState',
defaultValue: '',
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -0,0 +1,8 @@
export type ObjectOptionsContentId =
| 'viewSettings'
| 'fields'
| 'hiddenFields'
| 'recordGroups'
| 'hiddenRecordGroups'
| 'recordGroupFields'
| 'recordGroupSort';