Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/redirect-url/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ autocomplete<{ name: string }>({
placeholder: 'Search',
openOnFocus: true,
insights: true,
plugins: [createRedirectUrlPlugin()],
plugins: [createRedirectUrlPlugin({ awaitSubmit: () => 2000 })],
getSources({ query }) {
return [
{
Expand Down
34 changes: 32 additions & 2 deletions packages/autocomplete-core/src/__tests__/getFormProps.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createPlayground } from '../../../../test/utils';
import { createAlgoliaInsightsPlugin } from '@algolia/autocomplete-plugin-algolia-insights';
import { createRedirectUrlPlugin } from '@algolia/autocomplete-plugin-redirect-url';

import { createPlayground, runAllMicroTasks } from '../../../../test/utils';
import { createAutocomplete } from '../createAutocomplete';

describe('getFormProps', () => {
Expand Down Expand Up @@ -46,7 +49,7 @@ describe('getFormProps', () => {
expect(formProps.role).toEqual('search');
});

describe('onSubmit', () => {
describe.skip('onSubmit', () => {
Comment thread
drodriguln marked this conversation as resolved.
Outdated
test('prevents the default event', () => {
const { getFormProps, inputElement } = createPlayground(
createAutocomplete,
Expand Down Expand Up @@ -176,6 +179,33 @@ describe('getFormProps', () => {
})
);
});

describe('a plugin is configured with the option "awaitSubmit" === true', () => {
test('should await pending requests before triggering the submit event', async () => {
const plugins = [
createRedirectUrlPlugin(), // "awaitSubmit" is true by default
createAlgoliaInsightsPlugin({}), // "awaitSubmit" is neither configurable nor defined
];
const onSubmit = jest.fn();
const { getFormProps, inputElement } = createPlayground(
createAutocomplete,
{
onSubmit,
plugins,
}
);

const formProps = getFormProps({ inputElement });

formProps.onSubmit(new Event('submit'));

expect(onSubmit).toHaveBeenCalledTimes(0);

await runAllMicroTasks();

expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
});

describe('onReset', () => {
Expand Down
53 changes: 53 additions & 0 deletions packages/autocomplete-core/src/__tests__/getInputProps.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createAlgoliaInsightsPlugin } from '@algolia/autocomplete-plugin-algolia-insights';
import { createRedirectUrlPlugin } from '@algolia/autocomplete-plugin-redirect-url';
import { fireEvent, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

Expand All @@ -9,6 +11,14 @@ import {
runAllMicroTasks,
} from '../../../../test/utils';
import { createAutocomplete } from '../createAutocomplete';
import { createCancelablePromiseList } from '../utils';

jest.mock('../utils/createCancelablePromiseList', () => ({
createCancelablePromiseList: jest.fn(
jest.requireActual('../utils/createCancelablePromiseList')
.createCancelablePromiseList
),
}));

describe('getInputProps', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1287,6 +1297,49 @@ describe('getInputProps', () => {
);
});

describe.skip('a plugin is configured with the option "awaitSubmit"', () => {
Comment thread
drodriguln marked this conversation as resolved.
Outdated
const cancelAll = jest.fn();
const event = { ...new KeyboardEvent('keydown'), key: 'Enter' };

beforeEach(() => {
(createCancelablePromiseList as jest.Mock).mockReturnValueOnce({
add: jest.fn,
cancelAll,
isEmpty: jest.fn,
wait: jest.fn,
});
});

test('when true it should not cancel pending requests', () => {
const plugins = [
createRedirectUrlPlugin({ awaitSubmit: () => true }),
createAlgoliaInsightsPlugin({}), // "awaitSubmit" is neither configurable nor defined
];

const { inputProps } = createPlayground(createAutocomplete, {
plugins,
});

inputProps.onKeyDown(event);

expect(cancelAll).toHaveBeenCalledTimes(0);
});

test('when false it should cancel pending requests', () => {
const plugins = [
createRedirectUrlPlugin({ awaitSubmit: () => false }),
];

const { inputProps } = createPlayground(createAutocomplete, {
plugins,
});

inputProps.onKeyDown(event);

expect(cancelAll).toHaveBeenCalledTimes(1);
});
});

describe('Plain Enter', () => {
test('calls onSelect with item URL', () => {
const onSelect = jest.fn();
Expand Down
31 changes: 22 additions & 9 deletions packages/autocomplete-core/src/getPropGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
InternalAutocompleteOptions,
} from './types';
import {
getPluginSubmitPromise,
getActiveItem,
getAutocompleteElementId,
isOrContainsNode,
Expand Down Expand Up @@ -126,22 +127,34 @@ export function getPropGetters<
const getFormProps: GetFormProps<TEvent> = (providedProps) => {
const { inputElement, ...rest } = providedProps;

const handleSubmit = (event: TEvent) => {
props.onSubmit({
event,
refresh,
state: store.getState(),
...setters,
});

store.dispatch('submit', null);
providedProps.inputElement?.blur();
};

return {
action: '',
noValidate: true,
role: 'search',
onSubmit: (event) => {
(event as unknown as Event).preventDefault();

props.onSubmit({
event,
refresh,
state: store.getState(),
...setters,
});

store.dispatch('submit', null);
providedProps.inputElement?.blur();
const waitForSubmit = getPluginSubmitPromise(
props.plugins,
store.pendingRequests
);
if (waitForSubmit !== undefined) {
waitForSubmit.then(() => handleSubmit(event));
} else {
handleSubmit(event);
}
},
onReset: (event) => {
(event as unknown as Event).preventDefault();
Expand Down
22 changes: 16 additions & 6 deletions packages/autocomplete-core/src/onKeyDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
BaseItem,
InternalAutocompleteOptions,
} from './types';
import { getActiveItem, getAutocompleteElementId } from './utils';
import {
getPluginSubmitPromise,
getActiveItem,
getAutocompleteElementId,
} from './utils';

interface OnKeyDownOptions<TItem extends BaseItem>
extends AutocompleteScopeApi<TItem> {
Expand Down Expand Up @@ -128,11 +132,17 @@ export function onKeyDown<TItem extends BaseItem>({
.getState()
.collections.every((collection) => collection.items.length === 0)
) {
// If requests are still pending when the panel closes, they could reopen
// the panel once they resolve.
// We want to prevent any subsequent query from reopening the panel
// because it would result in an unsolicited UI behavior.
if (!props.debug) {
const waitForSubmit = getPluginSubmitPromise(
props.plugins,
store.pendingRequests
);
if (waitForSubmit !== undefined) {
waitForSubmit.then(store.pendingRequests.cancelAll); // Cancel the rest if timeout number is provided
} else if (!props.debug) {
// If requests are still pending when the panel closes, they could reopen
// the panel once they resolve.
// We want to prevent any subsequent query from reopening the panel
// because it would result in an unsolicited UI behavior.
store.pendingRequests.cancelAll();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,18 @@ describe('createCancelablePromiseList', () => {
expect(cancelablePromise3.isCanceled()).toBe(true);
expect(cancelablePromiseList.isEmpty()).toBe(true);
});

test('waits for all promises to resolve', async () => {
const cancelablePromiseList = createCancelablePromiseList();
const cancelablePromise = createCancelablePromise.resolve();

cancelablePromiseList.add(cancelablePromise);
cancelablePromiseList.add(cancelablePromise);

expect(cancelablePromiseList.isEmpty()).toBe(false);

await cancelablePromiseList.wait();

expect(cancelablePromiseList.isEmpty()).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,18 @@ export type CancelablePromiseList<TValue> = {
* Whether there are pending promises in the list.
*/
isEmpty(): boolean;
/**
* Waits for all pending promises to be resolved.
*
* @param timeout Maximum amount of time allowed to wait for pending promises. Returns early if this time is reached.
*/
wait(timeout?: number): Promise<void>;
};

// Ensures multiple callers sync to the same promise.
let _hasWaitPromiseResolved = true;
let _waitPromise: Promise<any>;

export function createCancelablePromiseList<
TValue
>(): CancelablePromiseList<TValue> {
Expand All @@ -39,5 +49,25 @@ export function createCancelablePromiseList<
isEmpty() {
return list.length === 0;
},
wait(timeout) {
// Reuse promise if already exists. Keeps multiple callers subscribed to the same promise.
if (!_hasWaitPromiseResolved) {
return _waitPromise;
}

// Creates a promise which either resolves after all pending requests complete
// or the timeout is reached (if provided). Whichever comes first.
_hasWaitPromiseResolved = false;
_waitPromise = !timeout
? Promise.all(list)
: Promise.race([
Promise.all(list),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
]);

return _waitPromise.then(() => {
_hasWaitPromiseResolved = true;
});
},
};
}
35 changes: 35 additions & 0 deletions packages/autocomplete-core/src/utils/getPluginSubmitPromise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { InternalAutocompleteOptions } from '../types';

import { CancelablePromiseList } from './createCancelablePromiseList';

/**
* If a plugin is configured to await a submit event, this returns a promise
* for either the max timeout value found or until it completes.
* Otherwise, return undefined.
*/
export const getPluginSubmitPromise = (
plugins: InternalAutocompleteOptions<any>['plugins'],
pendingRequests: CancelablePromiseList<void>
): Promise<void> | undefined => {
let waitUntilComplete = false;
const timeouts: number[] = [];

for (const plugin of plugins) {
const value: boolean | number | undefined =
plugin.__autocomplete_pluginOptions?.awaitSubmit?.();
if (typeof value === 'number') {
timeouts.push(value);
} else if (value === true) {
waitUntilComplete = true;
break; // break loop as bool overrides num array below
}
}

if (waitUntilComplete) {
return pendingRequests.wait();
} else if (timeouts.length > 0) {
return pendingRequests.wait(Math.max(...timeouts));
}

return undefined;
};
1 change: 1 addition & 0 deletions packages/autocomplete-core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './createCancelablePromiseList';
export * from './createConcurrentSafePromise';
export * from './getNextActiveItemId';
export * from './getNormalizedSources';
export * from './getPluginSubmitPromise';
export * from './getActiveItem';
export * from './getAutocompleteElementId';
export * from './isOrContainsNode';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,19 @@ describe('createRedirectUrlPlugin', () => {
expect(plugin.name).toBe('aa.redirectUrlPlugin');
});

test('exposes passed options and excludes default ones', () => {
test('exposes all provided options with plugin.__autocomplete_pluginOptions', () => {
const plugin = createRedirectUrlPlugin({
transformResponse: jest.fn(),
templates: { item: () => 'hey' },
onRedirect: jest.fn(),
awaitSubmit: () => false,
});

expect(plugin.__autocomplete_pluginOptions).toEqual({
transformResponse: expect.any(Function),
templates: expect.any(Object),
onRedirect: expect.any(Function),
awaitSubmit: expect.any(Function),
});
});

Expand Down Expand Up @@ -220,17 +222,11 @@ describe('createRedirectUrlPlugin', () => {
await waitFor(() => {
expect(findHitsSection(panelContainer)).not.toBeInTheDocument();

expect(findDropdownOptions(findRedirectSection(panelContainer)))
.toMatchInlineSnapshot(`
Array [
HTMLCollection [
<a>
My custom option:
redirect item
</a>,
],
]
`);
const dropdownText = findDropdownOptions(
findRedirectSection(panelContainer)
)[0].item(0)?.textContent;

expect(dropdownText).toBe('My custom option: redirect item');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export type CreateRedirectUrlPluginParams<TItem extends BaseItem> = {
options: OnRedirectOptions<RedirectUrlItem>
): void;
templates?: SourceTemplates<RedirectUrlItem>;
awaitSubmit?: () => boolean | number;
};