mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: Track user searches and results interactions (#52949)
This commit is contained in:
parent
06d78ea904
commit
c9d50ddaaa
@ -17,7 +17,7 @@ export interface Props extends Omit<CardContainerProps, 'disableEvents' | 'disab
|
||||
/** Link to redirect to on card click. If provided, the Card inner content will be rendered inside `a` */
|
||||
href?: string;
|
||||
/** On click handler for the Card */
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
/** @deprecated Use `Card.Heading` instead */
|
||||
heading?: ReactNode;
|
||||
/** @deprecated Use `Card.Description` instead */
|
||||
@ -37,7 +37,7 @@ export interface CardInterface extends FC<Props> {
|
||||
|
||||
const CardContext = React.createContext<{
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
disabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
} | null>(null);
|
||||
@ -93,7 +93,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps &
|
||||
return (
|
||||
<h2 className={cx(styles.heading, className)}>
|
||||
{href ? (
|
||||
<a href={href} className={styles.linkHack} aria-label={ariaLabel}>
|
||||
<a href={href} className={styles.linkHack} aria-label={ariaLabel} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
) : onClick ? (
|
||||
|
@ -20,13 +20,14 @@ export interface Props {
|
||||
item: DashboardSectionItem;
|
||||
onTagSelected?: (name: string) => any;
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
export function SearchCard({ editable, item, onTagSelected, onToggleChecked, onClick }: Props) {
|
||||
const [hasImage, setHasImage] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [showExpandedView, setShowExpandedView] = useState(false);
|
||||
@ -118,6 +119,7 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.imageContainer}>
|
||||
<SearchCheckbox
|
||||
@ -152,6 +154,7 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
|
||||
imageWidth={320}
|
||||
item={item}
|
||||
lastUpdated={lastUpdated}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
|
@ -16,9 +16,10 @@ export interface Props {
|
||||
imageWidth: number;
|
||||
item: DashboardSectionItem;
|
||||
lastUpdated?: string | null;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated }: Props) {
|
||||
export function SearchCardExpanded({ className, imageHeight, imageWidth, item, lastUpdated, onClick }: Props) {
|
||||
const theme = useTheme2();
|
||||
const [hasImage, setHasImage] = useState(true);
|
||||
const imageSrc = getThumbnailURL(item.uid!, theme.isLight);
|
||||
@ -27,7 +28,7 @@ export function SearchCardExpanded({ className, imageHeight, imageWidth, item, l
|
||||
const folderTitle = item.folderTitle || 'General';
|
||||
|
||||
return (
|
||||
<a className={classNames(className, styles.card)} key={item.uid} href={item.url}>
|
||||
<a className={classNames(className, styles.card)} key={item.uid} href={item.url} onClick={onClick}>
|
||||
<div className={styles.imageContainer}>
|
||||
{hasImage ? (
|
||||
<img
|
||||
|
@ -15,6 +15,7 @@ export interface Props {
|
||||
editable?: boolean;
|
||||
onTagSelected: (name: string) => any;
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
onClickItem?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
const selectors = e2eSelectors.components.Search;
|
||||
@ -29,7 +30,7 @@ const getIconFromMeta = (meta = ''): IconName => {
|
||||
};
|
||||
|
||||
/** @deprecated */
|
||||
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
|
||||
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected, onClickItem }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const tagSelected = useCallback(
|
||||
(tag: string, event: React.MouseEvent<HTMLElement>) => {
|
||||
@ -59,6 +60,7 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSe
|
||||
href={item.url}
|
||||
style={{ minHeight: SEARCH_ITEM_HEIGHT }}
|
||||
className={styles.container}
|
||||
onClick={onClickItem}
|
||||
>
|
||||
<Card.Heading>{item.title}</Card.Heading>
|
||||
<Card.Figure align={'center'} className={styles.checkbox}>
|
||||
|
@ -25,6 +25,7 @@ export interface DashboardSection {
|
||||
interface SectionHeaderProps {
|
||||
selection?: SelectionChecker;
|
||||
selectionToggle?: SelectionToggle;
|
||||
onClickItem?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
onTagSelected: (tag: string) => void;
|
||||
section: DashboardSection;
|
||||
renderStandaloneBody?: boolean; // render the body on its own
|
||||
@ -34,6 +35,7 @@ interface SectionHeaderProps {
|
||||
export const FolderSection: FC<SectionHeaderProps> = ({
|
||||
section,
|
||||
selectionToggle,
|
||||
onClickItem,
|
||||
onTagSelected,
|
||||
selection,
|
||||
renderStandaloneBody,
|
||||
@ -137,6 +139,7 @@ export const FolderSection: FC<SectionHeaderProps> = ({
|
||||
}
|
||||
}}
|
||||
editable={Boolean(selection != null)}
|
||||
onClickItem={onClickItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -15,11 +15,18 @@ import { SearchResultsProps } from '../components/SearchResultsTable';
|
||||
|
||||
import { DashboardSection, FolderSection } from './FolderSection';
|
||||
|
||||
type Props = Pick<SearchResultsProps, 'selection' | 'selectionToggle' | 'onTagSelected'> & {
|
||||
type Props = Pick<SearchResultsProps, 'selection' | 'selectionToggle' | 'onTagSelected' | 'onClickItem'> & {
|
||||
tags?: string[];
|
||||
hidePseudoFolders?: boolean;
|
||||
};
|
||||
export const FolderView = ({ selection, selectionToggle, onTagSelected, tags, hidePseudoFolders }: Props) => {
|
||||
export const FolderView = ({
|
||||
selection,
|
||||
selectionToggle,
|
||||
onTagSelected,
|
||||
tags,
|
||||
hidePseudoFolders,
|
||||
onClickItem,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const results = useAsync(async () => {
|
||||
@ -73,6 +80,7 @@ export const FolderView = ({ selection, selectionToggle, onTagSelected, tags, hi
|
||||
onTagSelected={onTagSelected}
|
||||
section={section}
|
||||
tags={tags}
|
||||
onClickItem={onClickItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -21,10 +21,9 @@ export const SearchResultsCards = React.memo(
|
||||
height,
|
||||
selection,
|
||||
selectionToggle,
|
||||
clearSelection,
|
||||
onTagSelected,
|
||||
onDatasourceChange,
|
||||
keyboardEvents,
|
||||
onClickItem,
|
||||
}: SearchResultsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
@ -89,11 +88,12 @@ export const SearchResultsCards = React.memo(
|
||||
}
|
||||
}}
|
||||
editable={Boolean(selection != null)}
|
||||
onClickItem={onClickItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle]
|
||||
[response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle, onClickItem]
|
||||
);
|
||||
|
||||
if (!response.totalRows) {
|
||||
|
@ -20,6 +20,7 @@ export const SearchResultsGrid = ({
|
||||
selection,
|
||||
selectionToggle,
|
||||
onTagSelected,
|
||||
onClickItem,
|
||||
keyboardEvents,
|
||||
}: SearchResultsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
@ -35,6 +36,7 @@ export const SearchResultsGrid = ({
|
||||
}
|
||||
},
|
||||
onTagSelected,
|
||||
onClick: onClickItem,
|
||||
};
|
||||
|
||||
const itemCount = response.totalRows ?? response.view.length;
|
||||
|
@ -26,6 +26,7 @@ export type SearchResultsProps = {
|
||||
clearSelection: () => void;
|
||||
onTagSelected: (tag: string) => void;
|
||||
onDatasourceChange?: (datasource?: string) => void;
|
||||
onClickItem?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||
};
|
||||
|
||||
@ -45,6 +46,7 @@ export const SearchResultsTable = React.memo(
|
||||
clearSelection,
|
||||
onTagSelected,
|
||||
onDatasourceChange,
|
||||
onClickItem,
|
||||
keyboardEvents,
|
||||
}: SearchResultsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
@ -121,14 +123,14 @@ export const SearchResultsTable = React.memo(
|
||||
cell={cell}
|
||||
columnIndex={index}
|
||||
columnCount={row.cells.length}
|
||||
userProps={{ href: url }}
|
||||
userProps={{ href: url, onClick: onClickItem }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles]
|
||||
[rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles, onClickItem]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
|
@ -14,7 +14,12 @@ import { PreviewsSystemRequirements } from '../../components/PreviewsSystemRequi
|
||||
import { useSearchQuery } from '../../hooks/useSearchQuery';
|
||||
import { getGrafanaSearcher, SearchQuery } from '../../service';
|
||||
import { SearchLayout } from '../../types';
|
||||
import { reportDashboardListViewed } from '../reporting';
|
||||
import {
|
||||
reportDashboardListViewed,
|
||||
reportSearchResultInteraction,
|
||||
reportSearchQueryInteraction,
|
||||
reportSearchFailedQueryInteraction,
|
||||
} from '../reporting';
|
||||
import { newSearchSelection, updateSearchSelection } from '../selection';
|
||||
|
||||
import { ActionRow, getValidQueryLayout } from './ActionRow';
|
||||
@ -65,6 +70,7 @@ export const SearchView = ({
|
||||
const isFolders = layout === SearchLayout.Folders;
|
||||
|
||||
const [listKey, setListKey] = useState(Date.now());
|
||||
const eventTrackingNamespace = folderDTO ? 'manage_dashboards' : 'dashboard_search';
|
||||
|
||||
const searchQuery = useMemo(() => {
|
||||
const q: SearchQuery = {
|
||||
@ -103,23 +109,55 @@ export const SearchView = ({
|
||||
// Search usage reporting
|
||||
useDebounce(
|
||||
() => {
|
||||
reportDashboardListViewed(folderDTO ? 'manage_dashboards' : 'dashboard_search', {
|
||||
reportDashboardListViewed(eventTrackingNamespace, {
|
||||
layout: query.layout,
|
||||
starred: query.starred,
|
||||
sortValue: query.sort?.value,
|
||||
query: query.query,
|
||||
tagCount: query.tag?.length,
|
||||
includePanels,
|
||||
});
|
||||
},
|
||||
1000,
|
||||
[folderDTO, query.layout, query.starred, query.sort?.value, query.query?.length, query.tag?.length]
|
||||
[]
|
||||
);
|
||||
|
||||
const onClickItem = () => {
|
||||
reportSearchResultInteraction(eventTrackingNamespace, {
|
||||
layout: query.layout,
|
||||
starred: query.starred,
|
||||
sortValue: query.sort?.value,
|
||||
query: query.query,
|
||||
tagCount: query.tag?.length,
|
||||
includePanels,
|
||||
});
|
||||
};
|
||||
|
||||
const results = useAsync(() => {
|
||||
const trackingInfo = {
|
||||
layout: query.layout,
|
||||
starred: query.starred,
|
||||
sortValue: query.sort?.value,
|
||||
query: query.query,
|
||||
tagCount: query.tag?.length,
|
||||
includePanels,
|
||||
};
|
||||
|
||||
reportSearchQueryInteraction(eventTrackingNamespace, trackingInfo);
|
||||
|
||||
if (searchQuery.starred) {
|
||||
return getGrafanaSearcher().starred(searchQuery);
|
||||
return getGrafanaSearcher()
|
||||
.starred(searchQuery)
|
||||
.catch((error) =>
|
||||
reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
|
||||
);
|
||||
}
|
||||
return getGrafanaSearcher().search(searchQuery);
|
||||
|
||||
return getGrafanaSearcher()
|
||||
.search(searchQuery)
|
||||
.catch((error) =>
|
||||
reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
|
||||
);
|
||||
}, [searchQuery]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
@ -200,6 +238,7 @@ export const SearchView = ({
|
||||
renderStandaloneBody={true}
|
||||
tags={query.tag}
|
||||
key={listKey}
|
||||
onClickItem={onClickItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -211,6 +250,7 @@ export const SearchView = ({
|
||||
tags={query.tag}
|
||||
onTagSelected={onTagAdd}
|
||||
hidePseudoFolders={hidePseudoFolders}
|
||||
onClickItem={onClickItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -229,6 +269,7 @@ export const SearchView = ({
|
||||
onTagSelected: onTagAdd,
|
||||
keyboardEvents,
|
||||
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
|
||||
onClickItem: onClickItem,
|
||||
};
|
||||
|
||||
if (layout === SearchLayout.Grid) {
|
||||
|
@ -126,7 +126,7 @@ export const generateColumns = (
|
||||
classNames += ' ' + styles.missingTitleText;
|
||||
}
|
||||
return (
|
||||
<a {...p.cellProps} href={p.userProps.href} className={classNames} title={name}>
|
||||
<a {...p.cellProps} href={p.userProps.href} onClick={p.userProps.onClick} className={classNames} title={name}>
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
|
@ -2,25 +2,48 @@ import { config, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { SearchLayout } from '../types';
|
||||
|
||||
export const reportDashboardListViewed = (
|
||||
dashboardListType: 'manage_dashboards' | 'dashboard_search',
|
||||
query: {
|
||||
layout?: SearchLayout;
|
||||
starred?: boolean;
|
||||
sortValue?: string;
|
||||
query?: string;
|
||||
tagCount?: number;
|
||||
}
|
||||
interface QueryProps {
|
||||
layout: SearchLayout;
|
||||
starred: boolean;
|
||||
sortValue: string;
|
||||
query: string;
|
||||
tagCount: number;
|
||||
includePanels: boolean;
|
||||
}
|
||||
|
||||
type DashboardListType = 'manage_dashboards' | 'dashboard_search';
|
||||
|
||||
export const reportDashboardListViewed = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_viewed`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchResultInteraction = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_result_clicked`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchQueryInteraction = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_query_submitted`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchFailedQueryInteraction = (
|
||||
dashboardListType: DashboardListType,
|
||||
{ error, ...query }: QueryProps & { error?: string }
|
||||
) => {
|
||||
reportInteraction(`${dashboardListType}_query_failed`, { ...getQuerySearchContext(query), error });
|
||||
};
|
||||
|
||||
const getQuerySearchContext = (query: QueryProps) => {
|
||||
const showPreviews = query.layout === SearchLayout.Grid;
|
||||
const previewsEnabled = Boolean(config.featureToggles.panelTitleSearch);
|
||||
const previews = previewsEnabled ? (showPreviews ? 'on' : 'off') : 'feature_disabled';
|
||||
reportInteraction(`${dashboardListType}_viewed`, {
|
||||
|
||||
return {
|
||||
previews,
|
||||
layout: query.layout,
|
||||
starredFilter: query.starred ?? false,
|
||||
sort: query.sortValue ?? '',
|
||||
tagCount: query.tagCount ?? 0,
|
||||
queryLength: query.query?.length ?? 0,
|
||||
});
|
||||
includePanels: query.includePanels ?? false,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user