Add object type filter dropdown to side panel record searches (#18912)

## Summary

- Adds an object type filter dropdown to the side panel, allowing users
to scope record search results to a specific object type (e.g. People,
Companies, Opportunities)
- The filter appears as a funnel icon next to the search input in both
the global search (SearchRecords page) and the "Pick a record" sidebar
flow
- Replaces the AI sparkles button on the search records page with the
filter icon
- Uses colored icons matching the navigation menu style
(NavigationMenuItemStyleIcon + getStandardObjectIconColor)

## Test plan

- [ ] Open the side panel, type something to enter SearchRecords mode,
verify the filter icon appears
- [ ] Click the filter icon, verify the dropdown opens with "Object"
header, search input, "All Objects" and individual object types with
colored icons
- [ ] Select an object type, verify only records of that type appear in
search results
- [ ] Select "All Objects", verify all record types appear again
- [ ] Verify the filter icon turns blue when a filter is active
- [ ] In layout customization mode, add a new sidebar item > Record,
verify the filter dropdown also works there
- [ ] Close and reopen the side panel, verify the filter resets to "All
Objects"
- [ ] Verify the AI sparkles button no longer appears on the command
menu root page
- [ ] Verify the AI edit icon still appears on Ask AI pages


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-25 16:02:24 +01:00
committed by GitHub
parent 13ea3ec75c
commit 5f0d6553f6
26 changed files with 641 additions and 98 deletions
@@ -0,0 +1,44 @@
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { SidePanelObjectFilterDropdownContent } from '@/side-panel/components/SidePanelObjectFilterDropdownContent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
export const OBJECT_FILTER_DROPDOWN_ID = 'side-panel-object-filter-dropdown';
type SidePanelObjectFilterDropdownProps = {
selectedObjectNameSingular: string | null;
onSelectObject: (objectNameSingular: string | null) => void;
};
export const SidePanelObjectFilterDropdown = ({
selectedObjectNameSingular,
onSelectObject,
}: SidePanelObjectFilterDropdownProps) => {
const { t } = useLingui();
const isFilterActive = isDefined(selectedObjectNameSingular);
return (
<Dropdown
dropdownId={OBJECT_FILTER_DROPDOWN_ID}
dropdownPlacement="bottom-end"
clickableComponent={
<IconButton
Icon={IconFilter}
variant="tertiary"
accent={isFilterActive ? 'blue' : 'default'}
size="small"
ariaLabel={t`Filter by object type`}
/>
}
dropdownComponents={
<SidePanelObjectFilterDropdownContent
selectedObjectNameSingular={selectedObjectNameSingular}
onSelectObject={onSelectObject}
/>
}
/>
);
};
@@ -0,0 +1,149 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants';
import { IconCube, useIcons } from 'twenty-ui/display';
import { MenuItemSelectAvatar, MenuItemToggle } from 'twenty-ui/navigation';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemStyleIcon';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/side-panel/components/SidePanelObjectFilterDropdown';
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const ALL_OBJECTS_ITEM_ID = 'all-objects';
type SidePanelObjectFilterDropdownContentProps = {
selectedObjectNameSingular: string | null;
onSelectObject: (objectNameSingular: string | null) => void;
};
export const SidePanelObjectFilterDropdownContent = ({
selectedObjectNameSingular,
onSelectObject,
}: SidePanelObjectFilterDropdownContentProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const [filterSearch, setFilterSearch] = useState('');
const [sidePanelShowHiddenObjects, setSidePanelShowHiddenObjects] =
useAtomState(sidePanelShowHiddenObjectsState);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const { closeDropdown } = useCloseDropdown();
const searchFilter = filterSearch.toLowerCase();
const displayedObjects = readableObjectMetadataItems.filter((item) => {
if (
OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS.includes(
item.nameSingular as (typeof OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS)[number],
)
) {
return false;
}
if (!sidePanelShowHiddenObjects && !item.isSearchable) {
return false;
}
return item.labelPlural.toLowerCase().includes(searchFilter);
});
const handleSelect = (objectNameSingular: string | null) => {
onSelectObject(objectNameSingular);
closeDropdown(OBJECT_FILTER_DROPDOWN_ID);
};
const selectableItemIdArray = [
ALL_OBJECTS_ITEM_ID,
...displayedObjects.map((item) => item.nameSingular),
];
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
OBJECT_FILTER_DROPDOWN_ID,
);
return (
<DropdownContent>
<DropdownMenuHeader>{t`Object`}</DropdownMenuHeader>
<DropdownMenuSearchInput
value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<SelectableList
selectableListInstanceId={OBJECT_FILTER_DROPDOWN_ID}
focusId={OBJECT_FILTER_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableListItem
itemId={ALL_OBJECTS_ITEM_ID}
onEnter={() => handleSelect(null)}
>
<MenuItemSelectAvatar
avatar={
<NavigationMenuItemStyleIcon Icon={IconCube} color="gray" />
}
text={t`All objects`}
selected={selectedObjectNameSingular === null}
onClick={() => handleSelect(null)}
focused={selectedItemId === ALL_OBJECTS_ITEM_ID}
/>
</SelectableListItem>
{displayedObjects.map((objectMetadataItem) => {
const ObjectIcon = getIcon(objectMetadataItem.icon);
const iconColor = getObjectColorWithFallback(objectMetadataItem);
return (
<SelectableListItem
key={objectMetadataItem.id}
itemId={objectMetadataItem.nameSingular}
onEnter={() => handleSelect(objectMetadataItem.nameSingular)}
>
<MenuItemSelectAvatar
avatar={
<NavigationMenuItemStyleIcon
Icon={ObjectIcon}
color={iconColor}
/>
}
text={objectMetadataItem.labelPlural}
selected={
selectedObjectNameSingular ===
objectMetadataItem.nameSingular
}
onClick={() => handleSelect(objectMetadataItem.nameSingular)}
focused={selectedItemId === objectMetadataItem.nameSingular}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>
</SelectableList>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconCube}
onToggleChange={() =>
setSidePanelShowHiddenObjects(!sidePanelShowHiddenObjects)
}
toggled={sidePanelShowHiddenObjects}
text={t`Show hidden objects`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -53,6 +53,7 @@ type SidePanelSubViewWithSearchProps = {
searchValue: string;
onSearchChange: (value: string) => void;
searchInputProps?: React.InputHTMLAttributes<HTMLInputElement>;
rightElement?: ReactNode;
children?: ReactNode;
};
@@ -61,6 +62,7 @@ export const SidePanelSubViewWithSearch = ({
searchValue,
onSearchChange,
searchInputProps,
rightElement,
children,
}: SidePanelSubViewWithSearchProps) => (
<StyledSubViewContainer>
@@ -73,6 +75,7 @@ export const SidePanelSubViewWithSearch = ({
// oxlint-disable-next-line react/jsx-props-no-spreading
{...searchInputProps}
/>
{rightElement}
</StyledSearchContainer>
{children != null && (
<StyledScrollableListWrapper>{children}</StyledScrollableListWrapper>
@@ -1,16 +1,17 @@
import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { IconEdit, IconSparkles } from 'twenty-ui/display';
import { IconEdit } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconButtonContainer = styled.div`
@@ -21,11 +22,19 @@ export const SidePanelTopBarRightCornerIcon = () => {
const isMobile = useIsMobile();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
const { switchToNewChat } = useSwitchToNewAIChat();
const [sidePanelSearchObjectFilter, setSidePanelSearchObjectFilter] =
useAtomState(sidePanelSearchObjectFilterState);
if (isMobile || !isAiEnabled) {
return null;
const isOnSearchPage = sidePanelPage === SidePanelPages.SearchRecords;
if (isOnSearchPage) {
return (
<SidePanelObjectFilterDropdown
selectedObjectNameSingular={sidePanelSearchObjectFilter}
onSelectObject={setSidePanelSearchObjectFilter}
/>
);
}
const isOnAskAIPage = [
@@ -33,17 +42,8 @@ export const SidePanelTopBarRightCornerIcon = () => {
SidePanelPages.ViewPreviousAIChats,
].includes(sidePanelPage);
if (!isOnAskAIPage) {
return (
<StyledIconButtonContainer>
<IconButton
onClick={() => openAskAIPage({ resetNavigationStack: false })}
Icon={IconSparkles}
variant="tertiary"
size="small"
/>
</StyledIconButtonContainer>
);
if (isMobile || !isAiEnabled || !isOnAskAIPage) {
return null;
}
return (