Skip to content

Commit

Permalink
Merge pull request #1226 from searchspring/develop
Browse files Browse the repository at this point in the history
Release 0.62.0
  • Loading branch information
korgon authored Dec 10, 2024
2 parents bae1305 + 7de021a commit 6b56ef9
Show file tree
Hide file tree
Showing 20 changed files with 2,375 additions and 1,533 deletions.
4 changes: 2 additions & 2 deletions packages/snap-client/src/Client/transforms/searchResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ transformSearchResponse.result = (rawResult: rawResult): SearchResponseModelResu
const attributes = Object.keys(rawResult)
.filter((k) => CORE_FIELDS.indexOf(k) == -1)
// remove 'badges' from attributes - but only if it is an object
.filter((k) => !(k == 'badges' && typeof rawResult[k] == 'object'))
.filter((k) => !(k == 'badges' && typeof rawResult[k] == 'object' && !Array.isArray(rawResult[k])))
.reduce((attributes, key) => {
return {
...attributes,
Expand Down Expand Up @@ -242,7 +242,7 @@ transformSearchResponse.result = (rawResult: rawResult): SearchResponseModelResu
core: coreFieldValues,
},
attributes,
badges: typeof rawResult.badges == 'object' ? rawResult.badges : [],
badges: typeof rawResult.badges == 'object' && !Array.isArray(rawResult.badges) ? rawResult.badges : [],
children,
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AutocompleteController, INPUT_DELAY as _INPUT_DELAY } from './Autocompl
import { waitFor } from '@testing-library/preact';

import { MockClient } from '@searchspring/snap-shared';
import deepmerge from 'deepmerge';

const KEY_ENTER = 13;
const KEY_ESCAPE = 27;
Expand Down Expand Up @@ -157,20 +158,27 @@ describe('Autocomplete Controller', () => {
tracker: new Tracker(globals),
});

controller.init();
// calling init to ensure event timings line up for asserting loading and loaded states
await controller.init();

const query = 'wh';
controller.urlManager = controller.urlManager.reset().set('query', query);
expect(controller.urlManager.state.query).toBe(query);

(controller.client as MockClient).mockData.updateConfig({ autocomplete: 'autocomplete.query.wh' });
await controller.search();
const searchPromise = controller.search();

await waitFor(() => {
expect(controller.store.results.length).toBeGreaterThan(0);
expect(controller.store.results.length).toBe(acConfig.globals!.pagination!.pageSize);
expect(controller.store.terms.length).toBe(acConfig.globals!.suggestions!.count);
});
expect(controller.store.loaded).toBe(false);
expect(controller.store.loading).toBe(true);

await searchPromise;

expect(controller.store.loaded).toBe(true);
expect(controller.store.loading).toBe(false);

expect(controller.store.results.length).toBeGreaterThan(0);
expect(controller.store.results.length).toBe(acConfig.globals!.pagination!.pageSize);
expect(controller.store.terms.length).toBe(acConfig.globals!.suggestions!.count);
});

it('has no results if query is blank', async () => {
Expand Down Expand Up @@ -226,6 +234,35 @@ describe('Autocomplete Controller', () => {
expect(controller.urlManager.state.query).toBe(undefined);
});

it('can opt out of binding input event', async () => {
const bindingConfig = deepmerge(acConfig, { settings: { bind: { input: false } } });

const controller = new AutocompleteController(bindingConfig, {
client: new MockClient(globals, {}),
store: new AutocompleteStore(bindingConfig, services),
urlManager,
eventManager: new EventManager(),
profiler: new Profiler(),
logger: new Logger(),
tracker: new Tracker(globals),
});

await controller.bind();

let inputEl: HTMLInputElement | null;

await waitFor(() => {
inputEl = document.querySelector(controller.config.selector);
expect(inputEl).toBeDefined();
});

const query = 'bumpers';
inputEl!.value = query;
inputEl!.focus();
inputEl!.dispatchEvent(new Event('input'));
expect(controller.urlManager.state.query).toBe(undefined);
});

it('can bind to input after input has been focused', async () => {
const controller = new AutocompleteController(acConfig, {
client: new MockClient(globals, {}),
Expand Down Expand Up @@ -672,6 +709,85 @@ describe('Autocomplete Controller', () => {
beforeSubmitfn.mockClear();
});

it('can opt out of submit event', async () => {
document.body.innerHTML = '<div><form action="/search.html"><input type="text" id="search_query"></form></div>';

const bindingConfig = deepmerge(acConfig, { settings: { bind: { submit: false } } });

const controller = new AutocompleteController(bindingConfig, {
client: new MockClient(globals, {}),
store: new AutocompleteStore(bindingConfig, services),
urlManager,
eventManager: new EventManager(),
profiler: new Profiler(),
logger: new Logger(),
tracker: new Tracker(globals),
});

await controller.bind();
(controller.client as MockClient).mockData.updateConfig({ autocomplete: 'autocomplete.query.bumpers' });

const inputEl: HTMLInputElement | null = document.querySelector(controller.config.selector);

const query = 'bumpers';
inputEl!.value = query;

const form = inputEl!.form;
const beforeSubmitfn = jest.spyOn(controller.eventManager, 'fire');
const handlerSubmitfn = jest.spyOn(controller.handlers.input, 'formSubmit');

form?.dispatchEvent(new Event('submit', { bubbles: true }));
//this timeout seems to be needed. Cant replace with waitFor
await new Promise((resolve) => setTimeout(resolve, INPUT_DELAY));

expect(beforeSubmitfn).not.toHaveBeenCalledWith('beforeSubmit', {
controller,
input: inputEl!,
});

expect(handlerSubmitfn).not.toHaveBeenCalled();

beforeSubmitfn.mockClear();
});

it('can opt out of submit event (with no form)', async () => {
const bindingConfig = deepmerge(acConfig, { action: '/search', settings: { bind: { submit: false } } });

const controller = new AutocompleteController(bindingConfig, {
client: new MockClient(globals, {}),
store: new AutocompleteStore(bindingConfig, services),
urlManager,
eventManager: new EventManager(),
profiler: new Profiler(),
logger: new Logger(),
tracker: new Tracker(globals),
});

await controller.bind();
(controller.client as MockClient).mockData.updateConfig({ autocomplete: 'autocomplete.query.bumpers' });

const beforeSubmitfn = jest.spyOn(controller.eventManager, 'fire');
const enterKeyfn = jest.spyOn(controller.handlers.input, 'enterKey');
const inputEl: HTMLInputElement | null = document.querySelector(controller.config.selector);

const query = 'bumpers';
inputEl!.value = query;

inputEl!.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, keyCode: KEY_ENTER }));

// this timeout seems to be needed. Cant replace with waitFor
await new Promise((resolve) => setTimeout(resolve, INPUT_DELAY));

expect(beforeSubmitfn).not.toHaveBeenCalledWith('beforeSubmit', {
controller,
input: inputEl!,
});

expect(enterKeyfn).not.toHaveBeenCalled();

beforeSubmitfn.mockClear();
});

it('adds fallback query when integrated spell correct setting is enabled', async () => {
let acConfig2 = { ...acConfig, settings: { integratedSpellCorrection: true } };

Expand Down Expand Up @@ -886,6 +1002,7 @@ describe('Autocomplete Controller', () => {
expect(inputEl).toBeDefined();

inputEl.value = query;
inputEl.dispatchEvent(new Event('input', { bubbles: true }));

await controller.search();
expect(controller.store.terms.length).toBeGreaterThan(0);
Expand All @@ -905,6 +1022,64 @@ describe('Autocomplete Controller', () => {
});
});

it('will not redirect when the previous search included a redirect in merchandising response', async () => {
document.body.innerHTML = '<div><input type="text" id="search_query"></div>';
acConfig = {
...acConfig,
selector: '#search_query',
action: '/search',
settings: {
redirects: {
merchandising: true,
},
},
};

const controller = new AutocompleteController(acConfig, {
client: new MockClient(globals, {}),
store: new AutocompleteStore(acConfig, services),
urlManager,
eventManager: new EventManager(),
profiler: new Profiler(),
logger: new Logger(),
tracker: new Tracker(globals),
});
(controller.client as MockClient).mockData.updateConfig({ autocomplete: 'redirect', siteId: '8uyt2m' });

const query = 'rumper';
controller.urlManager = controller.urlManager.set('query', query);

await controller.bind();
const inputEl: HTMLInputElement = document.querySelector(controller.config.selector)!;
expect(inputEl).toBeDefined();

inputEl.value = query;
inputEl.dispatchEvent(new Event('input', { bubbles: true }));

await controller.search();
expect(controller.store.terms.length).toBeGreaterThan(0);

// @ts-ignore
delete window.location;
window.location = {
...window.location,
href: '', // jest does not support window location changes
};

expect(controller.store.merchandising.redirect).toBe('https://searchspring.com/?redirect');
// change the input to a new query
inputEl.value = 'dress';
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
expect(controller.store.merchandising.redirect).toBe('');

inputEl.focus();
inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, keyCode: KEY_ENTER }));

await waitFor(() => {
expect(window.location.href).toContain('/search?oq=rumper&search_query=romper');
});
});

it('can redirect url when singleResult', async () => {
document.body.innerHTML = '<div><input type="text" id="search_query"></div>';
acConfig = {
Expand Down
59 changes: 29 additions & 30 deletions packages/snap-controller/src/Autocomplete/AutocompleteController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getSearchParams } from '../utils/getParams';
import { ControllerTypes } from '../types';

import { AutocompleteStore } from '@searchspring/snap-store-mobx';
import type { AutocompleteControllerConfig, BeforeSearchObj, AfterSearchObj, AfterStoreObj, ControllerServices, ContextVariables } from '../types';
import type { AutocompleteControllerConfig, AfterSearchObj, AfterStoreObj, ControllerServices, ContextVariables } from '../types';
import type { Next } from '@searchspring/snap-event-manager';
import type { AutocompleteRequestModel } from '@searchspring/snapi-types';

Expand Down Expand Up @@ -35,6 +35,10 @@ const defaultConfig: AutocompleteControllerConfig = {
merchandising: true,
singleResult: false,
},
bind: {
input: true,
submit: true,
},
},
};

Expand Down Expand Up @@ -76,31 +80,16 @@ export class AutocompleteController extends AbstractController {
key: `ss-controller-${this.config.id}`,
});

// add 'beforeSearch' middleware
this.eventManager.on('beforeSearch', async (ac: BeforeSearchObj, next: Next): Promise<void | boolean> => {
ac.controller.store.loading = true;

await next();
});

// add 'afterSearch' middleware
this.eventManager.on('afterSearch', async (ac: AfterSearchObj, next: Next): Promise<void | boolean> => {
await next();

// cancel search if no input or query doesn't match current urlState
if (ac.response.autocomplete.query != ac.controller.urlManager.state.query) {
ac.controller.store.loading = false;
return false;
}
});

// add 'afterStore' middleware
this.eventManager.on('afterStore', async (ac: AfterStoreObj, next: Next): Promise<void | boolean> => {
await next();

ac.controller.store.loading = false;
});

this.eventManager.on('beforeSubmit', async (ac: AfterStoreObj, next: Next): Promise<void | boolean> => {
await next();

Expand Down Expand Up @@ -363,6 +352,9 @@ export class AutocompleteController extends AbstractController {

this.store.state.input = value;

// remove merch redirect to prevent race condition
this.store.merchandising.redirect = '';

if (this.config?.settings?.syncInputs) {
const inputs = document.querySelectorAll(this.config.selector);
inputs.forEach((input) => {
Expand Down Expand Up @@ -445,7 +437,7 @@ export class AutocompleteController extends AbstractController {

input.setAttribute(INPUT_ATTRIBUTE, '');

input.addEventListener('input', this.handlers.input.input);
this.config.settings?.bind?.input && input.addEventListener('input', this.handlers.input.input);

if (this.config?.settings?.initializeFromUrl && !input.value && this.store.state.input) {
input.value = this.store.state.input;
Expand All @@ -458,10 +450,10 @@ export class AutocompleteController extends AbstractController {
let formActionUrl: string | undefined;

if (this.config.action) {
input.addEventListener('keydown', this.handlers.input.enterKey);
this.config.settings?.bind?.submit && input.addEventListener('keydown', this.handlers.input.enterKey);
formActionUrl = this.config.action;
} else if (form) {
form.addEventListener('submit', this.handlers.input.formSubmit as unknown as EventListener);
this.config.settings?.bind?.submit && form.addEventListener('submit', this.handlers.input.formSubmit as unknown as EventListener);
formActionUrl = form.action || '';

// serializeForm will include additional form element in our urlManager as globals
Expand Down Expand Up @@ -536,19 +528,25 @@ export class AutocompleteController extends AbstractController {
};

search = async (): Promise<void> => {
// if urlManager has no query, there will be no need to get params and no query
if (!this.urlManager.state.query) {
return;
}
try {
if (!this.initialized) {
await this.init();
}

const params = this.params;
// if urlManager has no query, there will be no need to get params and no query
if (!this.urlManager.state.query) {
return;
}

// if params have no query do not search
if (!params?.search?.query?.string) {
return;
}
const params = this.params;

// if params have no query do not search
if (!params?.search?.query?.string) {
return;
}

this.store.loading = true;

try {
try {
await this.eventManager.fire('beforeSearch', {
controller: this,
Expand Down Expand Up @@ -663,8 +661,9 @@ export class AutocompleteController extends AbstractController {
this.log.error(err);
this.handleError(err);
}
this.store.loading = false;
}
} finally {
this.store.loading = false;
}
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/snap-controller/src/Autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The `AutocompleteController` is used when making queries to the API `autocomplet
| settings.history.showResults | if history limit is set and there is no input, the first term results will be displayed | false | |
| settings.redirects.merchandising | boolean to disable merchandising redirects when ac form is submitted | true | |
| settings.redirects.singleResult | enable redirect to product detail page if search yields 1 result count | false | |
| settings.bind.input | boolean to disable binding of the input element (selector) | true | |
| settings.bind.submit | boolean to disable binding of the submit event (form submission of enter key press) | true | |
| settings.variants.field | used to set the field in which to grab the variant data from || |
| settings.variants.realtime.enabled | enable real time variant updates || |
| settings.variants.realtime.filters | specify which filters to use to determine which results are updated || |
Expand Down
Loading

0 comments on commit 6b56ef9

Please sign in to comment.