Skip to content

Commit

Permalink
Merge b95c7ea into 0dfb39a
Browse files Browse the repository at this point in the history
  • Loading branch information
nathanlb committed Nov 17, 2020
2 parents 0dfb39a + b95c7ea commit 161cf81
Show file tree
Hide file tree
Showing 4 changed files with 355 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/Index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
135 changes: 135 additions & 0 deletions src/components/AugmentedResultList/AugmentedResultList.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
data: T;
}

export interface AugmentedResultListOptions extends IResultListOptions {
matchingIdField: IFieldOption;
fetchAugmentData: (objectIds: String[]) => Promise<IPromiseReturnArgs<IAugmentData>>;
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<IPromiseReturnArgs<IAugmentData>>>(() => {
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<void> {
return super.renderResults(resultElements, append);
}

public async buildResults(queryResults: Coveo.IQueryResults): Promise<HTMLElement[]> {
let remoteResults: IPromiseReturnArgs<IAugmentData>;
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));

// Attach data specific to each result/object
for (const key in match) {
if (key.toLowerCase() !== fieldName && Boolean(res.raw[key.toLowerCase()])) {
this.logger.warn(`The ${key} field was overwritten on result: ${res.title}`);
}
res.raw[key.toLowerCase()] = match[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.');
}

return super.buildResults(queryResults);
}
}

Initialization.registerAutoCreateComponent(AugmentedResultList);
Initialization.registerComponentFields(AugmentedResultList.ID, [String(AugmentedResultList.options.matchingIdField)]);
219 changes: 219 additions & 0 deletions tests/components/AugmentedResultList/AugmentedResultList.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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 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<IPromiseReturnArgs<IAugmentData>>,
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<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 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 warn if an overwrite occured', (done) => {
const numResults = 10;
const data = createFakeResultsThatMatch(numResults);
const test = createComponent(stubFetchAugmentData);
const loggerSpy = sandbox.spy(Logger.prototype, 'warn');

// 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(test.cmp.getDisplayedResults().length).toEqual(numResults);
expect(loggerSpy.calledWith(`The name field was overwritten on result: ${overwrittenResult.title}`)).toBeTrue();
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();
});
});
});
1 change: 0 additions & 1 deletion tests/models/UserProfilingModel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down

0 comments on commit 161cf81

Please sign in to comment.