Add search to new sidebar item picker (#22041)
## Summary - Add search to the custom layout “New menu item” side panel. - Group search results by Objects, Views, and Records. - Reuse the existing record search behavior through a shared hook and preserve add-to-navigation drag/select flows. ## Video - Recording: https://gist.githubusercontent.com/Bonapara/c78107650efd94b580e38426b9fc2dbd/raw/755c87fab253281a9c68e5a24cbfdff6c9248af1/search-nav-item-custom-layout.webm ## Verification - Browser plugin: opened layout customization, clicked `Add menu item`, searched `o`, and verified `Objects`, `Views`, and `Records` result groups with object/view/record results. - `npx oxlint --type-aware -c packages/twenty-front/.oxlintrc.json packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage.tsx packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordSubPage.tsx packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemSearchResults.tsx packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/hooks/useAvailableNavigationMenuItemSearchRecords.ts` - `npx nx typecheck twenty-front` - `npx nx lint twenty-front` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22041?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
5ff1d997c7
commit
1589b9b912
+37
-13
@@ -1,23 +1,47 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
|
||||
import { SidePanelNewSidebarItemMainMenu } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemMainMenu';
|
||||
import { SidePanelNewSidebarItemSearchResults } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemSearchResults';
|
||||
import { SidePanelSubViewWithSearch } from '@/side-panel/components/SidePanelSubViewWithSearch';
|
||||
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
|
||||
|
||||
export const SidePanelNewSidebarItemPage = () => {
|
||||
const { t } = useLingui();
|
||||
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const trimmedSearchValue = searchValue.trim();
|
||||
const hasSearchQuery = isNonEmptyString(trimmedSearchValue);
|
||||
|
||||
return (
|
||||
<SidePanelNewSidebarItemMainMenu
|
||||
onSelectRecord={() =>
|
||||
navigateToSidePanelSubPage(SidePanelSubPages.NewSidebarItemRecord)
|
||||
}
|
||||
onSelectObject={() =>
|
||||
navigateToSidePanelSubPage(SidePanelSubPages.NewSidebarItemObjectPicker)
|
||||
}
|
||||
onSelectView={() =>
|
||||
navigateToSidePanelSubPage(
|
||||
SidePanelSubPages.NewSidebarItemViewObjectPicker,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SidePanelSubViewWithSearch
|
||||
searchPlaceholder={t`Search a nav item...`}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
>
|
||||
{hasSearchQuery ? (
|
||||
<SidePanelNewSidebarItemSearchResults
|
||||
searchValue={trimmedSearchValue}
|
||||
/>
|
||||
) : (
|
||||
<SidePanelNewSidebarItemMainMenu
|
||||
onSelectRecord={() =>
|
||||
navigateToSidePanelSubPage(SidePanelSubPages.NewSidebarItemRecord)
|
||||
}
|
||||
onSelectObject={() =>
|
||||
navigateToSidePanelSubPage(
|
||||
SidePanelSubPages.NewSidebarItemObjectPicker,
|
||||
)
|
||||
}
|
||||
onSelectView={() =>
|
||||
navigateToSidePanelSubPage(
|
||||
SidePanelSubPages.NewSidebarItemViewObjectPicker,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidePanelSubViewWithSearch>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-68
@@ -1,90 +1,40 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
|
||||
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
|
||||
import { useAvailableNavigationMenuItemSearchRecords } from '@/navigation-menu-item/edit/side-panel/hooks/useAvailableNavigationMenuItemSearchRecords';
|
||||
import { SidePanelAddToNavigationDroppable } from '@/side-panel/components/SidePanelAddToNavigationDroppable';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown';
|
||||
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
|
||||
import { SidePanelSubViewWithSearch } from '@/side-panel/components/SidePanelSubViewWithSearch';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SidePanelNewSidebarItemRecordItem } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordItem';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { SearchDocument } from '~/generated/graphql';
|
||||
|
||||
type SearchRecordBase = {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
export const SidePanelNewSidebarItemRecordSubPage = () => {
|
||||
const { t } = useLingui();
|
||||
const { currentItems } = useNavigationMenuItemEditController();
|
||||
const [recordSearchInput, setRecordSearchInput] = useState('');
|
||||
const [deferredRecordSearchInput] = useDebounce(recordSearchInput, 300);
|
||||
const coreClient = useApolloCoreClient();
|
||||
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
|
||||
const [selectedObjectNameSingular, setSelectedObjectNameSingular] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const sidePanelShowHiddenObjects = useAtomStateValue(
|
||||
sidePanelShowHiddenObjectsState,
|
||||
);
|
||||
|
||||
const includedObjectNameSingulars = useMemo(() => {
|
||||
if (isDefined(selectedObjectNameSingular)) {
|
||||
return [selectedObjectNameSingular];
|
||||
}
|
||||
|
||||
return readableObjectMetadataItems
|
||||
.filter((item) => sidePanelShowHiddenObjects || item.isSearchable)
|
||||
.map((item) => item.nameSingular);
|
||||
}, [
|
||||
readableObjectMetadataItems,
|
||||
const {
|
||||
availableSearchRecords,
|
||||
isSearchDebouncing,
|
||||
recordSearchLoading,
|
||||
trimmedSearchInput,
|
||||
} = useAvailableNavigationMenuItemSearchRecords({
|
||||
searchInput: recordSearchInput,
|
||||
selectedObjectNameSingular,
|
||||
sidePanelShowHiddenObjects,
|
||||
]);
|
||||
skip: !isNonEmptyString(recordSearchInput.trim()),
|
||||
});
|
||||
|
||||
const { data: searchData, loading: recordSearchLoading } = useQuery(
|
||||
SearchDocument,
|
||||
{
|
||||
client: coreClient,
|
||||
variables: {
|
||||
searchInput: deferredRecordSearchInput ?? '',
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
includedObjectNameSingulars,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const recordIdsAlreadyAdded = new Set(
|
||||
currentItems.flatMap((item) =>
|
||||
isDefined(item.targetRecordId) ? [item.targetRecordId] : [],
|
||||
),
|
||||
);
|
||||
|
||||
const searchRecords =
|
||||
searchData?.search?.edges?.map((edge) => edge.node) ?? [];
|
||||
const availableSearchRecords = searchRecords.filter(
|
||||
(record) => !recordIdsAlreadyAdded.has(record.recordId),
|
||||
) as SearchRecordBase[];
|
||||
|
||||
const isEmpty = availableSearchRecords.length === 0 && !recordSearchLoading;
|
||||
const isRecordSearchLoading = recordSearchLoading || isSearchDebouncing;
|
||||
const isEmpty = availableSearchRecords.length === 0 && !isRecordSearchLoading;
|
||||
const selectableItemIds = isEmpty
|
||||
? []
|
||||
: availableSearchRecords.map((record) => record.recordId);
|
||||
const noResultsText =
|
||||
deferredRecordSearchInput.length > 0
|
||||
? t`No results found`
|
||||
: t`Type to search records`;
|
||||
const noResultsText = isNonEmptyString(trimmedSearchInput)
|
||||
? t`No results found`
|
||||
: t`Type to search records`;
|
||||
|
||||
return (
|
||||
<SidePanelSubViewWithSearch
|
||||
@@ -102,7 +52,7 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
<SidePanelList
|
||||
selectableItemIds={selectableItemIds}
|
||||
loading={recordSearchLoading}
|
||||
loading={isRecordSearchLoading}
|
||||
noResults={isEmpty}
|
||||
noResultsText={noResultsText}
|
||||
>
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/display/view/components/ObjectIconWithViewOverlay';
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/icon';
|
||||
|
||||
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
|
||||
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
|
||||
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
|
||||
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
|
||||
import { SidePanelNewSidebarItemRecordItem } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordItem';
|
||||
import { SidePanelObjectMenuItem } from '@/navigation-menu-item/edit/side-panel/components/SidePanelObjectMenuItem';
|
||||
import { useAvailableNavigationMenuItemSearchRecords } from '@/navigation-menu-item/edit/side-panel/hooks/useAvailableNavigationMenuItemSearchRecords';
|
||||
import { getAvailableObjectMetadataForNewSidebarItem } from '@/navigation-menu-item/edit/side-panel/utils/getAvailableObjectMetadataForNewSidebarItem';
|
||||
import { isViewDisplayableInNavigationMenu } from '@/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu';
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
|
||||
import { SidePanelAddToNavigationDroppable } from '@/side-panel/components/SidePanelAddToNavigationDroppable';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelItemWithAddToNavigationDrag } from '@/side-panel/components/SidePanelItemWithAddToNavigationDrag';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
|
||||
|
||||
type SidePanelNewSidebarItemSearchResultsProps = {
|
||||
searchValue: string;
|
||||
};
|
||||
|
||||
export const SidePanelNewSidebarItemSearchResults = ({
|
||||
searchValue,
|
||||
}: SidePanelNewSidebarItemSearchResultsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
const { currentItems, createItem } = useNavigationMenuItemEditController();
|
||||
const [
|
||||
pendingInsertionNavigationMenuItem,
|
||||
setPendingInsertionNavigationMenuItem,
|
||||
] = useAtomState(pendingInsertionNavigationMenuItemState);
|
||||
const { openNavigationMenuItemInSidePanel } =
|
||||
useOpenNavigationMenuItemInSidePanel();
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const { activeNonSystemObjectMetadataItems } =
|
||||
useFilteredObjectMetadataItems();
|
||||
const { views, objectMetadataIdsWithIndexView, viewIdsAlreadyAdded } =
|
||||
useNavigationMenuObjectMetadataForSection(currentItems);
|
||||
const trimmedSearchValue = searchValue.trim();
|
||||
const { availableSearchRecords, isSearchDebouncing, recordSearchLoading } =
|
||||
useAvailableNavigationMenuItemSearchRecords({
|
||||
searchInput: trimmedSearchValue,
|
||||
skip: !isNonEmptyString(trimmedSearchValue),
|
||||
});
|
||||
const isRecordSearchLoading = recordSearchLoading || isSearchDebouncing;
|
||||
|
||||
const availableViews = views
|
||||
.filter((view) => view.key !== ViewKey.INDEX)
|
||||
.filter((view) => !viewIdsAlreadyAdded.has(view.id))
|
||||
.filter(isViewDisplayableInNavigationMenu);
|
||||
|
||||
const objectMetadataIdsWithDisplayableViews = new Set(
|
||||
availableViews.map((view) => view.objectMetadataId),
|
||||
);
|
||||
|
||||
const {
|
||||
availableObjectMetadataItems,
|
||||
availableSystemObjectMetadataItems,
|
||||
objectMetadataItemsWithViews,
|
||||
availableSystemObjectMetadataItemsForView,
|
||||
} = getAvailableObjectMetadataForNewSidebarItem({
|
||||
objectMetadataItems,
|
||||
activeNonSystemObjectMetadataItems,
|
||||
objectMetadataIdsWithIndexView,
|
||||
objectMetadataIdsWithDisplayableViews,
|
||||
});
|
||||
|
||||
const searchableObjectMetadataItems = [
|
||||
...availableObjectMetadataItems,
|
||||
...availableSystemObjectMetadataItems,
|
||||
].toSorted((a, b) => a.labelPlural.localeCompare(b.labelPlural));
|
||||
|
||||
const searchableObjectMetadataItemsWithViews = [
|
||||
...objectMetadataItemsWithViews,
|
||||
...availableSystemObjectMetadataItemsForView,
|
||||
];
|
||||
|
||||
const filteredObjectMetadataItems = filterBySearchQuery({
|
||||
items: searchableObjectMetadataItems,
|
||||
searchQuery: trimmedSearchValue,
|
||||
getSearchableValues: (item) => [
|
||||
item.labelPlural,
|
||||
item.labelSingular,
|
||||
item.namePlural,
|
||||
item.nameSingular,
|
||||
],
|
||||
});
|
||||
|
||||
const objectMetadataItemIdsWithViews = new Set(
|
||||
searchableObjectMetadataItemsWithViews.map((item) => item.id),
|
||||
);
|
||||
|
||||
const filteredViews = filterBySearchQuery({
|
||||
items: availableViews
|
||||
.filter((view) =>
|
||||
objectMetadataItemIdsWithViews.has(view.objectMetadataId),
|
||||
)
|
||||
.sort((viewA, viewB) => viewA.name.localeCompare(viewB.name)),
|
||||
searchQuery: trimmedSearchValue,
|
||||
getSearchableValues: (view) => [view.name],
|
||||
});
|
||||
|
||||
const selectableItemIds = [
|
||||
...filteredObjectMetadataItems.map((item) => item.id),
|
||||
...filteredViews.map((view) => view.id),
|
||||
...availableSearchRecords.map((record) => record.recordId),
|
||||
];
|
||||
|
||||
const isEmpty =
|
||||
filteredObjectMetadataItems.length === 0 &&
|
||||
filteredViews.length === 0 &&
|
||||
availableSearchRecords.length === 0 &&
|
||||
!isRecordSearchLoading;
|
||||
|
||||
const handleSelectObject = (
|
||||
objectMetadataItem: EnrichedObjectMetadataItem,
|
||||
) => {
|
||||
const itemId = createItem(
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
targetObjectMetadataId: objectMetadataItem.id,
|
||||
color: getObjectColorWithFallback(objectMetadataItem),
|
||||
},
|
||||
{
|
||||
targetFolderId: pendingInsertionNavigationMenuItem?.folderId,
|
||||
targetIndex: pendingInsertionNavigationMenuItem?.position,
|
||||
},
|
||||
);
|
||||
|
||||
setPendingInsertionNavigationMenuItem(null);
|
||||
openNavigationMenuItemInSidePanel({
|
||||
itemId,
|
||||
pageTitle: objectMetadataItem.labelSingular,
|
||||
pageIcon: getIcon(objectMetadataItem.icon),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectView = (view: View) => {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.id === view.objectMetadataId,
|
||||
);
|
||||
const viewIconColor = isDefined(objectMetadataItem)
|
||||
? getObjectColorWithFallback(objectMetadataItem)
|
||||
: undefined;
|
||||
|
||||
const itemId = createItem(
|
||||
{
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewId: view.id,
|
||||
color: viewIconColor,
|
||||
},
|
||||
{
|
||||
targetFolderId: pendingInsertionNavigationMenuItem?.folderId ?? null,
|
||||
targetIndex: pendingInsertionNavigationMenuItem?.position,
|
||||
},
|
||||
);
|
||||
|
||||
setPendingInsertionNavigationMenuItem(null);
|
||||
openNavigationMenuItemInSidePanel({
|
||||
itemId,
|
||||
pageTitle: view.name,
|
||||
pageIcon: getIcon(view.icon),
|
||||
});
|
||||
};
|
||||
|
||||
const getObjectMetadataItemForView = (view: View) =>
|
||||
objectMetadataItems.find((item) => item.id === view.objectMetadataId);
|
||||
|
||||
return (
|
||||
<SidePanelAddToNavigationDroppable>
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
<SidePanelList
|
||||
selectableItemIds={selectableItemIds}
|
||||
loading={isRecordSearchLoading}
|
||||
noResults={isEmpty}
|
||||
noResultsText={t`No results found`}
|
||||
>
|
||||
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div ref={innerRef} {...droppableProps}>
|
||||
<SidePanelGroup heading={t`Objects`}>
|
||||
{filteredObjectMetadataItems.map((objectMetadataItem, index) => (
|
||||
<SidePanelObjectMenuItem
|
||||
key={objectMetadataItem.id}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
onSelect={handleSelectObject}
|
||||
variant="add"
|
||||
dragIndex={index}
|
||||
/>
|
||||
))}
|
||||
</SidePanelGroup>
|
||||
<SidePanelGroup heading={t`Views`}>
|
||||
{filteredViews.map((view, index) => {
|
||||
const objectMetadataItem = getObjectMetadataItemForView(view);
|
||||
const objectIconColor = isDefined(objectMetadataItem)
|
||||
? getObjectColorWithFallback(objectMetadataItem)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={view.id}
|
||||
itemId={view.id}
|
||||
onEnter={() => handleSelectView(view)}
|
||||
>
|
||||
<SidePanelItemWithAddToNavigationDrag
|
||||
customIconContent={
|
||||
isDefined(objectMetadataItem) ? (
|
||||
<ObjectIconWithViewOverlay
|
||||
ObjectIcon={getIcon(objectMetadataItem.icon)}
|
||||
ViewIcon={getIcon(view.icon)}
|
||||
objectColor={objectIconColor}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
icon={
|
||||
isDefined(objectMetadataItem)
|
||||
? undefined
|
||||
: getIcon(view.icon)
|
||||
}
|
||||
label={view.name}
|
||||
id={view.id}
|
||||
onClick={() => handleSelectView(view)}
|
||||
dragIndex={filteredObjectMetadataItems.length + index}
|
||||
payload={{
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewId: view.id,
|
||||
label: view.name,
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
})}
|
||||
</SidePanelGroup>
|
||||
<SidePanelGroup heading={t`Records`}>
|
||||
{availableSearchRecords.map((record, index) => (
|
||||
<SidePanelNewSidebarItemRecordItem
|
||||
key={record.recordId}
|
||||
record={record}
|
||||
dragIndex={
|
||||
filteredObjectMetadataItems.length +
|
||||
filteredViews.length +
|
||||
index
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SidePanelGroup>
|
||||
{placeholder}
|
||||
</div>
|
||||
</SidePanelList>
|
||||
)}
|
||||
</SidePanelAddToNavigationDroppable>
|
||||
);
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useMemo } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
|
||||
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
|
||||
import { useSearchableObjectNameSingulars } from '@/side-panel/hooks/useSearchableObjectNameSingulars';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type NavigationMenuItemSearchRecord = {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
type UseAvailableNavigationMenuItemSearchRecordsParams = {
|
||||
searchInput: string;
|
||||
selectedObjectNameSingular?: string | null;
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const useAvailableNavigationMenuItemSearchRecords = ({
|
||||
searchInput,
|
||||
selectedObjectNameSingular = null,
|
||||
skip = false,
|
||||
}: UseAvailableNavigationMenuItemSearchRecordsParams) => {
|
||||
const { currentItems } = useNavigationMenuItemEditController();
|
||||
const trimmedSearchInput = searchInput.trim();
|
||||
|
||||
const [deferredSearchInput] = useDebounce(trimmedSearchInput, 300);
|
||||
|
||||
const includedObjectNameSingulars = useSearchableObjectNameSingulars({
|
||||
selectedObjectNameSingular,
|
||||
});
|
||||
|
||||
const { loading: recordSearchLoading, searchRecords } =
|
||||
useObjectRecordSearchRecords({
|
||||
objectNameSingulars: includedObjectNameSingulars,
|
||||
searchInput: deferredSearchInput,
|
||||
skip: skip || !isNonEmptyString(deferredSearchInput),
|
||||
});
|
||||
|
||||
const recordIdsAlreadyAdded = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
currentItems.flatMap((item) =>
|
||||
isDefined(item.targetRecordId) ? [item.targetRecordId] : [],
|
||||
),
|
||||
),
|
||||
[currentItems],
|
||||
);
|
||||
|
||||
const availableSearchRecords = useMemo(
|
||||
() =>
|
||||
searchRecords
|
||||
.filter((record) => !recordIdsAlreadyAdded.has(record.recordId))
|
||||
.map(
|
||||
(record): NavigationMenuItemSearchRecord => ({
|
||||
recordId: record.recordId,
|
||||
objectNameSingular: record.objectNameSingular,
|
||||
label: record.label,
|
||||
imageUrl: record.imageUrl,
|
||||
}),
|
||||
),
|
||||
[recordIdsAlreadyAdded, searchRecords],
|
||||
);
|
||||
|
||||
return {
|
||||
availableSearchRecords,
|
||||
deferredSearchInput,
|
||||
isSearchDebouncing: trimmedSearchInput !== deferredSearchInput,
|
||||
recordSearchLoading,
|
||||
trimmedSearchInput,
|
||||
};
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
|
||||
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
type UseSearchableObjectNameSingularsParams = {
|
||||
selectedObjectNameSingular?: string | null;
|
||||
};
|
||||
|
||||
export const useSearchableObjectNameSingulars = ({
|
||||
selectedObjectNameSingular = null,
|
||||
}: UseSearchableObjectNameSingularsParams = {}) => {
|
||||
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
|
||||
const sidePanelShowHiddenObjects = useAtomStateValue(
|
||||
sidePanelShowHiddenObjectsState,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (isDefined(selectedObjectNameSingular)) {
|
||||
return [selectedObjectNameSingular];
|
||||
}
|
||||
|
||||
return readableObjectMetadataItems
|
||||
.filter((item) => sidePanelShowHiddenObjects || item.isSearchable)
|
||||
.map((item) => item.nameSingular);
|
||||
}, [
|
||||
readableObjectMetadataItems,
|
||||
selectedObjectNameSingular,
|
||||
sidePanelShowHiddenObjects,
|
||||
]);
|
||||
};
|
||||
+26
-50
@@ -1,16 +1,12 @@
|
||||
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
|
||||
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
|
||||
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
|
||||
import { useSearchableObjectNameSingulars } from '@/side-panel/hooks/useSearchableObjectNameSingulars';
|
||||
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { SearchDocument } from '~/generated/graphql';
|
||||
|
||||
export type SearchResultItem = {
|
||||
id: string;
|
||||
@@ -32,56 +28,36 @@ export const useSidePanelSearchRecords = () => {
|
||||
const sidePanelSearchObjectFilter = useAtomStateValue(
|
||||
sidePanelSearchObjectFilterState,
|
||||
);
|
||||
const sidePanelShowHiddenObjects = useAtomStateValue(
|
||||
sidePanelShowHiddenObjectsState,
|
||||
);
|
||||
const coreClient = useApolloCoreClient();
|
||||
const trimmedSidePanelSearch = sidePanelSearch.trim();
|
||||
|
||||
const [deferredSidePanelSearch] = useDebounce(sidePanelSearch, 300);
|
||||
const [deferredSidePanelSearch] = useDebounce(trimmedSidePanelSearch, 300);
|
||||
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
|
||||
const includedObjectNameSingulars = useSearchableObjectNameSingulars({
|
||||
selectedObjectNameSingular: sidePanelSearchObjectFilter,
|
||||
});
|
||||
|
||||
const includedObjectNameSingulars = useMemo(() => {
|
||||
if (isDefined(sidePanelSearchObjectFilter)) {
|
||||
return [sidePanelSearchObjectFilter];
|
||||
}
|
||||
|
||||
return readableObjectMetadataItems
|
||||
.filter((item) => sidePanelShowHiddenObjects || item.isSearchable)
|
||||
.map((item) => item.nameSingular);
|
||||
}, [
|
||||
readableObjectMetadataItems,
|
||||
sidePanelSearchObjectFilter,
|
||||
sidePanelShowHiddenObjects,
|
||||
]);
|
||||
|
||||
const { data: searchData, loading } = useQuery(SearchDocument, {
|
||||
client: coreClient,
|
||||
variables: {
|
||||
searchInput: deferredSidePanelSearch ?? '',
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
includedObjectNameSingulars,
|
||||
},
|
||||
const { loading, searchRecords } = useObjectRecordSearchRecords({
|
||||
objectNameSingulars: includedObjectNameSingulars,
|
||||
searchInput: deferredSidePanelSearch,
|
||||
});
|
||||
|
||||
const searchResultItems: SearchResultItem[] = useMemo(() => {
|
||||
return (searchData?.search.edges.map((edge) => edge.node) ?? []).map(
|
||||
(searchRecord) => ({
|
||||
id: searchRecord.recordId,
|
||||
label: searchRecord.label,
|
||||
objectNameSingular: searchRecord.objectNameSingular,
|
||||
recordId: searchRecord.recordId,
|
||||
imageUrl: searchRecord.imageUrl,
|
||||
objectLabel:
|
||||
readableObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === searchRecord.objectNameSingular,
|
||||
)?.labelSingular ?? searchRecord.objectNameSingular,
|
||||
avatarType:
|
||||
searchRecord.objectNameSingular === CoreObjectNameSingular.Company
|
||||
? ('squared' as const)
|
||||
: ('rounded' as const),
|
||||
}),
|
||||
);
|
||||
}, [searchData, readableObjectMetadataItems]);
|
||||
return searchRecords.map((searchRecord) => ({
|
||||
id: searchRecord.recordId,
|
||||
label: searchRecord.label,
|
||||
objectNameSingular: searchRecord.objectNameSingular,
|
||||
recordId: searchRecord.recordId,
|
||||
imageUrl: searchRecord.imageUrl,
|
||||
objectLabel:
|
||||
readableObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === searchRecord.objectNameSingular,
|
||||
)?.labelSingular ?? searchRecord.objectNameSingular,
|
||||
avatarType:
|
||||
searchRecord.objectNameSingular === CoreObjectNameSingular.Company
|
||||
? ('squared' as const)
|
||||
: ('rounded' as const),
|
||||
}));
|
||||
}, [searchRecords, readableObjectMetadataItems]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
|
||||
+5
@@ -12,6 +12,11 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
const meta: Meta<typeof SettingsRestPlayground> = {
|
||||
title: 'Pages/Settings/Playground/RestPlayground',
|
||||
component: SettingsRestPlayground,
|
||||
// Scalar renders an embedded API reference that manipulates browser history
|
||||
// and can reload the headless storybook test iframe. Exclude this
|
||||
// 3rd-party-tool wrapper from the vitest run; it remains available in
|
||||
// Storybook UI. (!test removes the auto-applied `test` tag.)
|
||||
tags: ['!test'],
|
||||
decorators: [
|
||||
(Story) => {
|
||||
jotaiStore.set(playgroundApiKeyState.atom, {
|
||||
|
||||
Reference in New Issue
Block a user