feat(front): preview highlighted record in the search command bar (#23413)

Previewing the highlighted record while searching. The preview is a card
anchored to the highlighted result, and its fields come from the
object's **index view** (the base list view), so what you see while
searching matches the list you came from.

## Screenshots

> Note: these were taken before the latest design pass (smaller header,
field cap). The layout is otherwise unchanged.

Highlighted result, showing the index view's columns:

![Preview
tooltip](https://raw.githubusercontent.com/twentyhq/twenty/b39dbeea8c525581a14f34035037ad9ba79999df/.pr-screenshots/preview-tooltip-collapsed.png)

Overflow and hidden columns sit behind the `More (N)` expander:

![Preview tooltip
expanded](https://raw.githubusercontent.com/twentyhq/twenty/b39dbeea8c525581a14f34035037ad9ba79999df/.pr-screenshots/preview-tooltip-expanded.png)

Arrowing to a Person re-anchors the card and renders that object's own
index view fields:

![Preview tooltip for a
person](https://raw.githubusercontent.com/twentyhq/twenty/b39dbeea8c525581a14f34035037ad9ba79999df/.pr-screenshots/preview-tooltip-person.png)

## Changes

- `SidePanelSearchRecordPreviewCard.tsx` — the card: the shared
`SidePanelPageInfoLayout` header (avatar + name + created-at), then
read-only `FieldDisplay` rows, then the `More (N)` expander.
- `useSidePanelSearchRecordPreviewFields.ts` — resolves the object's
index view through `useViewOrDefaultView` and splits its `viewFields`
into visible and hidden, sorted by position, dropping the label
identifier since the card already shows the record name.
- `useSidePanelSearchRecordPreviewItem.ts` — resolves the highlighted
item from the selectable list, following the selection immediately.
- `useSidePanelSearchRecordPreviewRecord.ts` — hydrates the record into
the record store so field displays can read their values. The fetch is
debounced 200ms so holding an arrow key doesn't fire a `findOne` per row
crossed, and it reports whether the record is hydrated yet.
- `SidePanelSearchRecordsPage.tsx` — anchors the card with `AppTooltip`
(`place="left-start"`, controlled `isOpen`, `clickable` so the expander
is reachable) against a per-result anchor id.

## How many fields show

Collapsed, the card shows at most seven of the index view's visible
columns. Everything past that, plus the columns hidden in that view,
sits behind `More (N)`. So the expander appears whenever there are more
fields than fit, not only when the view happens to have hidden columns.

## Keeping the card stable while it loads

The first cut jumped: measuring it over time gave `288px → unmounted for
~200ms → 232px`. Two separate causes, both fixed.

It was **unmounting between records** because the previewed item was
debounced and briefly resolved to `null`. The selection is now followed
immediately and the *fetch* is what's debounced instead, so the card is
reused across records rather than remounted.

Its **size was derived from the data**, so every value that arrived
nudged the layout. The header, rows (24px) and width are fixed, with
skeleton placeholders for values until the record is hydrated. The field
list comes from view metadata, which is available synchronously, so the
card is its final size on first paint.

Measured after that change: a constant `360x328` across 13 consecutive
records spanning Person and Workspace Member, and no unmount. The
skeleton and loaded states are the same height, so values just fade in.

The card still disappears briefly on a brand-new search. That tracks the
results list turning over — the anchor row it attaches to is genuinely
removed from the DOM — so following the list is the correct behaviour
there rather than holding a stale card against a deleted anchor.

## Notes

The preview is read-only (`FieldDisplay`, not `RecordInlineCell`) — a
floating preview isn't the right place to start an inline edit, and it
keeps the card out of the field hover/edit portal machinery.

The `More` button is wrapped in a container that prevents the default
mousedown focus shift. Without it, clicking the expander moved focus out
of the search input and arrow keys started driving the record table
behind the panel instead of the results list.

The section heading still reads `Results`; the design says `Records`.
Left as-is since it is out of scope here.

## Testing

- `nx typecheck twenty-front` passes.
- `oxlint --type-aware` and `oxfmt` clean on the changed files.
- Verified manually against a seeded dev workspace: anchoring and
re-anchoring on arrow navigation, Company vs Person rendering their own
index view fields, the `More` expander, arrow keys still driving the
results list after clicking it, and the card holding a constant size
through load.
This commit is contained in:
Félix Malfait
2026-07-28 22:42:19 +02:00
committed by GitHub
parent 965e033f3f
commit 2d9582117b
9 changed files with 516 additions and 58 deletions
@@ -0,0 +1,279 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierFamilySelector';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { FieldWidgetShowMoreButton } from '@/page-layout/widgets/field/components/FieldWidgetShowMoreButton';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { SIDE_PANEL_SEARCH_RECORD_PREVIEW_MAX_COLLAPSED_FIELDS } from '@/side-panel/pages/search/constants/SidePanelSearchRecordPreviewMaxCollapsedFields';
import { SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH } from '@/side-panel/pages/search/constants/SidePanelSearchRecordPreviewWidth';
import { useSidePanelSearchRecordPreviewFields } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewFields';
import { useSidePanelSearchRecordPreviewRecord } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewRecord';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Avatar } from 'twenty-ui/data-display';
import { useIcons } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const SKELETON_HEIGHT = SKELETON_LOADER_HEIGHT_SIZES.standard.s;
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
// Pinned width so the card keeps its shape across records of different objects
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
width: ${SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH}px;
`;
const StyledHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
height: 40px;
padding: 0 ${themeCssVariables.spacing[2]};
`;
const StyledTitleText = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledFieldList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
max-height: 50dvh;
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
`;
// Rows keep a fixed height so the card does not resize as values arrive
const StyledFieldRow = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: 24px;
`;
const StyledFieldLabel = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
width: 104px;
`;
const StyledFieldLabelText = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledFieldIcon = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
height: 16px;
width: 16px;
`;
const StyledShowMoreContainer = styled.div`
display: flex;
`;
const StyledFieldValue = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
min-width: 0;
overflow: hidden;
`;
type SidePanelSearchRecordPreviewCardProps = {
objectNameSingular: string;
recordId: string;
label: string;
};
export const SidePanelSearchRecordPreviewCard = ({
objectNameSingular,
recordId,
label,
}: SidePanelSearchRecordPreviewCardProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { getIcon } = useIcons();
const { theme } = useContext(ThemeContext);
const [areAllFieldsVisible, setAreAllFieldsVisible] = useState(false);
const { visibleFields, hiddenFields } =
useSidePanelSearchRecordPreviewFields(objectMetadataItem);
const { isRecordLoaded } = useSidePanelSearchRecordPreviewRecord({
objectNameSingular,
recordId,
});
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const recordIdentifier = useAtomFamilySelectorValue(
recordStoreIdentifierFamilySelector,
{ recordId, allowRequestsToTwentyIcons },
);
const recordCreatedAt = useAtomFamilySelectorValue(
recordStoreFamilySelector,
{ recordId, fieldName: 'createdAt' },
) as string | null;
const { localeCatalog } = useAtomStateValue(dateLocaleState);
const beautifiedCreatedAt = isNonEmptyString(recordCreatedAt)
? beautifyPastDateRelativeToNow(recordCreatedAt, localeCatalog)
: '';
// Collapsed shows at most a handful of the view's visible columns; everything
// past that, plus the view's hidden columns, sits behind the expander
const collapsedFields = visibleFields.slice(
0,
SIDE_PANEL_SEARCH_RECORD_PREVIEW_MAX_COLLAPSED_FIELDS,
);
const displayedFields = areAllFieldsVisible
? [...visibleFields, ...hiddenFields]
: collapsedFields;
const remainingFieldCount =
visibleFields.length + hiddenFields.length - collapsedFields.length;
const renderFieldRow = (
fieldMetadataItem: FieldMetadataItem,
index: number,
) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
const FieldIcon = getIcon(fieldMetadataItem.icon);
const instanceId = getRecordFieldInputInstanceId({
recordId,
fieldName: fieldMetadataItem.name,
prefix: 'side-panel-search-record-preview',
});
return (
<StyledFieldRow key={fieldMetadataItem.id}>
<StyledFieldLabel>
<StyledFieldIcon>
<FieldIcon size={16} />
</StyledFieldIcon>
<StyledFieldLabelText>{fieldMetadataItem.label}</StyledFieldLabelText>
</StyledFieldLabel>
<StyledFieldValue>
{isRecordLoaded ? (
<RecordFieldComponentInstanceContext.Provider
value={{ instanceId }}
>
<FieldContext.Provider
value={{
recordId,
isLabelIdentifier: false,
fieldDefinition,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: true,
disableChipClick: true,
}}
>
<FieldDisplay />
</FieldContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
) : (
<Skeleton width={120} height={SKELETON_HEIGHT} />
)}
</StyledFieldValue>
</StyledFieldRow>
);
};
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledCard>
<StyledHeader>
<SidePanelPageInfoLayout
icon={
<Avatar
avatarUrl={getAbsoluteImageUrl(recordIdentifier?.avatarUrl)}
placeholder={recordIdentifier?.name ?? label}
placeholderColorSeed={recordId}
size="md"
type={recordIdentifier?.avatarType ?? 'rounded'}
/>
}
title={
<StyledTitleText>
{recordIdentifier?.name ?? label}
</StyledTitleText>
}
label={
isRecordLoaded ? (
beautifiedCreatedAt ? (
<Trans>Created {beautifiedCreatedAt}</Trans>
) : undefined
) : (
<Skeleton width={92} height={SKELETON_HEIGHT} />
)
}
/>
</StyledHeader>
<StyledFieldList>
{displayedFields.map(renderFieldRow)}
{!areAllFieldsVisible &&
remainingFieldCount > 0 && (
// Keeping focus on the search input so arrow keys still move through results
<StyledShowMoreContainer
onMouseDown={(event) => event.preventDefault()}
>
<FieldWidgetShowMoreButton
remainingCount={remainingFieldCount}
onClick={() => setAreAllFieldsVisible(true)}
/>
</StyledShowMoreContainer>
)}
</StyledFieldList>
</StyledCard>
</SkeletonTheme>
);
};
@@ -1,17 +1,35 @@
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { SidePanelSearchRecordPreviewCard } from '@/side-panel/pages/search/components/SidePanelSearchRecordPreviewCard';
import { SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH } from '@/side-panel/pages/search/constants/SidePanelSearchRecordPreviewWidth';
import { useSidePanelSearchRecordPreviewItem } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewItem';
import { useSidePanelSearchRecords } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecords';
import { getSidePanelSearchResultAnchorId } from '@/side-panel/pages/search/utils/getSidePanelSearchResultAnchorId';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { css } from '@linaria/core';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { useLingui } from '@lingui/react/macro';
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
// The card brings its own surface, so the tooltip only contributes the shadow.
// Tooltips render at 0.9 opacity, which would make the card translucent.
const previewTooltipClass = css`
background: transparent !important;
border-radius: ${themeCssVariables.border.radius.md} !important;
box-shadow: ${themeCssVariables.boxShadow.strong} !important;
opacity: 1 !important;
padding: 0 !important;
`;
export const SidePanelSearchRecordsPage = () => {
const { t } = useLingui();
@@ -19,69 +37,99 @@ export const SidePanelSearchRecordsPage = () => {
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const { closeCommandMenu } = useCloseCommandMenu();
const navigate = useNavigate();
const isMobile = useIsMobile();
const selectableItemIds = useMemo(
() => searchResultItems.map((item) => item.id),
[searchResultItems],
);
const previewedItem = useSidePanelSearchRecordPreviewItem(searchResultItems);
const shouldDisplayPreview = !isMobile && isDefined(previewedItem);
return (
<SidePanelList
selectableItemIds={selectableItemIds}
loading={loading}
noResults={noResults}
>
{searchResultItems.length > 0 && (
<SidePanelGroup heading={t`Results`}>
{searchResultItems.map((item) => {
const isTaskOrNote = [
CoreObjectNameSingular.Task,
CoreObjectNameSingular.Note,
].includes(item.objectNameSingular as CoreObjectNameSingular);
<>
<SidePanelList
selectableItemIds={selectableItemIds}
loading={loading}
noResults={noResults}
>
{searchResultItems.length > 0 && (
<SidePanelGroup heading={t`Results`}>
{searchResultItems.map((item) => {
const isTaskOrNote = [
CoreObjectNameSingular.Task,
CoreObjectNameSingular.Note,
].includes(item.objectNameSingular as CoreObjectNameSingular);
const handleClick = () => {
if (isTaskOrNote) {
openRecordInSidePanel({
recordId: item.recordId,
objectNameSingular:
item.objectNameSingular as CoreObjectNameSingular,
});
} else {
closeCommandMenu();
navigate(
getAppPath(AppPath.RecordShowPage, {
objectNameSingular: item.objectNameSingular,
objectRecordId: item.recordId,
}),
);
}
};
const handleClick = () => {
if (isTaskOrNote) {
openRecordInSidePanel({
recordId: item.recordId,
objectNameSingular:
item.objectNameSingular as CoreObjectNameSingular,
});
} else {
closeCommandMenu();
navigate(
getAppPath(AppPath.RecordShowPage, {
objectNameSingular: item.objectNameSingular,
objectRecordId: item.recordId,
}),
);
}
};
return (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={handleClick}
>
<CommandMenuItem
id={item.id}
label={item.label}
description={item.objectLabel}
onClick={handleClick}
LeftComponent={
<Avatar
type={item.avatarType}
avatarUrl={getAbsoluteImageUrl(item.imageUrl)}
placeholderColorSeed={item.recordId}
placeholder={item.label}
return (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={handleClick}
>
<div id={getSidePanelSearchResultAnchorId(item.id)}>
<CommandMenuItem
id={item.id}
label={item.label}
description={item.objectLabel}
onClick={handleClick}
LeftComponent={
<Avatar
type={item.avatarType}
avatarUrl={getAbsoluteImageUrl(item.imageUrl)}
placeholderColorSeed={item.recordId}
placeholder={item.label}
/>
}
/>
}
/>
</SelectableListItem>
);
})}
</SidePanelGroup>
</div>
</SelectableListItem>
);
})}
</SidePanelGroup>
)}
</SidePanelList>
{shouldDisplayPreview && (
<AppTooltip
anchorSelect={`#${getSidePanelSearchResultAnchorId(previewedItem.id)}`}
place="left-start"
offset={16}
noArrow
clickable
isOpen
delay={TooltipDelay.noDelay}
className={previewTooltipClass}
width={`${SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH}px`}
>
<SidePanelSearchRecordPreviewCard
key={previewedItem.recordId}
objectNameSingular={previewedItem.objectNameSingular}
recordId={previewedItem.recordId}
label={previewedItem.label}
/>
</AppTooltip>
)}
</SidePanelList>
</>
);
};
@@ -0,0 +1 @@
export const SEARCH_RECORD_PREVIEW_DEBOUNCE_MS = 200;
@@ -0,0 +1 @@
export const SIDE_PANEL_SEARCH_RECORD_PREVIEW_MAX_COLLAPSED_FIELDS = 7;
@@ -0,0 +1 @@
export const SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH = 360;
@@ -0,0 +1,57 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isActiveFieldMetadataItem } from '@/object-metadata/utils/isActiveFieldMetadataItem';
import { useViewOrDefaultView } from '@/views/hooks/useViewOrDefaultView';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
// The preview mirrors the columns of the object's index view, so what you see
// while searching matches the list view you came from. Columns hidden in that
// view stay available behind the "More" expander.
export const useSidePanelSearchRecordPreviewFields = (
objectMetadataItem: EnrichedObjectMetadataItem,
): {
visibleFields: FieldMetadataItem[];
hiddenFields: FieldMetadataItem[];
} => {
const { view } = useViewOrDefaultView({
objectMetadataItemId: objectMetadataItem.id,
});
return useMemo(() => {
if (!isDefined(view)) {
return { visibleFields: [], hiddenFields: [] };
}
const readableActiveFields = objectMetadataItem.readableFields.filter(
(fieldMetadata) =>
isActiveFieldMetadataItem({
objectNameSingular: objectMetadataItem.nameSingular,
fieldMetadata,
}),
);
const sortedViewFields = [...view.viewFields].sort(
(a, b) => a.position - b.position,
);
const toFieldMetadataItems = (isVisible: boolean) =>
sortedViewFields
.filter((viewField) => viewField.isVisible === isVisible)
.map((viewField) =>
readableActiveFields.find(
(field) => field.id === viewField.fieldMetadataId,
),
)
.filter(isDefined)
.filter(
(field) =>
field.id !== objectMetadataItem.labelIdentifierFieldMetadataId,
);
return {
visibleFields: toFieldMetadataItems(true),
hiddenFields: toFieldMetadataItems(false),
};
}, [view, objectMetadataItem]);
};
@@ -0,0 +1,17 @@
import { SIDE_PANEL_SELECTABLE_LIST_ID } from '@/side-panel/constants/SidePanelSelectableListId';
import { type SearchResultItem } from '@/side-panel/pages/search/hooks/useSidePanelSearchRecords';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
// Follows the selection immediately so the card never unmounts between
// records. Fetching is what gets debounced, one level down.
export const useSidePanelSearchRecordPreviewItem = (
searchResultItems: SearchResultItem[],
): SearchResultItem | null => {
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
SIDE_PANEL_SELECTABLE_LIST_ID,
);
return searchResultItems.find((item) => item.id === selectedItemId) ?? null;
};
@@ -0,0 +1,52 @@
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SEARCH_RECORD_PREVIEW_DEBOUNCE_MS } from '@/side-panel/pages/search/constants/SearchRecordPreviewDebounceMs';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useStore } from 'jotai';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebounce } from 'use-debounce';
// Field displays read from the record store, so the previewed record has to be
// hydrated there before its values can render. The fetch lags behind the
// selection to avoid firing a query for every row crossed with arrow keys.
export const useSidePanelSearchRecordPreviewRecord = ({
objectNameSingular,
recordId,
}: {
objectNameSingular: string;
recordId: string;
}) => {
const store = useStore();
const [debouncedRecordId] = useDebounce(
recordId,
SEARCH_RECORD_PREVIEW_DEBOUNCE_MS,
);
// objectNameSingular tracks the selection, so it only matches the debounced
// record once the selection has settled
const isDebouncedRecordIdSettled = debouncedRecordId === recordId;
const { record } = useFindOneRecord({
objectNameSingular,
objectRecordId: recordId,
skip: !isDebouncedRecordIdSettled,
});
useEffect(() => {
if (!isDefined(record)) {
return;
}
store.set(
recordStoreFamilyState.atomFamily(recordId),
record as ObjectRecord,
);
}, [record, recordId, store]);
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
return { isRecordLoaded: isDefined(recordStore) };
};
@@ -0,0 +1,2 @@
export const getSidePanelSearchResultAnchorId = (itemId: string) =>
`side-panel-search-result-${itemId.replace(/[^a-zA-Z0-9-_]/g, '_')}`;