Search: Track user searches and results interactions (#52949)

This commit is contained in:
Ivan Ortega Alba 2022-07-29 17:30:13 +02:00 committed by GitHub
parent 06d78ea904
commit c9d50ddaaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 116 additions and 31 deletions

View File

@ -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 ? (

View File

@ -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>

View File

@ -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

View File

@ -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}>

View File

@ -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}
/>
);
});

View File

@ -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>

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View File

@ -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) {

View File

@ -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>
);

View File

@ -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,
};
};