SearchCard.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { css } from '@emotion/css';
  2. import React, { useCallback, useRef, useState } from 'react';
  3. import SVG from 'react-inlinesvg';
  4. import { usePopper } from 'react-popper';
  5. import { GrafanaTheme2 } from '@grafana/data';
  6. import { selectors } from '@grafana/e2e-selectors';
  7. import { Icon, Portal, TagList, useTheme2 } from '@grafana/ui';
  8. import { backendSrv } from 'app/core/services/backend_srv';
  9. import { DashboardSectionItem, OnToggleChecked } from '../types';
  10. import { SearchCardExpanded } from './SearchCardExpanded';
  11. import { SearchCheckbox } from './SearchCheckbox';
  12. const DELAY_BEFORE_EXPANDING = 500;
  13. export interface Props {
  14. editable?: boolean;
  15. item: DashboardSectionItem;
  16. onTagSelected?: (name: string) => any;
  17. onToggleChecked?: OnToggleChecked;
  18. }
  19. export function getThumbnailURL(uid: string, isLight?: boolean) {
  20. return `/api/dashboards/uid/${uid}/img/thumb/${isLight ? 'light' : 'dark'}`;
  21. }
  22. export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
  23. const [hasImage, setHasImage] = useState(true);
  24. const [lastUpdated, setLastUpdated] = useState<string | null>(null);
  25. const [showExpandedView, setShowExpandedView] = useState(false);
  26. const timeout = useRef<number | null>(null);
  27. // Popper specific logic
  28. const offsetCallback = useCallback(({ placement, reference, popper }) => {
  29. let result: [number, number] = [0, 0];
  30. if (placement === 'bottom' || placement === 'top') {
  31. result = [0, -(reference.height + popper.height) / 2];
  32. } else if (placement === 'left' || placement === 'right') {
  33. result = [-(reference.width + popper.width) / 2, 0];
  34. }
  35. return result;
  36. }, []);
  37. const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null);
  38. const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
  39. const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, {
  40. modifiers: [
  41. {
  42. name: 'offset',
  43. options: {
  44. offset: offsetCallback,
  45. },
  46. },
  47. ],
  48. });
  49. const theme = useTheme2();
  50. const imageSrc = getThumbnailURL(item.uid!, theme.isLight);
  51. const styles = getStyles(
  52. theme,
  53. markerElement?.getBoundingClientRect().width,
  54. popperElement?.getBoundingClientRect().width
  55. );
  56. const onShowExpandedView = async () => {
  57. setShowExpandedView(true);
  58. if (item.uid && !lastUpdated) {
  59. const dashboard = await backendSrv.getDashboardByUid(item.uid);
  60. const { updated } = dashboard.meta;
  61. if (updated) {
  62. setLastUpdated(new Date(updated).toLocaleString());
  63. } else {
  64. setLastUpdated(null);
  65. }
  66. }
  67. };
  68. const onMouseEnter = () => {
  69. timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
  70. };
  71. const onMouseMove = () => {
  72. if (timeout.current) {
  73. window.clearTimeout(timeout.current);
  74. }
  75. timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
  76. };
  77. const onMouseLeave = () => {
  78. if (timeout.current) {
  79. window.clearTimeout(timeout.current);
  80. }
  81. setShowExpandedView(false);
  82. };
  83. const onCheckboxClick = (ev: React.MouseEvent) => {
  84. ev.stopPropagation();
  85. ev.preventDefault();
  86. onToggleChecked?.(item);
  87. };
  88. const onTagClick = (tag: string, ev: React.MouseEvent) => {
  89. ev.stopPropagation();
  90. ev.preventDefault();
  91. onTagSelected?.(tag);
  92. };
  93. return (
  94. <a
  95. data-testid={selectors.components.Search.dashboardCard(item.title)}
  96. className={styles.card}
  97. key={item.uid}
  98. href={item.url}
  99. ref={(ref) => setMarkerElement(ref as unknown as HTMLDivElement)}
  100. onMouseEnter={onMouseEnter}
  101. onMouseLeave={onMouseLeave}
  102. onMouseMove={onMouseMove}
  103. >
  104. <div className={styles.imageContainer}>
  105. <SearchCheckbox
  106. className={styles.checkbox}
  107. aria-label="Select dashboard"
  108. editable={editable}
  109. checked={item.checked}
  110. onClick={onCheckboxClick}
  111. />
  112. {hasImage ? (
  113. <img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} />
  114. ) : (
  115. <div className={styles.imagePlaceholder}>
  116. {item.icon ? (
  117. <SVG src={item.icon} width={36} height={36} title={item.title} />
  118. ) : (
  119. <Icon name="apps" size="xl" />
  120. )}
  121. </div>
  122. )}
  123. </div>
  124. <div className={styles.info}>
  125. <div className={styles.title}>{item.title}</div>
  126. <TagList displayMax={1} tags={item.tags} onClick={onTagClick} />
  127. </div>
  128. {showExpandedView && (
  129. <Portal className={styles.portal}>
  130. <div ref={setPopperElement} style={popperStyles.popper} {...attributes.popper}>
  131. <SearchCardExpanded
  132. className={styles.expandedView}
  133. imageHeight={240}
  134. imageWidth={320}
  135. item={item}
  136. lastUpdated={lastUpdated}
  137. />
  138. </div>
  139. </Portal>
  140. )}
  141. </a>
  142. );
  143. }
  144. const getStyles = (theme: GrafanaTheme2, markerWidth = 0, popperWidth = 0) => {
  145. const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4);
  146. return {
  147. card: css`
  148. background-color: ${theme.colors.background.secondary};
  149. border: 1px solid ${theme.colors.border.medium};
  150. border-radius: 4px;
  151. display: flex;
  152. flex-direction: column;
  153. &:hover {
  154. background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
  155. }
  156. `,
  157. checkbox: css`
  158. left: 0;
  159. margin: ${theme.spacing(1)};
  160. position: absolute;
  161. top: 0;
  162. `,
  163. expandedView: css`
  164. @keyframes expand {
  165. 0% {
  166. transform: scale(${markerWidth / popperWidth});
  167. }
  168. 100% {
  169. transform: scale(1);
  170. }
  171. }
  172. animation: expand ${theme.transitions.duration.shortest}ms ease-in-out 0s 1 normal;
  173. background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
  174. `,
  175. image: css`
  176. aspect-ratio: 4 / 3;
  177. box-shadow: ${theme.shadows.z1};
  178. margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
  179. width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
  180. `,
  181. imageContainer: css`
  182. flex: 1;
  183. position: relative;
  184. &:after {
  185. background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%);
  186. bottom: 0;
  187. content: '';
  188. left: 0;
  189. margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
  190. position: absolute;
  191. right: 0;
  192. top: 0;
  193. }
  194. `,
  195. imagePlaceholder: css`
  196. align-items: center;
  197. aspect-ratio: 4 / 3;
  198. color: ${theme.colors.text.secondary};
  199. display: flex;
  200. justify-content: center;
  201. margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
  202. width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
  203. `,
  204. info: css`
  205. align-items: center;
  206. background-color: ${theme.colors.background.canvas};
  207. border-bottom-left-radius: 4px;
  208. border-bottom-right-radius: 4px;
  209. display: flex;
  210. height: ${theme.spacing(7)};
  211. gap: ${theme.spacing(1)};
  212. padding: 0 ${theme.spacing(2)};
  213. z-index: 1;
  214. `,
  215. portal: css`
  216. pointer-events: none;
  217. `,
  218. title: css`
  219. display: block;
  220. overflow: hidden;
  221. text-overflow: ellipsis;
  222. white-space: nowrap;
  223. `,
  224. };
  225. };