From ce66a31cbbad65ede7bc4b37bbc7f084c7216fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rokas=20Bar=C5=A1auskas?= Date: Wed, 24 Apr 2024 18:23:44 +0300 Subject: [PATCH 1/3] feat: Implement isKeyword support for analytics --- src/api/client.ts | 2 +- src/api/search/index.ts | 8 ++++++++ src/autocomplete.ts | 8 ++++++-- src/config.ts | 10 ++++++++-- src/search.ts | 14 ++++++++++---- src/utils/dropdown.ts | 10 +++++++--- src/utils/state.ts | 18 ++++++++++++------ 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 8e48968..4e393cf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -116,7 +116,7 @@ export function log( (async () => { const api = await getNostoClient() api.captureError(error, "nostoAutocomplete", level) - })() + })() } console[level](...[msg, error].filter(Boolean)) } diff --git a/src/api/search/index.ts b/src/api/search/index.ts index 3ea7c53..93be314 100644 --- a/src/api/search/index.ts +++ b/src/api/search/index.ts @@ -19,6 +19,10 @@ export interface InputSearchQueryWithFields extends InputSearchQuery { } & InputSearchQuery["keywords"] } +/** + * @group Nosto Client + * @category Core + */ export interface SearchOptions { /** * Enabled Nosto tracking. The source of search request must be provided, e.g. if request made from autocomplete, track should be set to "autocomplete". @@ -28,6 +32,10 @@ export interface SearchOptions { * Automatically handle redirect when received from search. */ redirect?: boolean + /** + * Marks that search was done by clicking a keyword + */ + isKeyword?: boolean } export * from "./generated" diff --git a/src/autocomplete.ts b/src/autocomplete.ts index 949cd50..42fbbb9 100644 --- a/src/autocomplete.ts +++ b/src/autocomplete.ts @@ -8,6 +8,7 @@ import { LimiterError, createLimiter } from "./utils/limiter" import { CancellableError } from "./utils/promise" import { getGaTrackUrl, isGaEnabled, trackGaPageView } from "./utils/ga" import { createHistory } from "./utils/history" +import { SearchOptions } from "./api/search" export type AutocompleteInstance = { /** @@ -24,6 +25,8 @@ export type AutocompleteInstance = { destroy(): void } +export type SearchAutocompleteOptions = Pick + /** * @param config Autocomplete configuration. * @returns Autocomplete instance. @@ -338,8 +341,9 @@ function submitWithContext(context: { config: AutocompleteConfig actions: StateActions }) { - return async (value: string, redirect: boolean = false) => { + return async (value: string, options?: SearchAutocompleteOptions) => { const { config, actions } = context + const { redirect = false } = options ?? {} if (value.length > 0) { if (config.historyEnabled) { @@ -359,7 +363,7 @@ function submitWithContext(context: { } if (!redirect && typeof config?.submit === "function") { - config.submit(value, config) + config.submit(value, config, options) } } } diff --git a/src/config.ts b/src/config.ts index 3056dfa..6575b30 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { InputSearchQueryWithFields } from "./api/search" +import { SearchAutocompleteOptions } from "./autocomplete" import { search } from "./search" /** @@ -55,7 +56,11 @@ export interface AutocompleteConfig { /** * The function to use to submit the search */ - submit?: (query: string, config: AutocompleteConfig) => void + submit?: ( + query: string, + config: AutocompleteConfig, + options?: SearchAutocompleteOptions + ) => void /** * Enable history */ @@ -87,7 +92,7 @@ export function getDefaultConfig() { historySize: 5, nostoAnalytics: true, googleAnalytics: defaultGaConfig, - submit: (query, config) => { + submit: (query, config, options) => { if ( query.length >= (config.minQueryLength ?? @@ -100,6 +105,7 @@ export function getDefaultConfig() { { redirect: true, track: config.nostoAnalytics ? "serp" : undefined, + ...options, } ) } diff --git a/src/search.ts b/src/search.ts index 036d607..f9f35c0 100644 --- a/src/search.ts +++ b/src/search.ts @@ -82,7 +82,11 @@ export async function search( query: InputSearchQueryWithFields, options?: SearchOptions ) { - const { redirect, track } = options ?? { redirect: false, track: undefined } + const { redirect, track, isKeyword } = options ?? { + redirect: false, + track: undefined, + isKeyword: false, + } const fields = query.products?.fields ?? defaultProductFields const facets = query.products?.facets ?? ["*"] @@ -90,7 +94,8 @@ export async function search( const from = query.products?.from ?? 0 const api = await getNostoClient() - const response = await api.search({ + const response = await api.search( + { ...query, products: { ...query.products, @@ -98,9 +103,10 @@ export async function search( facets, size, from, - } + }, }, - { redirect, track }) + { redirect, track, isKeyword } + ) return { query, response } } diff --git a/src/utils/dropdown.ts b/src/utils/dropdown.ts index 6e1f13f..96379a0 100644 --- a/src/utils/dropdown.ts +++ b/src/utils/dropdown.ts @@ -1,4 +1,5 @@ import { log } from "../api/client" +import { SearchAutocompleteOptions } from "../autocomplete" type OnClickBindings = { [key: string]: (obj: { @@ -12,7 +13,10 @@ export function createDropdown( container: HTMLElement, initialState: PromiseLike, render: (container: HTMLElement, state: State) => void | PromiseLike, - submit: (inputValue: string, redirect?: boolean) => unknown, + submit: ( + inputValue: string, + options?: SearchAutocompleteOptions + ) => unknown, updateInput: (inputValue: string) => void, onClickBindings?: OnClickBindings ) { @@ -35,7 +39,7 @@ export function createDropdown( } if (parsedHit?.keyword) { - submit(parsedHit.keyword, !!parsedHit?._redirect) + submit(parsedHit.keyword, { redirect: !!parsedHit?._redirect, isKeyword: true }) if (parsedHit?._redirect) { location.href = parsedHit._redirect @@ -230,7 +234,7 @@ export function createDropdown( async function init() { const state = await Promise.resolve(initialState) await Promise.resolve(render(container, state)) - + // Without setTimeout React does not have committed DOM changes yet, so we don't have the correct elements. setTimeout(() => { loadElements() diff --git a/src/utils/state.ts b/src/utils/state.ts index 8fc93ed..73f49be 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -3,6 +3,7 @@ import { AutocompleteConfig } from "../config" import { History } from "./history" import { Cancellable, makeCancellable } from "./promise" import { search } from "../search" +import { SearchAutocompleteOptions } from "../autocomplete" /** * @group Autocomplete @@ -41,7 +42,11 @@ export const getStateActions = ({ }): StateActions => { let cancellable: Cancellable | undefined - const fetchState = (value: string, config: AutocompleteConfig): PromiseLike => { + const fetchState = ( + value: string, + config: AutocompleteConfig, + options?: SearchAutocompleteOptions + ): PromiseLike => { if (typeof config.fetch === "function") { return config.fetch(value) } else { @@ -54,27 +59,28 @@ export const getStateActions = ({ { track: config.nostoAnalytics ? "autocomplete" : undefined, redirect: false, + ...options, } ) } } - + function getHistoryState(query: string): PromiseLike { - // @ts-expect-error type mismatch + // @ts-expect-error type mismatch return Promise.resolve({ query: { query, }, - history: history?.getItems() + history: history?.getItems(), }) } return { - updateState: (inputValue?: string): PromiseLike => { + updateState: (inputValue?: string, options?: SearchAutocompleteOptions): PromiseLike => { cancellable?.cancel() if (inputValue && inputValue.length >= config.minQueryLength) { - cancellable = makeCancellable(fetchState(inputValue, config)) + cancellable = makeCancellable(fetchState(inputValue, config, options)) return cancellable.promise } else if (history) { return getHistoryState(inputValue ?? "") From d9f3f51b3690bc5e7e5493aaafb44f417e45cc07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rokas=20Bar=C5=A1auskas?= Date: Thu, 25 Apr 2024 13:00:42 +0300 Subject: [PATCH 2/3] test: add isKeyword tests --- spec/suites/autocomplete.ts | 75 ++++++++++++++++++++++++++++++++++++- src/search.ts | 6 +-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/spec/suites/autocomplete.ts b/spec/suites/autocomplete.ts index 1284aa7..8ad9983 100644 --- a/spec/suites/autocomplete.ts +++ b/spec/suites/autocomplete.ts @@ -9,6 +9,7 @@ import { NostoClient, autocomplete, } from "../../src/entries/base" +import { getDefaultConfig } from "../../src/config" interface WindowWithNostoJS extends Window { nostojs: jest.Mock< @@ -29,7 +30,7 @@ interface WindowWithNostoJS extends Window { export const handleAutocomplete = async ( render: AutocompleteConfig["render"], - submit: AutocompleteConfig["submit"] = () => ({}) + submit?: AutocompleteConfig["submit"] ) => { autocomplete({ fetch: { @@ -56,7 +57,7 @@ export const handleAutocomplete = async ( inputSelector: "#search", dropdownSelector: "#search-results", render, - submit, + submit: submit ?? getDefaultConfig().submit, }) } @@ -349,6 +350,23 @@ export function autocompleteSuite({ ) }) + it("should call search with isKeyword=false", async () => { + const user = userEvent.setup() + + await waitFor(() => handleAutocomplete(render())) + + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + + await waitFor(() => + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: true, + isKeyword: false, + }) + ) + }) + it("should record search submit with keyboard", async () => { const user = userEvent.setup() @@ -361,6 +379,22 @@ export function autocompleteSuite({ }) }) + it("should call search with keyboard with isKeyword=false", async () => { + const user = userEvent.setup() + + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") + + await waitFor(async () => { + await user.keyboard("{enter}") + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: true, + isKeyword: false, + }) + }) + }) + it("should record search click on keyword click", async () => { const user = userEvent.setup() @@ -376,6 +410,23 @@ export function autocompleteSuite({ }) }) + it("should call search on keyword click with isKeyword=true", async () => { + const user = userEvent.setup() + + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") + + await waitFor(async () => { + await user.click(screen.getAllByTestId("keyword")?.[0]) + + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: false, + isKeyword: true, + }) + }) + }) + it("should record search click on product click", async () => { const user = userEvent.setup() @@ -448,5 +499,25 @@ export function autocompleteSuite({ assignMock.mockClear() }) + + it("should call search when keyqord is submitted with keyboard, with isKeyword=true", async () => { + const user = userEvent.setup() + + await waitFor(() => handleAutocomplete(render())) + + await user.type(screen.getByTestId("input"), "black") + + await waitFor(async () => { + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{enter}") + + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: false, + isKeyword: true, + }) + }) + }) }) } diff --git a/src/search.ts b/src/search.ts index f9f35c0..0e02061 100644 --- a/src/search.ts +++ b/src/search.ts @@ -82,11 +82,7 @@ export async function search( query: InputSearchQueryWithFields, options?: SearchOptions ) { - const { redirect, track, isKeyword } = options ?? { - redirect: false, - track: undefined, - isKeyword: false, - } + const { redirect = false, track, isKeyword = false } = options ?? {} const fields = query.products?.fields ?? defaultProductFields const facets = query.products?.facets ?? ["*"] From aca61f6cff7e9af38311237526c726a1827aae1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rokas=20Bar=C5=A1auskas?= Date: Thu, 25 Apr 2024 13:34:53 +0300 Subject: [PATCH 3/3] test: fix typo in isKeyword tests --- spec/suites/autocomplete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/suites/autocomplete.ts b/spec/suites/autocomplete.ts index 8ad9983..8e48a1f 100644 --- a/spec/suites/autocomplete.ts +++ b/spec/suites/autocomplete.ts @@ -500,7 +500,7 @@ export function autocompleteSuite({ assignMock.mockClear() }) - it("should call search when keyqord is submitted with keyboard, with isKeyword=true", async () => { + it("should call search when keyword is submitted with keyboard, with isKeyword=true", async () => { const user = userEvent.setup() await waitFor(() => handleAutocomplete(render()))