Skip to content

Commit

Permalink
Search Entities can be now aborted
Browse files Browse the repository at this point in the history
Auto abort the request for the entities based on the given parameters
  • Loading branch information
widoz committed Feb 4, 2024
1 parent dbad408 commit 96b9eb0
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 9 deletions.
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'jsdom',
moduleDirectories: ['node_modules'],
Expand Down
23 changes: 20 additions & 3 deletions sources/client/src/api/search-entities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import EntitiesSearch from '@types';

import { doAction } from '@wordpress/hooks';

import { abortControllers } from '../services/abort-controllers';
import { ContextualAbortController } from '../services/contextual-abort-controller';
import { Set } from '../vo/set';
import { fetch } from './fetch';

Expand Down Expand Up @@ -30,11 +34,24 @@ export async function searchEntities<E>(
search: phrase,
subtype: subtype.toArray(),
_fields: serializeFields(fields),
});
}).toString();

const controller = abortControllers.add(
new ContextualAbortController(
params,
`Request aborted with parameters: ${params}`
)
);

// TODO What happen in the case of an error?
const entities = await fetch<ReadonlyArray<E>>({
path: `?rest_route=/wp/v2/search&${params.toString()}`,
path: `?rest_route=/wp/v2/search&${params}`,
signal: controller?.signal() ?? null,
}).catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') {
doAction('wp-entities-search.on-search.abort', error);
}

throw error;
});

return new Set(entities);
Expand Down
37 changes: 37 additions & 0 deletions sources/client/src/services/abort-controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ContextualAbortController } from './contextual-abort-controller';

/**
* @internal
*/
class AbortControllers {
private controllers = new Map<string, ContextualAbortController>();

public has(controller: ContextualAbortController): boolean {
return this.controllers.has(controller.context());
}

public add(
controller: ContextualAbortController
): ContextualAbortController {
const context = controller.context();

this.controller(context)?.abort();
this.set(controller);

return this.controller(context)!;
}

public delete(controller: ContextualAbortController): void {
this.controllers.delete(controller.context());
}

private set(controller: ContextualAbortController): void {
this.controllers.set(controller.context(), controller);
}

private controller(context: string): ContextualAbortController | undefined {
return this.controllers.get(context);
}
}

export const abortControllers = new AbortControllers();
39 changes: 39 additions & 0 deletions sources/client/src/services/contextual-abort-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @internal
*/
export class ContextualAbortController {
#controller: AbortController;
#context: string;
#reason: string;

constructor(context: string, reason: string) {
if (context === '') {
throw new Error('Abort Controllers, context cannot be empty');
}

this.#controller = new AbortController();
this.#context = context;
this.#reason = reason;
}

public context(): string {
return this.#context;
}

public abort(): ContextualAbortController {
this.#controller.abort(this.reason());
return this;
}

public signal(): AbortSignal {
return this.#controller.signal;
}

public isAborted(): boolean {
return this.#controller.signal.aborted;
}

private reason(): DOMException {
return new DOMException(this.#reason, 'AbortError');
}
}
70 changes: 64 additions & 6 deletions tests/client/unit/api/search-entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,38 @@ import EntitiesSearch from '@types';

import { describe, expect, it, jest } from '@jest/globals';

import { doAction } from '@wordpress/hooks';

import { fetch } from '../../../../sources/client/src/api/fetch';
import { searchEntities } from '../../../../sources/client/src/api/search-entities';
import { Set } from '../../../../sources/client/src/vo/set';

jest.mock('@wordpress/hooks', () => ({
doAction: jest.fn(),
}));

jest.mock('../../../../sources/client/src/api/fetch', () => ({
fetch: jest.fn(),
}));

describe('Search Posts', () => {
it('perform a search with the given parameters', () => {
it('perform a search with the given parameters', async () => {
let expectedPath: string = '';
// @ts-ignore
jest.mocked(fetch).mockImplementation((options: any) => {
const { path } = options;
expect(path).toBe(
'?rest_route=/wp/v2/search&per_page=10&order=DESC&orderBy=title&exclude=1%2C3&include=2%2C5&type=post&search=foo&subtype=post&_fields=title%2Cid'
);
expectedPath = options.path;
// Fetch result is irrelevant for this test
return Promise.resolve([]);
});

searchEntities('post', new Set(['post']), 'foo', {
await searchEntities('post', new Set(['post']), 'foo', {
exclude: new Set<string>(['1', '3']),
include: new Set<string>(['2', '5']),
});

expect(expectedPath).toBe(
'?rest_route=/wp/v2/search&per_page=10&order=DESC&orderBy=title&exclude=1%2C3&include=2%2C5&type=post&search=foo&subtype=post&_fields=title%2Cid'
);
});

it('return the immutable set of entities', async () => {
Expand Down Expand Up @@ -52,4 +62,52 @@ describe('Search Posts', () => {
);
});
});

it('abort the request when the search is aborted', async () => {
let expectedError: Error;

// @ts-ignore
jest.mocked(fetch).mockImplementation(() =>
Promise.reject(new DOMException('Aborted Request', 'AbortError'))
);

try {
await searchEntities('post', new Set(['post']), 'foo', {
exclude: new Set<string>(['1', '3']),
include: new Set<string>(['2', '5']),
});
} catch (error: any) {
expectedError = error;
}

// @ts-ignore
expect(expectedError.name).toBe('AbortError');
expect(doAction).toHaveBeenCalledWith(
'wp-entities-search.on-search.abort',
// @ts-ignore
expectedError
);
});

it('do not execute aborted action when wrong error type', async () => {
let expectedError: Error;

// @ts-ignore
jest.mocked(fetch).mockImplementation(() =>
Promise.reject(new Error('Aborted Request'))
);

try {
await searchEntities('post', new Set(['post']), 'foo', {
exclude: new Set<string>(['1', '3']),
include: new Set<string>(['2', '5']),
});
} catch (error: any) {
expectedError = error;
}

// @ts-ignore
expect(expectedError.message).toBe('Aborted Request');
expect(doAction).not.toHaveBeenCalled();
});
});
32 changes: 32 additions & 0 deletions tests/client/unit/services/abort-controllers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { it, jest, describe, expect, beforeEach } from '@jest/globals';

import { abortControllers } from '../../../../sources/client/src/services/abort-controllers';
import { ContextualAbortController } from '../../../../sources/client/src/services/contextual-abort-controller';

describe('AbortControllers', () => {
let controller: ContextualAbortController;

beforeEach(() => {
controller = new ContextualAbortController('context', 'reason');
});

it('should add a controller', () => {
abortControllers.add(controller);
expect(abortControllers.has(controller)).toBe(true);
});

it('should delete a controller', () => {
abortControllers.add(controller);
expect(abortControllers.has(controller)).toBe(true);

abortControllers.delete(controller);
expect(abortControllers.has(controller)).toBe(false);
});

it('should abort a controller', () => {
const spy = jest.spyOn(controller, 'abort');
abortControllers.add(controller);
abortControllers.add(controller);
expect(spy).toHaveBeenCalled();
});
});
45 changes: 45 additions & 0 deletions tests/client/unit/services/contextual-abort-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, it, expect } from '@jest/globals';

import { ContextualAbortController } from '../../../../sources/client/src/services/contextual-abort-controller';

describe('ContextualAbortController', () => {
let controller: ContextualAbortController;

beforeEach(() => {
controller = new ContextualAbortController('testContext', 'testReason');
});

afterEach(() => {
controller.abort();
});

it('should create a new instance of ContextualAbortController', () => {
expect(controller).toBeInstanceOf(ContextualAbortController);
});

it('should return the correct context', () => {
expect(controller.context()).toBe('testContext');
});

it('should abort the controller', () => {
controller.abort();
expect(controller.isAborted()).toBe(true);
});

it('should return the correct signal', () => {
const signal = controller.signal();
expect(signal).toBeInstanceOf(AbortSignal);
});

it('should return the correct aborted state', () => {
expect(controller.isAborted()).toBe(false);
controller.abort();
expect(controller.isAborted()).toBe(true);
});

it('should throw an error if the context is empty', () => {
expect(
() => new ContextualAbortController('', 'testReason')
).toThrowError('Abort Controllers, context cannot be empty');
});
});

0 comments on commit 96b9eb0

Please sign in to comment.