diff --git a/src/Index.ts b/src/Index.ts index 52765275..34ba3173 100644 --- a/src/Index.ts +++ b/src/Index.ts @@ -15,3 +15,4 @@ export { ViewedByCustomer } from './components/ViewedByCustomer/ViewedByCustomer export { ResultAction } from './components/ResultAction/ResultAction'; export { CopyToClipboard } from './components/CopyToClipboard/CopyToClipboard'; export { TopQueries } from './components/TopQueries/TopQueries'; +export { AugmentedResultList } from './components/AugmentedResultList/AugmentedResultList'; diff --git a/src/components/AugmentedResultList/AugmentedResultList.ts b/src/components/AugmentedResultList/AugmentedResultList.ts new file mode 100644 index 00000000..5c109203 --- /dev/null +++ b/src/components/AugmentedResultList/AugmentedResultList.ts @@ -0,0 +1,135 @@ +import { ComponentOptions, IComponentBindings, Initialization, IQueryResult, IResultListOptions, IFieldOption } from 'coveo-search-ui'; + +/** + * Interface for the data returned from external fetch action. + * + * @export + * @interface IAugmentData + */ +export interface IAugmentData { + /** + * Data specific to a result with matching object id. + */ + resultData: {}[]; + /** + * Data to add to every result with matching object id. + */ + commonData: {}; +} + +/** + * Generic interface for the response returned by the external fetch action. + * + * @export + * @interface IPromiseReturnArgs + * @template T + */ +export interface IPromiseReturnArgs { + data: T; +} + +export interface AugmentedResultListOptions extends IResultListOptions { + matchingIdField: IFieldOption; + fetchAugmentData: (objectIds: String[]) => Promise>; + matchingFunction: (augmentData: any, queryResult: IQueryResult) => boolean; +} + +export class AugmentedResultList extends Coveo.ResultList implements IComponentBindings { + static ID = 'AugmentedResultList'; + + /** + * @componentOptions + */ + static options: AugmentedResultListOptions = { + /** + * The field to be used as matching ID between augment data and result. + */ + matchingIdField: ComponentOptions.buildFieldOption({ + required: true, + }), + /** + * The function used to fetch extra result information. + */ + fetchAugmentData: ComponentOptions.buildCustomOption<(objectIds: String[]) => Promise>>(() => { + return null; + }), + /** + * The function to use to determine a match between augment data and query results. + */ + matchingFunction: ComponentOptions.buildCustomOption<(augmentData: any, queryResult: IQueryResult) => boolean>(() => { + return null; + }), + }; + + /** + * Creates a new `AugmentedResultList` component. + * @param element The HTMLElement on which to instantiate the component. + * @param options The options for the `ResultList` component. + * @param bindings The bindings that the component requires to function normally. If not set, these will be + * automatically resolved (with a slower execution time). + */ + constructor(public element: HTMLElement, public options: AugmentedResultListOptions, public bindings: IComponentBindings) { + super(element, ComponentOptions.initComponentOptions(element, AugmentedResultList, options), bindings, AugmentedResultList.ID); + this.options.matchingFunction = this.options.matchingFunction ?? this.defaultMatchingFunction; + } + + private defaultMatchingFunction = (augmentData: any, queryResult: IQueryResult) => { + const fieldName = this.getMatchingFieldString(); + return augmentData[fieldName] === queryResult.raw[fieldName]; + }; + + private getObjectPayload(results: IQueryResult[]): String[] { + const field = this.getMatchingFieldString(); + return results.filter((result) => result.raw && result.raw[field]).map((result) => result.raw[field]); + } + + private getMatchingFieldString() { + return this.options.matchingIdField.replace('@', ''); + } + + public renderResults(resultElements: HTMLElement[], append = false): Promise { + return super.renderResults(resultElements, append); + } + + public async buildResults(queryResults: Coveo.IQueryResults): Promise { + let remoteResults: IPromiseReturnArgs; + const fieldName = this.getMatchingFieldString(); + + if (this.options.fetchAugmentData) { + try { + // Call remote action to fetch augmenting data + remoteResults = await this.options.fetchAugmentData(this.getObjectPayload(queryResults.results)); + } catch (e) { + this.logger.error(['Unable to fetch augment data.', e]); + return null; + } + + if (remoteResults?.data) { + // Merge augmenting data with Coveo Results + queryResults.results.forEach((res: Coveo.IQueryResult) => { + const match: any = remoteResults.data.resultData.find((data: any) => this.options.matchingFunction(data, res)); + + // Add data common to all results + for (const key in remoteResults.data.commonData) { + res.raw[key.toLowerCase()] = (remoteResults.data.commonData as any)[key]; + } + + // Add data specific to each result/object + for (const key in match) { + if (key.toLowerCase() !== fieldName && Boolean(res.raw[key.toLowerCase()])) { + this.logger.trace(`The ${key} field was overwritten on result: ${res.title}`); + } + res.raw[key.toLowerCase()] = match[key]; + } + }); + } + } else { + this.logger.error('No objectDataAction is defined.'); + } + + return super.buildResults(queryResults); + } +} + +Initialization.registerAutoCreateComponent(AugmentedResultList); +Initialization.registerComponentFields(AugmentedResultList.ID, [String(AugmentedResultList.options.matchingIdField)]); diff --git a/tests/components/AugmentedResultList/AugmentedResultList.spec.ts b/tests/components/AugmentedResultList/AugmentedResultList.spec.ts new file mode 100644 index 00000000..54a5a4de --- /dev/null +++ b/tests/components/AugmentedResultList/AugmentedResultList.spec.ts @@ -0,0 +1,237 @@ +import * as sinon from 'sinon'; +import { IQueryResult, Logger, SearchEndpoint } from 'coveo-search-ui'; +import { Mock, Fake } from 'coveo-search-ui-tests'; +import { AugmentedResultList, IPromiseReturnArgs, IAugmentData } from '../../../src/components/AugmentedResultList/AugmentedResultList'; + +describe('AugmentedResultList', () => { + let sandbox: sinon.SinonSandbox; + + let componentSetup: Mock.IBasicComponentSetup; + let element: HTMLElement; + let testOptions: Mock.AdvancedComponentSetupOptions; + + const resultData = [ + { + id: '#001', + name: 'bulbasaur', + type: 'grass,poison', + }, + { + id: '#004', + name: 'charmander', + type: 'fire', + }, + { + id: '#007', + name: 'squirtle', + type: 'water', + }, + ]; + + let commonData: any = { + evolution: 1, + starter: true, + }; + + const getReturnData = (): IPromiseReturnArgs => { + return { + data: { + resultData, + commonData, + }, + }; + }; + + const matchingIdField = '@id'; + + const stubFetchAugmentData = (objectIds: string[]): Promise> => { + return Promise.resolve(getReturnData()); + }; + + const failingFetchStub = (objectIds: string[]): Promise> => { + return Promise.reject('purposeful failure'); + }; + + const testMatchingFunction = (augmentData: any, queryResult: IQueryResult) => { + return augmentData.type === 'water' && augmentData.id === queryResult.raw.id; + }; + + const createFakeResultsThatMatch = (numResults: number) => { + const fakeResults = Fake.createFakeResults(numResults); + fakeResults.results.forEach((result, index) => (result.raw.id = `#00${index}`)); + return fakeResults; + }; + + const createComponent = ( + fetchAugmentData?: (objectIds: string[]) => Promise>, + matchingFunction?: (augmentData: any, queryResult: IQueryResult) => boolean + ) => { + element = document.createElement('div'); + document.body.append(element); + testOptions = new Mock.AdvancedComponentSetupOptions(element, { + matchingIdField, + fetchAugmentData, + matchingFunction, + }); + + componentSetup = Mock.advancedComponentSetup(AugmentedResultList, testOptions); + return componentSetup; + }; + + const verifyAugmentedResultData = (resultData: any, results: IQueryResult[]) => { + resultData.forEach((data: any) => { + const matchingResult = results.find((result) => result.raw.id === data.id); + if (matchingResult) { + for (const key in data) { + expect(matchingResult.raw[key]).toEqual(data[key]); + } + } + }); + }; + + const verifyUntouchedResults = (resultData: {}[], results: IQueryResult[]) => { + const idString = matchingIdField.replace('@', ''); + + resultData.forEach((data: any) => { + const ids = resultData.map((data: any) => (data as any)[idString]); + const otherResults = results.filter((result) => !ids.find((id) => (result as any)[idString] === id)); + + otherResults.forEach((result: IQueryResult) => { + for (const key in data) { + if (key !== idString) { + expect(result.raw[key]).toBeUndefined; + } + } + }); + }); + }; + + const verifyAugmentedCommonData = (commonData: any, results: IQueryResult[]) => { + results.forEach((result) => { + for (const key in commonData) { + expect(result.raw[key]).toEqual(commonData[key]); + } + }); + }; + + const verifyAugmentedResults = (returnData: IPromiseReturnArgs, results: IQueryResult[], numResults: number) => { + expect(results.length).toEqual(numResults); + verifyAugmentedResultData(returnData.data.resultData, results); + verifyAugmentedCommonData(returnData.data.commonData, results); + verifyUntouchedResults(returnData.data.resultData, results); + }; + + beforeAll(() => { + Logger.disable(); + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + const endpoint = sandbox.createStubInstance(SearchEndpoint); + endpoint.search.callsFake(() => Promise.resolve(createFakeResultsThatMatch(10))); + commonData = { + evolution: 1, + starter: true, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + afterAll(() => { + Logger.enable(); + }); + + it('should augment results with object data', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + const test = createComponent(stubFetchAugmentData); + + test.cmp.buildResults(data).then(() => { + verifyAugmentedResults(getReturnData(), test.cmp.getDisplayedResults(), numResults); + done(); + }); + }); + + it('should augment results with object data using provided matching function', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + const test = createComponent(stubFetchAugmentData, testMatchingFunction); + + test.cmp.buildResults(data).then(() => { + const displayedResults = test.cmp.getDisplayedResults(); + expect(displayedResults.length).toEqual(numResults); + expect(displayedResults.find((result) => result.raw.id === '#007').raw.name).toEqual('squirtle'); + expect(displayedResults.find((result) => result.raw.id === '#001').raw.name).toBeUndefined(); + expect(displayedResults.find((result) => result.raw.id === '#004').raw.name).toBeUndefined(); + done(); + }); + }); + + it('should augment results with object data and log a trace if an overwrite occured', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + const test = createComponent(stubFetchAugmentData); + const loggerSpy = sandbox.spy(Logger.prototype, 'trace'); + + // Set attribute to be overwritten. + const overwrittenResult = data.results.find((res) => res.raw.id === '#001'); + overwrittenResult.raw.name = 'Mewtwo'; + + test.cmp.buildResults(data).then(() => { + expect(loggerSpy.calledWith(`The name field was overwritten on result: ${overwrittenResult.title}`)).toBeTrue(); + verifyAugmentedResults(getReturnData(), test.cmp.getDisplayedResults(), numResults); + done(); + }); + }); + + it('should augment results with object data over common data in case of overlap', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + commonData.type = 'psychic'; + const test = createComponent(stubFetchAugmentData); + const loggerSpy = sandbox.spy(Logger.prototype, 'trace'); + + test.cmp.buildResults(data).then(() => { + expect(loggerSpy.called).toBeTrue(); + verifyAugmentedResultData(getReturnData().data.resultData, test.cmp.getDisplayedResults()); + done(); + }); + }); + + it("should NOT augment results if IDs don't match", (done) => { + const numResults = 10; + const data = Fake.createFakeResults(numResults); + const test = createComponent(stubFetchAugmentData); + + test.cmp.buildResults(data).then(() => { + verifyAugmentedResults(getReturnData(), test.cmp.getDisplayedResults(), numResults); + done(); + }); + }); + + it('should still build results without augmenting if resultDataAction is missing', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + const test = createComponent(); + + test.cmp.buildResults(data).then(() => { + expect(test.cmp.getDisplayedResults().length).toEqual(numResults); + expect(test.cmp.getDisplayedResults()).toEqual(data.results); + done(); + }); + }); + + it('should fail gracefully and log an error if fetch is unsuccessful', (done) => { + const numResults = 10; + const data = createFakeResultsThatMatch(numResults); + const test = createComponent(failingFetchStub); + const loggerSpy = sandbox.spy(Logger.prototype, 'error'); + + test.cmp.buildResults(data).then(() => { + expect(loggerSpy.calledWith(['Unable to fetch augment data.', 'purposeful failure'])).toBeTrue(); + done(); + }); + }); +}); diff --git a/tests/models/UserProfilingModel.spec.ts b/tests/models/UserProfilingModel.spec.ts index bda9581d..4a3ad92d 100644 --- a/tests/models/UserProfilingModel.spec.ts +++ b/tests/models/UserProfilingModel.spec.ts @@ -172,7 +172,6 @@ describe('UserProfilingModel', () => { ); const actions = await actionsPromise; - console.log(actions); const actionsWithDocument = actions.filter((action) => action.document); const uniqueUriHashes = FAKE_ACTIONS_WITH_URI_HASH.map((x) => x.value.uri_hash).filter((x, i, l) => l.indexOf(x) === i);