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

@@ -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(),

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

@@ -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 (

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 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

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

View File

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

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';

View File

@@ -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 {

View File

@@ -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<HTMLDivElement>(null);
const recordGroupActions = useRecordGroupActions();
const recordGroupActions = useRecordGroupActions({
viewType: ViewType.Kanban,
});
const closeMenu = useCallback(() => {
onClose();

View File

@@ -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 (
<MenuItemDraggable
key={recordGroup.id}
text={
<Tag
variant={
recordGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
recordGroup.type !== RecordGroupDefinitionType.NoValue
? recordGroup.color
: 'transparent'
}
text={recordGroup.title}
weight={
recordGroup.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
accent={isNoValue || showDragGrip ? 'placeholder' : 'default'}
iconButtons={!isNoValue ? getIconButtons(recordGroup) : undefined}
showGrip={isNoValue ? true : showDragGrip}
isDragDisabled={isNoValue ? true : !isDraggable}
isHoverDisabled={isNoValue}
/>
);
};

View File

@@ -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<HTMLDivElement>(null);
return (
<div ref={ref}>
{showSubheader && (
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
)}
<DropdownMenuItemsContainer>
{!!recordGroups.length && (
<>
{!isDraggable ? (
recordGroupsWithoutNoValueGroups.map((recordGroup) => (
<RecordGroupMenuItemDraggable
recordGroup={recordGroup}
onVisibilityChange={onVisibilityChange}
showDragGrip={showDragGrip}
isDraggable={isDraggable}
/>
))
) : (
<DraggableList
onDragEnd={handleOnDrag}
draggableItems={
<>
{recordGroupsWithoutNoValueGroups.map(
(recordGroup, index) => (
<DraggableItem
key={recordGroup.id}
draggableId={recordGroup.id}
index={index + 1}
itemComponent={
<RecordGroupMenuItemDraggable
recordGroup={recordGroup}
onVisibilityChange={onVisibilityChange}
showDragGrip={showDragGrip}
isDraggable={isDraggable}
/>
}
/>
),
)}
</>
}
/>
)}
{noValueRecordGroups.map((recordGroup) => (
<RecordGroupMenuItemDraggable
recordGroup={recordGroup}
onVisibilityChange={onVisibilityChange}
/>
))}
</>
)}
</DropdownMenuItemsContainer>
</div>
);
};

View File

@@ -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(

View File

@@ -1,25 +1,48 @@
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(
const handleVisibilityChange = useRecoilCallback(
({ snapshot, set }) =>
async (updatedRecordGroupDefinition: RecordGroupDefinition) => {
const recordGroupDefinitions = getSnapshotValue(
snapshot,
recordGroupDefinitionsState,
);
const updatedRecordGroupDefinitions = recordGroupDefinitions.map(
(groupDefinition) =>
groupDefinition.id === updatedRecordGroupDefinition.id
@@ -30,16 +53,78 @@ export const useRecordGroupVisibility = ({
: groupDefinition,
);
setRecordGroupDefinitions(updatedRecordGroupDefinitions);
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),
);
},
[recordGroupDefinitions, setRecordGroupDefinitions, saveViewGroups],
[
recordGroupDefinitionsState,
objectOptionsDropdownRecordGroupHideState,
saveViewGroups,
viewType,
tableRowIdsByGroupFamilyState,
recordIdsByColumnIdFamilyState,
],
);
return {
handleVisibilityChange,
handleHideEmptyRecordGroupChange,
};
};

View File

@@ -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(

View File

@@ -0,0 +1,5 @@
export enum RecordGroupSort {
Manual = 'Manual',
Alphabetical = 'Alphabetical',
ReverseAlphabetical = 'Reverse Alphabetical',
}

View File

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

View File

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

View File

@@ -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 = () => {
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<RecordIndexOptionsDropdown
<ObjectOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.Table}

View File

@@ -7,9 +7,9 @@ import {
} from '../useExportFetchRecords';
import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { recordBoardKanbanFieldMetadataNameComponentState } from '@/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState';
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { ViewType } from '@/views/types/ViewType';
import { MockedResponse } from '@apollo/client/testing';
@@ -248,7 +248,7 @@ describe('useRecordData', () => {
}),
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,

View File

@@ -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',

View File

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

View File

@@ -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 (

View File

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

View File

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

View File

@@ -57,7 +57,9 @@ export const useFindManyParams = (
);
if (!fieldMetadataItem) {
return {};
throw new Error(
`Field metadata item with id ${currentRecordGroupDefinition.fieldMetadataId} not found`,
);
}
return {

View File

@@ -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 (
<Dropdown
dropdownId={RECORD_INDEX_OPTIONS_DROPDOWN_ID}
clickableComponent={<RecordIndexOptionsDropdownButton />}
dropdownMenuWidth={'200px'}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: 8 }}
dropdownComponents={
<RecordIndexOptionsDropdownContent
viewType={viewType}
objectMetadataItem={objectMetadataItem}
recordIndexId={recordIndexId}
/>
}
/>
);
};

View File

@@ -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 && (
<DropdownMenuItemsContainer>
{isViewGroupMenuItemVisible && (
<MenuItem
onClick={() => handleSelectMenu('viewGroups')}
LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)}
text={viewGroupFieldMetadataItem.label}
hasSubMenu
/>
)}
<MenuItem
onClick={() => handleSelectMenu('fields')}
LeftIcon={IconTag}
text="Fields"
hasSubMenu
/>
<MenuItem
onClick={() => openObjectRecordsSpreasheetImportDialog()}
LeftIcon={IconFileImport}
text="Import"
/>
<MenuItem
onClick={download}
LeftIcon={IconFileExport}
text={displayedExportProgress(mode, progress)}
/>
<MenuItem
onClick={() => {
handleToggleTrashColumnFilter();
toggleSoftDeleteFilterState(true);
closeDropdown();
}}
LeftIcon={IconRotate2}
text={`Deleted ${objectNamePlural}`}
/>
</DropdownMenuItemsContainer>
)}
{currentMenu === 'viewGroups' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
{viewGroupFieldMetadataItem?.label}
</DropdownMenuHeader>
<ViewGroupsVisibilityDropdownSection
title={viewGroupFieldMetadataItem?.label ?? ''}
viewGroups={visibleRecordGroups}
onDragEnd={handleRecordGroupOrderChange}
onVisibilityChange={handleRecordGroupVisibilityChange}
isDraggable
showSubheader={false}
showDragGrip={true}
/>
{hiddenRecordGroups.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => handleSelectMenu('hiddenViewGroups')}
LeftIcon={IconEyeOff}
text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`}
/>
</DropdownMenuItemsContainer>
</>
)}
</>
)}
{currentMenu === 'fields' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Fields
</DropdownMenuHeader>
<ScrollWrapper contextProviderName="dropdownMenuItemsContainer">
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleRecordFields}
isDraggable
onDragEnd={handleReorderFields}
onVisibilityChange={handleChangeFieldVisibility}
showSubheader={false}
showDragGrip={true}
/>
</ScrollWrapper>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => handleSelectMenu('hiddenFields')}
LeftIcon={IconEyeOff}
text="Hidden Fields"
/>
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'hiddenViewGroups' && (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => setCurrentMenu('viewGroups')}
>
Hidden {viewGroupFieldMetadataItem?.label}
</DropdownMenuHeader>
<ViewGroupsVisibilityDropdownSection
title={`Hidden ${viewGroupFieldMetadataItem?.label}`}
viewGroups={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>
</>
)}
{currentMenu === 'hiddenFields' && (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => setCurrentMenu('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>
</>
)}
{viewType === ViewType.Kanban && !currentMenu && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={() =>
setAndPersistIsCompactModeActive(
!isCompactModeActive,
currentViewWithCombinedFiltersAndSorts,
)
}
toggled={isCompactModeActive}
text="Compact view"
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</>
)}
</>
);
};

View File

@@ -1,2 +0,0 @@
export const RECORD_INDEX_BOARD_OPTIONS_DROPDOWN_ID =
'record-index-table-options-dropdown-id';

View File

@@ -1,2 +0,0 @@
export const RECORD_INDEX_OPTIONS_DROPDOWN_ID =
'record-index-options-dropdown-id';

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 recordIndexRecordGroupHideComponentState =
createComponentStateV2<boolean>({
key: 'recordIndexRecordGroupHideComponentState',
defaultValue: false,
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@@ -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<RecordGroupSort>({
key: 'recordIndexRecordGroupSortComponentState',
defaultValue: RecordGroupSort.Manual,
componentInstanceContext: ViewComponentInstanceContext,
});

View File

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

View File

@@ -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 (
<RecordTableRow key={recordId} recordId={recordId} rowIndex={rowIndex} />

View File

@@ -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(
(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(() => {

View File

@@ -10,6 +10,7 @@ export const RecordTableRecordGroupBodyEffects = () => {
return recordGroupDefinitions.map((recordGroupDefinition) => (
<RecordGroupContext.Provider
key={recordGroupDefinition.id}
value={{ recordGroupId: recordGroupDefinition.id }}
>
<RecordTableRecordGroupBodyEffect />

View File

@@ -36,6 +36,7 @@ export const RecordTableRecordGroupsBody = ({
<RecordTablePendingRow />
{visibleRecordGroups.map((recordGroupDefinition) => (
<RecordGroupContext.Provider
key={recordGroupDefinition.id}
value={{ recordGroupId: recordGroupDefinition.id }}
>
<RecordTableRecordGroupRows />

View File

@@ -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 {

View File

@@ -11,7 +11,7 @@ import {
RelationDefinitionType,
} from '~/generated-metadata/graphql';
export const useOpenObjectRecordsSpreasheetImportDialog = (
export const useOpenObjectRecordsSpreadsheetImportDialog = (
objectNameSingular: string,
) => {
const { openSpreadsheetImportDialog } = useOpenSpreadsheetImportDialog<any>();

View File

@@ -1,19 +1,25 @@
import { ExtractPathParams } from '@/types/ExtractPathParams';
import { SettingsPath } from '@/types/SettingsPath';
import { isDefined } from '~/utils/isDefined';
type PathParams = {
type Params<V extends string> = {
[K in ExtractPathParams<V>]: string;
} & {
id?: string;
objectSlug?: string;
};
export const getSettingsPagePath = <Path extends SettingsPath>(
path: Path,
params?: PathParams,
params?: Params<Path>,
) => {
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<Path>];
return value;
});
}
if (isDefined(params?.id)) {

View File

@@ -0,0 +1,6 @@
export type ExtractPathParams<V extends string> =
V extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractPathParams<`/${Rest}`>
: V extends `${string}:${infer Param}`
? Param
: never;

View File

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

View File

@@ -18,7 +18,7 @@ export function createComponentFamilySelectorV2<
key: string;
get: SelectorGetter<ValueType, ComponentFamilyStateKeyV2<FamilyKey>>;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
}): ComponentFamilySelectorV2<ValueType, FamilyKey>;
}): ComponentFamilyReadOnlySelectorV2<ValueType, FamilyKey>;
export function createComponentFamilySelectorV2<
ValueType,
@@ -28,7 +28,7 @@ export function createComponentFamilySelectorV2<
get: SelectorGetter<ValueType, ComponentFamilyStateKeyV2<FamilyKey>>;
set: SelectorSetter<ValueType, ComponentFamilyStateKeyV2<FamilyKey>>;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
}): ComponentFamilyReadOnlySelectorV2<ValueType, FamilyKey>;
}): ComponentFamilySelectorV2<ValueType, FamilyKey>;
export function createComponentFamilySelectorV2<
ValueType,

View File

@@ -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<HTMLDivElement>(null);
return (
<div ref={ref}>
{showSubheader && (
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
)}
<DropdownMenuItemsContainer>
{!!viewGroups.length && (
<>
{!isDraggable ? (
viewGroupsWithoutNoValueGroups.map(
(viewGroup, viewGroupIndex) => (
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
iconButtons={getIconButtons(viewGroupIndex, viewGroup)}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={showDragGrip}
isDragDisabled={!isDraggable}
/>
),
)
) : (
<DraggableList
onDragEnd={handleOnDrag}
draggableItems={
<>
{viewGroupsWithoutNoValueGroups.map(
(viewGroup, viewGroupIndex) => (
<DraggableItem
key={viewGroup.id}
draggableId={viewGroup.id}
index={viewGroupIndex + 1}
itemComponent={
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !==
RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
iconButtons={getIconButtons(
viewGroupIndex,
viewGroup,
)}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={showDragGrip}
isDragDisabled={!isDraggable}
/>
}
/>
),
)}
</>
}
/>
)}
{noValueViewGroups.map((viewGroup) => (
<MenuItemDraggable
key={viewGroup.id}
text={
<Tag
variant={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? viewGroup.color
: 'transparent'
}
text={viewGroup.title}
weight={
viewGroup.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
/>
}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={true}
isDragDisabled={true}
isHoverDisabled
/>
))}
</>
)}
</DropdownMenuItemsContainer>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ export type MenuItemProps = {
onMouseLeave?: (event: MouseEvent<HTMLDivElement>) => 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}
>
<StyledMenuItemLeftContent>
<MenuItemLeftContent LeftIcon={LeftIcon ?? undefined} text={text} />
<MenuItemLeftContent
LeftIcon={LeftIcon ?? undefined}
text={text}
contextualText={contextualText}
/>
</StyledMenuItemLeftContent>
<div className="hoverable-buttons">
{showIconButtons && (

View File

@@ -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 (
<StyledMenuItemLeftContent className={className}>
{showGrip &&
(isDisabled ? (
{showGrip && (
<StyledDraggableItem>
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={
isDisabled
? theme.font.color.extraLight
: theme.font.color.light
isDisabled ? theme.font.color.extraLight : theme.font.color.light
}
/>
</StyledDraggableItem>
) : (
<StyledDraggableItem>
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={
isDisabled
? theme.font.color.extraLight
: theme.font.color.light
}
/>
</StyledDraggableItem>
))}
)}
{LeftIcon && (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
<StyledMenuItemLabel hasLeftIcon={!!LeftIcon}>
{isString(text) ? <OverflowingTextWithTooltip text={text} /> : text}
{isString(contextualText) ? (
<StyledContextualText>{`· ${contextualText}`}</StyledContextualText>
) : (
contextualText
)}
</StyledMenuItemLabel>
</StyledMenuItemLeftContent>
);

View File

@@ -69,6 +69,8 @@ export const StyledMenuItemBase = styled.div<MenuItemBaseProps>`
`;
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};

View File

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