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:
+44
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+149
@@ -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>
|
||||
|
||||
+18
-18
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user