diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordPreviewCard.tsx b/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordPreviewCard.tsx new file mode 100644 index 0000000000..b331aaac18 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordPreviewCard.tsx @@ -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 ( + + + + + + {fieldMetadataItem.label} + + + {isRecordLoaded ? ( + + + + + + ) : ( + + )} + + + ); + }; + + return ( + + + + + } + title={ + + {recordIdentifier?.name ?? label} + + } + label={ + isRecordLoaded ? ( + beautifiedCreatedAt ? ( + Created {beautifiedCreatedAt} + ) : undefined + ) : ( + + ) + } + /> + + + + {displayedFields.map(renderFieldRow)} + {!areAllFieldsVisible && + remainingFieldCount > 0 && ( + // Keeping focus on the search input so arrow keys still move through results + event.preventDefault()} + > + setAreAllFieldsVisible(true)} + /> + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx index 03ea3e0f29..734f47da83 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/search/components/SidePanelSearchRecordsPage.tsx @@ -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 ( - - {searchResultItems.length > 0 && ( - - {searchResultItems.map((item) => { - const isTaskOrNote = [ - CoreObjectNameSingular.Task, - CoreObjectNameSingular.Note, - ].includes(item.objectNameSingular as CoreObjectNameSingular); + <> + + {searchResultItems.length > 0 && ( + + {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 ( - - +
+ + } /> - } - /> - - ); - })} - +
+
+ ); + })} +
+ )} +
+ + {shouldDisplayPreview && ( + + + )} -
+ ); }; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/constants/SearchRecordPreviewDebounceMs.ts b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SearchRecordPreviewDebounceMs.ts new file mode 100644 index 0000000000..f2ab8fac95 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SearchRecordPreviewDebounceMs.ts @@ -0,0 +1 @@ +export const SEARCH_RECORD_PREVIEW_DEBOUNCE_MS = 200; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewMaxCollapsedFields.ts b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewMaxCollapsedFields.ts new file mode 100644 index 0000000000..dcd86125ab --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewMaxCollapsedFields.ts @@ -0,0 +1 @@ +export const SIDE_PANEL_SEARCH_RECORD_PREVIEW_MAX_COLLAPSED_FIELDS = 7; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewWidth.ts b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewWidth.ts new file mode 100644 index 0000000000..f91ce95422 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/constants/SidePanelSearchRecordPreviewWidth.ts @@ -0,0 +1 @@ +export const SIDE_PANEL_SEARCH_RECORD_PREVIEW_WIDTH = 360; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewFields.ts b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewFields.ts new file mode 100644 index 0000000000..062121fcb3 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewFields.ts @@ -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]); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewItem.ts b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewItem.ts new file mode 100644 index 0000000000..0cac675de4 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewItem.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewRecord.ts b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewRecord.ts new file mode 100644 index 0000000000..32e36ea03d --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecordPreviewRecord.ts @@ -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) }; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/utils/getSidePanelSearchResultAnchorId.ts b/packages/twenty-front/src/modules/side-panel/pages/search/utils/getSidePanelSearchResultAnchorId.ts new file mode 100644 index 0000000000..a4a68173ea --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/search/utils/getSidePanelSearchResultAnchorId.ts @@ -0,0 +1,2 @@ +export const getSidePanelSearchResultAnchorId = (itemId: string) => + `side-panel-search-result-${itemId.replace(/[^a-zA-Z0-9-_]/g, '_')}`;