-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
308 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
129 changes: 129 additions & 0 deletions
129
src/components/AugmentedResultList/AugmentedResultList.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
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: any[]; | ||
/** | ||
* Data to add to every result with matching object id. | ||
*/ | ||
commonData: any; | ||
} | ||
|
||
/** | ||
* Generic interface for the response returned by the external fetch action. | ||
* | ||
* @export | ||
* @interface IPromiseReturnArgs | ||
* @template T | ||
*/ | ||
export interface IPromiseReturnArgs<T> { | ||
data: T; | ||
} | ||
|
||
export interface AugmentedResultListOptions extends IResultListOptions { | ||
matchingIdField: IFieldOption; | ||
fetchAugmentData: (objectIds: String[]) => Promise<IPromiseReturnArgs<IAugmentData>>; | ||
} | ||
|
||
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<IPromiseReturnArgs<IAugmentData>>>(() => { | ||
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); | ||
} | ||
|
||
private getObjectPayload(results: IQueryResult[]): String[] { | ||
const field = this.getFieldString(this.options.matchingIdField); | ||
if (results.length > 0) { | ||
return results.filter((result) => result.raw && result.raw[field]).map((result) => result.raw[field]); | ||
} | ||
return []; | ||
} | ||
|
||
private getFieldString(fieldName: IFieldOption) { | ||
return fieldName.replace('@', ''); | ||
} | ||
|
||
public renderResults(resultElements: HTMLElement[], append = false): Promise<void> { | ||
const res = super.renderResults(resultElements, append); | ||
return res; | ||
} | ||
|
||
public async buildResults(queryResults: Coveo.IQueryResults): Promise<HTMLElement[]> { | ||
const fieldString = this.getFieldString(this.options.matchingIdField); | ||
|
||
if (this.options.fetchAugmentData) { | ||
let remoteResults: IPromiseReturnArgs<IAugmentData>; | ||
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 remote action results with Coveo Results | ||
queryResults.results.forEach((res: Coveo.IQueryResult) => { | ||
const match = remoteResults.data.resultData.find((data) => { | ||
return (data as any)[fieldString] === res.raw[fieldString]; | ||
}); | ||
|
||
// Attach data specific to each result/object | ||
for (const key in match) { | ||
if (Boolean(res.raw[key.toLowerCase()])) { | ||
this.logger.warn(`The ${key} field was overwritten on result: ${res.title}`); | ||
} | ||
res.raw[key.toLowerCase()] = (match as any)[key]; | ||
} | ||
|
||
// Attach data common to all results | ||
for (const key in remoteResults.data.commonData) { | ||
res.raw[key.toLowerCase()] = (remoteResults.data.commonData as any)[key]; | ||
} | ||
}); | ||
} | ||
} else { | ||
this.logger.error('No objectDataAction is defined.'); | ||
} | ||
|
||
const ret = super.buildResults(queryResults); | ||
return ret; | ||
} | ||
} | ||
|
||
Initialization.registerAutoCreateComponent(AugmentedResultList); | ||
Initialization.registerComponentFields(AugmentedResultList.ID, [String(AugmentedResultList.options.matchingIdField)]); |
178 changes: 178 additions & 0 deletions
178
tests/components/AugmentedResultList/AugmentedResultList.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
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<AugmentedResultList>; | ||
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', | ||
}, | ||
]; | ||
|
||
const commonData = { | ||
evolution: 1, | ||
starter: true, | ||
}; | ||
|
||
const returnData: IPromiseReturnArgs<IAugmentData> = { | ||
data: { | ||
resultData, | ||
commonData, | ||
}, | ||
}; | ||
|
||
const matchingIdField = '@id'; | ||
|
||
const stubFetchAugmentData = (objectIds: string[]): Promise<IPromiseReturnArgs<IAugmentData>> => { | ||
return Promise.resolve(returnData); | ||
}; | ||
|
||
const failingFetchStub = (objectIds: string[]): Promise<IPromiseReturnArgs<IAugmentData>> => { | ||
return Promise.reject('purposeful failure'); | ||
}; | ||
|
||
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<IPromiseReturnArgs<IAugmentData>>) => { | ||
element = document.createElement('div'); | ||
document.body.append(element); | ||
testOptions = new Mock.AdvancedComponentSetupOptions(element, { | ||
matchingIdField, | ||
fetchAugmentData, | ||
}); | ||
|
||
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<IAugmentData>, results: IQueryResult[]) => { | ||
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))); | ||
}); | ||
|
||
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(() => { | ||
expect(test.cmp.getDisplayedResults().length).toEqual(numResults); | ||
verifyAugmentedResults(returnData, 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(() => { | ||
expect(test.cmp.getDisplayedResults().length).toEqual(numResults); | ||
verifyAugmentedResults(returnData, test.cmp.getDisplayedResults()); | ||
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', (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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters