123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- import { css } from '@emotion/css';
- import React, { useCallback, useRef, useState } from 'react';
- import SVG from 'react-inlinesvg';
- import { usePopper } from 'react-popper';
- import { GrafanaTheme2 } from '@grafana/data';
- import { selectors } from '@grafana/e2e-selectors';
- import { Icon, Portal, TagList, useTheme2 } from '@grafana/ui';
- import { backendSrv } from 'app/core/services/backend_srv';
- import { DashboardSectionItem, OnToggleChecked } from '../types';
- import { SearchCardExpanded } from './SearchCardExpanded';
- import { SearchCheckbox } from './SearchCheckbox';
- const DELAY_BEFORE_EXPANDING = 500;
- export interface Props {
- editable?: boolean;
- item: DashboardSectionItem;
- onTagSelected?: (name: string) => any;
- onToggleChecked?: OnToggleChecked;
- }
- export function getThumbnailURL(uid: string, isLight?: boolean) {
- return `/api/dashboards/uid/${uid}/img/thumb/${isLight ? 'light' : 'dark'}`;
- }
- export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: Props) {
- const [hasImage, setHasImage] = useState(true);
- const [lastUpdated, setLastUpdated] = useState<string | null>(null);
- const [showExpandedView, setShowExpandedView] = useState(false);
- const timeout = useRef<number | null>(null);
- // Popper specific logic
- const offsetCallback = useCallback(({ placement, reference, popper }) => {
- let result: [number, number] = [0, 0];
- if (placement === 'bottom' || placement === 'top') {
- result = [0, -(reference.height + popper.height) / 2];
- } else if (placement === 'left' || placement === 'right') {
- result = [-(reference.width + popper.width) / 2, 0];
- }
- return result;
- }, []);
- const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null);
- const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
- const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement, {
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: offsetCallback,
- },
- },
- ],
- });
- const theme = useTheme2();
- const imageSrc = getThumbnailURL(item.uid!, theme.isLight);
- const styles = getStyles(
- theme,
- markerElement?.getBoundingClientRect().width,
- popperElement?.getBoundingClientRect().width
- );
- const onShowExpandedView = async () => {
- setShowExpandedView(true);
- if (item.uid && !lastUpdated) {
- const dashboard = await backendSrv.getDashboardByUid(item.uid);
- const { updated } = dashboard.meta;
- if (updated) {
- setLastUpdated(new Date(updated).toLocaleString());
- } else {
- setLastUpdated(null);
- }
- }
- };
- const onMouseEnter = () => {
- timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
- };
- const onMouseMove = () => {
- if (timeout.current) {
- window.clearTimeout(timeout.current);
- }
- timeout.current = window.setTimeout(onShowExpandedView, DELAY_BEFORE_EXPANDING);
- };
- const onMouseLeave = () => {
- if (timeout.current) {
- window.clearTimeout(timeout.current);
- }
- setShowExpandedView(false);
- };
- const onCheckboxClick = (ev: React.MouseEvent) => {
- ev.stopPropagation();
- ev.preventDefault();
- onToggleChecked?.(item);
- };
- const onTagClick = (tag: string, ev: React.MouseEvent) => {
- ev.stopPropagation();
- ev.preventDefault();
- onTagSelected?.(tag);
- };
- return (
- <a
- data-testid={selectors.components.Search.dashboardCard(item.title)}
- className={styles.card}
- key={item.uid}
- href={item.url}
- ref={(ref) => setMarkerElement(ref as unknown as HTMLDivElement)}
- onMouseEnter={onMouseEnter}
- onMouseLeave={onMouseLeave}
- onMouseMove={onMouseMove}
- >
- <div className={styles.imageContainer}>
- <SearchCheckbox
- className={styles.checkbox}
- aria-label="Select dashboard"
- editable={editable}
- checked={item.checked}
- onClick={onCheckboxClick}
- />
- {hasImage ? (
- <img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} />
- ) : (
- <div className={styles.imagePlaceholder}>
- {item.icon ? (
- <SVG src={item.icon} width={36} height={36} title={item.title} />
- ) : (
- <Icon name="apps" size="xl" />
- )}
- </div>
- )}
- </div>
- <div className={styles.info}>
- <div className={styles.title}>{item.title}</div>
- <TagList displayMax={1} tags={item.tags} onClick={onTagClick} />
- </div>
- {showExpandedView && (
- <Portal className={styles.portal}>
- <div ref={setPopperElement} style={popperStyles.popper} {...attributes.popper}>
- <SearchCardExpanded
- className={styles.expandedView}
- imageHeight={240}
- imageWidth={320}
- item={item}
- lastUpdated={lastUpdated}
- />
- </div>
- </Portal>
- )}
- </a>
- );
- }
- const getStyles = (theme: GrafanaTheme2, markerWidth = 0, popperWidth = 0) => {
- const IMAGE_HORIZONTAL_MARGIN = theme.spacing(4);
- return {
- card: css`
- background-color: ${theme.colors.background.secondary};
- border: 1px solid ${theme.colors.border.medium};
- border-radius: 4px;
- display: flex;
- flex-direction: column;
- &:hover {
- background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
- }
- `,
- checkbox: css`
- left: 0;
- margin: ${theme.spacing(1)};
- position: absolute;
- top: 0;
- `,
- expandedView: css`
- @keyframes expand {
- 0% {
- transform: scale(${markerWidth / popperWidth});
- }
- 100% {
- transform: scale(1);
- }
- }
- animation: expand ${theme.transitions.duration.shortest}ms ease-in-out 0s 1 normal;
- background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
- `,
- image: css`
- aspect-ratio: 4 / 3;
- box-shadow: ${theme.shadows.z1};
- margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
- width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
- `,
- imageContainer: css`
- flex: 1;
- position: relative;
- &:after {
- background: linear-gradient(180deg, rgba(196, 196, 196, 0) 0%, rgba(127, 127, 127, 0.25) 100%);
- bottom: 0;
- content: '';
- left: 0;
- margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
- position: absolute;
- right: 0;
- top: 0;
- }
- `,
- imagePlaceholder: css`
- align-items: center;
- aspect-ratio: 4 / 3;
- color: ${theme.colors.text.secondary};
- display: flex;
- justify-content: center;
- margin: ${theme.spacing(1)} ${IMAGE_HORIZONTAL_MARGIN} 0;
- width: calc(100% - (2 * ${IMAGE_HORIZONTAL_MARGIN}));
- `,
- info: css`
- align-items: center;
- background-color: ${theme.colors.background.canvas};
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- display: flex;
- height: ${theme.spacing(7)};
- gap: ${theme.spacing(1)};
- padding: 0 ${theme.spacing(2)};
- z-index: 1;
- `,
- portal: css`
- pointer-events: none;
- `,
- title: css`
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- `,
- };
- };
|