diff --git a/packages/headless/src/features/analytics/analytics-utils.test.ts b/packages/headless/src/features/analytics/analytics-utils.test.ts index 664f33255bf..37d1bd0b538 100644 --- a/packages/headless/src/features/analytics/analytics-utils.test.ts +++ b/packages/headless/src/features/analytics/analytics-utils.test.ts @@ -1,47 +1,95 @@ import {createMockState} from '../../test'; import {buildMockResult} from '../../test'; -import {partialDocumentInformation} from './analytics-utils'; +import {createMockRecommendationState} from '../../test/mock-recommendation-state'; +import { + partialDocumentInformation, + partialRecommendationInformation, +} from './analytics-utils'; describe('analytics-utils', () => { - it('should extract documentation information with a single author', () => { - const result = buildMockResult(); - result.raw['author'] = 'john'; + describe('#partialDocumentInformation', () => { + it('should extract documentation information with a single author', () => { + const result = buildMockResult(); + result.raw['author'] = 'john'; - expect( - partialDocumentInformation(result, createMockState()).documentAuthor - ).toBe('john'); - }); + expect( + partialDocumentInformation(result, createMockState()).documentAuthor + ).toBe('john'); + }); - it('should extract documentation information with multiple author', () => { - const result = buildMockResult(); - result.raw['author'] = ['john', 'doe']; + it('should extract documentation information with multiple author', () => { + const result = buildMockResult(); + result.raw['author'] = ['john', 'doe']; - expect( - partialDocumentInformation(result, createMockState()).documentAuthor - ).toBe('john;doe'); - }); + expect( + partialDocumentInformation(result, createMockState()).documentAuthor + ).toBe('john;doe'); + }); - it('should extract document information when there is no author', () => { - const result = buildMockResult(); - delete result.raw['author']; - expect( - partialDocumentInformation(result, createMockState()).documentAuthor - ).toBe('unknown'); - }); + it('should extract document information when there is no author', () => { + const result = buildMockResult(); + delete result.raw['author']; + expect( + partialDocumentInformation(result, createMockState()).documentAuthor + ).toBe('unknown'); + }); + + it('should extract sourceName information from source field', () => { + const result = buildMockResult(); + result.raw.source = 'mysource'; + expect( + partialDocumentInformation(result, createMockState()).sourceName + ).toBe('mysource'); + }); + + it('should extract sourceName information when there is no source field', () => { + const result = buildMockResult(); + delete result.raw['source']; + expect( + partialDocumentInformation(result, createMockState()).sourceName + ).toBe('unknown'); + }); + + it('when the result is not found in state, the documentPosition is 0', () => { + const result = buildMockResult({uniqueId: '1'}); + const state = createMockState(); - it('should extract sourceName information from source field', () => { - const result = buildMockResult(); - result.raw.source = 'mysource'; - expect( - partialDocumentInformation(result, createMockState()).sourceName - ).toBe('mysource'); + const {documentPosition} = partialDocumentInformation(result, state); + expect(documentPosition).toBe(0); + }); + + it('when the result is found in state, the documentPosition is the index + 1', () => { + const result = buildMockResult({uniqueId: '1'}); + const state = createMockState(); + state.search.response.results = [result]; + + const {documentPosition} = partialDocumentInformation(result, state); + expect(documentPosition).toBe(1); + }); }); - it('should extract sourceName information when there is no source field', () => { - const result = buildMockResult(); - delete result.raw['source']; - expect( - partialDocumentInformation(result, createMockState()).sourceName - ).toBe('unknown'); + describe('#partialRecommendationInformation', () => { + it('when the recommendation is not found in state, the documentPosition is 0', () => { + const recommendation = buildMockResult({uniqueId: '1'}); + const state = createMockRecommendationState(); + + const {documentPosition} = partialRecommendationInformation( + recommendation, + state + ); + expect(documentPosition).toBe(0); + }); + + it('when the recommendation is found in state, the documentPosition is the index + 1', () => { + const recommendation = buildMockResult({uniqueId: '1'}); + const state = createMockRecommendationState(); + state.recommendation.recommendations = [recommendation]; + + const {documentPosition} = partialRecommendationInformation( + recommendation, + state + ); + expect(documentPosition).toBe(1); + }); }); }); diff --git a/packages/headless/src/features/analytics/analytics-utils.ts b/packages/headless/src/features/analytics/analytics-utils.ts index af208d1e555..84ad59fb112 100644 --- a/packages/headless/src/features/analytics/analytics-utils.ts +++ b/packages/headless/src/features/analytics/analytics-utils.ts @@ -22,6 +22,8 @@ import {SearchEventResponse} from 'coveo.analytics/dist/definitions/events'; import {AsyncThunkAction, createAsyncThunk} from '@reduxjs/toolkit'; import {requiredNonEmptyString} from '../../utils/validate-payload'; import {ThunkExtraArguments} from '../../app/thunk-extra-arguments'; +import {PipelineSection} from '../../state/state-sections'; +import {RecommendationAppState} from '../../state/recommendation-app-state'; export enum AnalyticsType { Search, @@ -117,6 +119,26 @@ export const partialDocumentInformation = ( ({uniqueId}) => result.uniqueId === uniqueId ) || 0; + return buildPartialDocumentInformation(result, resultIndex, state); +}; + +export const partialRecommendationInformation = ( + result: Result, + state: Partial +): PartialDocumentInformation => { + const resultIndex = + state.recommendation?.recommendations.findIndex( + ({uniqueId}) => result.uniqueId === uniqueId + ) || 0; + + return buildPartialDocumentInformation(result, resultIndex, state); +}; + +function buildPartialDocumentInformation( + result: Result, + resultIndex: number, + state: Partial +): PartialDocumentInformation { const collection = result.raw.collection; const collectionName = typeof collection === 'string' ? collection : 'default'; @@ -133,7 +155,7 @@ export const partialDocumentInformation = ( sourceName: getSourceName(result), queryPipeline: state.pipeline || getPipelineInitialState(), }; -}; +} export const documentIdentifier = (result: Result): DocumentIdentifier => { return { diff --git a/packages/headless/src/features/analytics/index.ts b/packages/headless/src/features/analytics/index.ts index de0c2865a59..68e1cee27da 100644 --- a/packages/headless/src/features/analytics/index.ts +++ b/packages/headless/src/features/analytics/index.ts @@ -142,12 +142,16 @@ export namespace QuerySuggestAnalyticsActions { export const logQuerySuggestionClick = logQuerySuggestionClickAlias; } -import {logRecommendationUpdate as logRecommendationUpdateAlias} from '../recommendation/recommendation-analytics-actions'; +import { + logRecommendationUpdate as logRecommendationUpdateAlias, + logRecommendationOpen as logRecommendationOpenAlias, +} from '../recommendation/recommendation-analytics-actions'; /** - * @deprecated - This namespace will be removed. Please use `loadSearchAnalyticsActions` instead. + * @deprecated - This namespace will be removed. Please use `loadClickAnalyticsActions` from "@coveo/headless/recommendation" instead. */ export namespace RecommendationAnalyticsActions { export const logRecommendationUpdate = logRecommendationUpdateAlias; + export const logRecommendationOpen = logRecommendationOpenAlias; } import {logTriggerRedirect as logTriggerRedirectAlias} from '../redirection/redirection-analytics-actions'; diff --git a/packages/headless/src/features/recommendation/recommendation-analytics-actions.ts b/packages/headless/src/features/recommendation/recommendation-analytics-actions.ts index 1de0680346b..e22f39744f8 100644 --- a/packages/headless/src/features/recommendation/recommendation-analytics-actions.ts +++ b/packages/headless/src/features/recommendation/recommendation-analytics-actions.ts @@ -1,4 +1,11 @@ -import {AnalyticsType, makeAnalyticsAction} from '../analytics/analytics-utils'; +import {Result} from '../../api/search/search/result'; +import { + AnalyticsType, + documentIdentifier, + makeAnalyticsAction, + partialRecommendationInformation, + validateResultPayload, +} from '../analytics/analytics-utils'; /** * Logs a search event with an `actionCause` value of `recommendationInterfaceLoad`. @@ -8,3 +15,16 @@ export const logRecommendationUpdate = makeAnalyticsAction( AnalyticsType.Search, (client) => client.logRecommendationInterfaceLoad() ); + +export const logRecommendationOpen = (result: Result) => + makeAnalyticsAction( + 'analytics/recommendation/open', + AnalyticsType.Click, + (client, state) => { + validateResultPayload(result); + return client.logRecommendationOpen( + partialRecommendationInformation(result, state), + documentIdentifier(result) + ); + } + )(); diff --git a/packages/headless/src/features/recommendation/recommendation-click-analytics-actions-loader.ts b/packages/headless/src/features/recommendation/recommendation-click-analytics-actions-loader.ts new file mode 100644 index 00000000000..ae64bd8ddf4 --- /dev/null +++ b/packages/headless/src/features/recommendation/recommendation-click-analytics-actions-loader.ts @@ -0,0 +1,46 @@ +import {AsyncThunkAction} from '@reduxjs/toolkit'; +import {StateNeededByAnalyticsProvider} from '../../api/analytics/analytics'; +import {Result} from '../../api/search/search/result'; +import {Engine} from '../../app/headless-engine'; +import { + AnalyticsType, + AsyncThunkAnalyticsOptions, +} from '../analytics/analytics-utils'; +import {logRecommendationOpen} from './recommendation-analytics-actions'; + +/** + * The click analytics action creators. + */ +export interface ClickAnalyticsActionCreators { + /** + * The event to log when a recommendation is selected. + * + * @param result - The selected recommendation. + * @returns A dispatchable action. + */ + logRecommendationOpen( + recommendation: Result + ): AsyncThunkAction< + { + analyticsType: AnalyticsType.Click; + }, + void, + AsyncThunkAnalyticsOptions + >; +} + +/** + * Returns possible click analytics action creators. + * + * @param engine - The headless engine. + * @returns An object holding the action creators. + */ +export function loadClickAnalyticsActions( + engine: Engine +): ClickAnalyticsActionCreators { + engine.addReducers({}); + + return { + logRecommendationOpen, + }; +} diff --git a/packages/headless/src/recommendation.index.ts b/packages/headless/src/recommendation.index.ts index 4e80fed2ebc..b3e8f873647 100644 --- a/packages/headless/src/recommendation.index.ts +++ b/packages/headless/src/recommendation.index.ts @@ -23,6 +23,7 @@ export * from './features/pipeline/pipeline-actions-loader'; export * from './features/search-hub/search-hub-actions-loader'; export * from './features/debug/debug-actions-loader'; export * from './features/recommendation/recommendation-actions-loader'; +export * from './features/recommendation/recommendation-click-analytics-actions-loader'; // Controllers export { @@ -40,3 +41,5 @@ export { ContextPayload, buildContext, } from './controllers/context/headless-context'; + +export {Result} from './api/search/search/result'; diff --git a/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.class.tsx b/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.class.tsx index 65cc6405ff5..b46346a616e 100644 --- a/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.class.tsx +++ b/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.class.tsx @@ -1,13 +1,15 @@ import {Component, ContextType} from 'react'; -import {ResultLink} from '../result-list/result-link'; import { buildRecommendationList, RecommendationList as HeadlessRecommendationList, RecommendationListOptions, RecommendationListState, + Result, Unsubscribe, } from '@coveo/headless'; +import {loadClickAnalyticsActions} from '@coveo/headless/recommendation'; import {AppContext} from '../../context/engine'; +import {filterProtocol} from '../../utils/filter-protocol'; export class RecommendationList extends Component< RecommendationListOptions, @@ -37,6 +39,17 @@ export class RecommendationList extends Component< this.setState(this.controller.state); } + private logClick(recommendation: Result) { + const engine = this.context.recommendationEngine; + + if (!engine) { + return; + } + + const {logRecommendationOpen} = loadClickAnalyticsActions(engine); + engine.dispatch(logRecommendationOpen(recommendation)); + } + render() { if (!this.state) { return null; @@ -65,9 +78,15 @@ export class RecommendationList extends Component< diff --git a/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.fn.tsx b/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.fn.tsx index 12e63414209..0ab9a1c2927 100644 --- a/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.fn.tsx +++ b/packages/samples/headless-react/src/components/recommendation-list/recommendation-list.fn.tsx @@ -1,6 +1,11 @@ -import {useEffect, useState, FunctionComponent} from 'react'; -import {ResultLink} from '../result-list/result-link'; -import {RecommendationList as HeadlessRecommendationList} from '@coveo/headless'; +import {useEffect, useState, FunctionComponent, useContext} from 'react'; +import { + RecommendationList as HeadlessRecommendationList, + Result, +} from '@coveo/headless'; +import {loadClickAnalyticsActions} from '@coveo/headless/recommendation'; +import {AppContext} from '../../context/engine'; +import {filterProtocol} from '../../utils/filter-protocol'; interface RecommendationListProps { controller: HeadlessRecommendationList; @@ -9,6 +14,7 @@ interface RecommendationListProps { export const RecommendationList: FunctionComponent = ( props ) => { + const engine = useContext(AppContext).recommendationEngine; const {controller} = props; const [state, setState] = useState(controller.state); @@ -28,6 +34,15 @@ export const RecommendationList: FunctionComponent = ( return ; } + const logClick = (recommendation: Result) => { + if (!engine) { + return; + } + + const {logRecommendationOpen} = loadClickAnalyticsActions(engine); + engine.dispatch(logRecommendationOpen(recommendation)); + }; + return (
@@ -36,10 +51,16 @@ export const RecommendationList: FunctionComponent = (
  • diff --git a/packages/samples/headless-react/src/components/result-list/result-link.tsx b/packages/samples/headless-react/src/components/result-list/result-link.tsx index c080bb958d6..026974b4e9c 100644 --- a/packages/samples/headless-react/src/components/result-list/result-link.tsx +++ b/packages/samples/headless-react/src/components/result-list/result-link.tsx @@ -1,14 +1,7 @@ import {buildInteractiveResult, Result} from '@coveo/headless'; import {FunctionComponent, useContext, useEffect} from 'react'; import {AppContext} from '../../context/engine'; - -function filterProtocol(uri: string) { - // Filters out dangerous URIs that can create XSS attacks such as `javascript:`. - const isAbsolute = /^(https?|ftp|file|mailto|tel):/i.test(uri); - const isRelative = /^\//.test(uri); - - return isAbsolute || isRelative ? uri : ''; -} +import {filterProtocol} from '../../utils/filter-protocol'; interface LinkProps { result: Result; diff --git a/packages/samples/headless-react/src/utils/filter-protocol.ts b/packages/samples/headless-react/src/utils/filter-protocol.ts new file mode 100644 index 00000000000..f51dea6aada --- /dev/null +++ b/packages/samples/headless-react/src/utils/filter-protocol.ts @@ -0,0 +1,7 @@ +export function filterProtocol(uri: string) { + // Filters out dangerous URIs that can create XSS attacks such as `javascript:`. + const isAbsolute = /^(https?|ftp|file|mailto|tel):/i.test(uri); + const isRelative = /^(\/|\.\/|\.\.\/)/.test(uri); + + return isAbsolute || isRelative ? uri : ''; +}