From 5a5c1f4b2cd89219cb080ab0cbeca649cb003f41 Mon Sep 17 00:00:00 2001 From: dmbrooke <38883189+dmbrooke@users.noreply.github.com> Date: Tue, 27 Jun 2023 14:15:21 -0400 Subject: [PATCH 01/10] feat(headless): support date range facet exclusion (#2998) * Roughly support excluded facet value state https://coveord.atlassian.net/browse/KIT-2549 * Improve nomenclature https://coveord.atlassian.net/browse/KIT-2549 * Finally figure out how to affect Headless state https://coveord.atlassian.net/browse/KIT-2549 * Add UTs https://coveord.atlassian.net/browse/KIT-2549 * Improve UTs, actually toggle exclude https://coveord.atlassian.net/browse/KIT-2549 * Initial batch of date range exclusion changes https://coveord.atlassian.net/browse/KIT-2553 * Support more actions for excluding https://coveord.atlassian.net/browse/KIT-2549 * Undo accidental changes to date/range facet controllers https://coveord.atlassian.net/browse/KIT-2549 * Headless facet UTs https://coveord.atlassian.net/browse/KIT-2549 * Minor cleanup https://coveord.atlassian.net/browse/KIT-2549 * Very plain support for facet manager https://coveord.atlassian.net/browse/KIT-2549 * Fix possible facet states in another place https://coveord.atlassian.net/browse/KIT-2549 * Apply review comments pt 1 https://coveord.atlassian.net/browse/KIT-2549 * Make props methods more clear in facet search https://coveord.atlassian.net/browse/KIT-2549 * Update coveo.analytics, implement product listing analytics actions https://coveord.atlassian.net/browse/KIT-2549 * Basic improvements to date facet controller actions https://coveord.atlassian.net/browse/KIT-2553 * Further support toggleExclude in range facet https://coveord.atlassian.net/browse/KIT-2553 * Update range facet selectors https://coveord.atlassian.net/browse/KIT-2553 * Another pass on actions, utils, and slice https://coveord.atlassian.net/browse/KIT-2553 * Little bit of cleanup https://coveord.atlassian.net/browse/KIT-2553 * Add isValueExcluded to core range facet https://coveord.atlassian.net/browse/KIT-2553 * Apply Louis' suggestion https://coveord.atlassian.net/browse/KIT-2553 --- .../facets/facet/headless-core-facet.test.ts | 18 +++++ .../headless-core-date-facet.test.ts | 67 ++++++++++++++++++- .../date-facet/headless-core-date-facet.ts | 30 ++++++++- .../range-facet/headless-core-range-facet.ts | 7 +- .../facets/facet/headless-facet.test.ts | 29 ++++++++ .../date-facet/headless-date-facet.ts | 14 +++- .../date-facet-actions-loader.ts | 12 ++++ .../date-facet-set/date-facet-actions.ts | 9 +++ .../date-facet-controller-actions.test.ts | 26 ++++++- .../date-facet-controller-actions.ts | 27 +++++++- .../date-facet-selectors.test.ts | 39 ++++++++++- .../date-facet-set/date-facet-selectors.ts | 17 +++-- .../date-facet-set/date-facet-set-slice.ts | 6 ++ .../range-facet-controller-actions.test.ts | 16 ++++- .../generic/range-facet-controller-actions.ts | 9 +++ .../generic/range-facet-insight-utils.ts | 21 ++++-- .../generic/range-facet-reducers.ts | 22 ++++++ .../range-facets/generic/range-facet-utils.ts | 17 +++++ .../numeric-facet-actions-loader.ts | 12 ++++ .../numeric-facet-actions.ts | 9 +++ .../numeric-facet-controller-actions.test.ts | 28 +++++++- .../numeric-facet-controller-actions.ts | 31 +++++++-- .../numeric-facet-selectors.test.ts | 39 ++++++++++- .../numeric-facet-selectors.ts | 17 +++-- .../numeric-facet-set-slice.test.ts | 14 ++++ .../numeric-facet-set-slice.ts | 6 ++ 26 files changed, 512 insertions(+), 30 deletions(-) diff --git a/packages/headless/src/controllers/core/facets/facet/headless-core-facet.test.ts b/packages/headless/src/controllers/core/facets/facet/headless-core-facet.test.ts index 2e96279dfe3..f7802b0c997 100644 --- a/packages/headless/src/controllers/core/facets/facet/headless-core-facet.test.ts +++ b/packages/headless/src/controllers/core/facets/facet/headless-core-facet.test.ts @@ -168,6 +168,24 @@ describe('facet', () => { }); }); + describe('#toggleExclude', () => { + it('dispatches a #toggleExclude action with the passed facet value', () => { + const facetValue = buildMockFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(engine.actions).toContainEqual( + toggleExcludeFacetValue({facetId, selection: facetValue}) + ); + }); + + it('dispatches #updateFacetOptions with #freezeFacetOrder true', () => { + const facetValue = buildMockFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(engine.actions).toContainEqual(updateFacetOptions()); + }); + }); + function testCommonToggleSingleSelect(facetValue: () => FacetValue) { it('dispatches a #toggleSelect action with the passed facet value', () => { facet.toggleSingleSelect(facetValue()); diff --git a/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.test.ts b/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.test.ts index b492041452e..5f9cce1c31d 100644 --- a/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.test.ts +++ b/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.test.ts @@ -4,6 +4,7 @@ import {facetOptionsReducer as facetOptions} from '../../../../../features/facet import { deselectAllDateFacetValues, registerDateFacet, + toggleExcludeDateFacetValue, toggleSelectDateFacetValue, } from '../../../../../features/facets/range-facets/date-facet-set/date-facet-actions'; import {dateFacetSetReducer as dateFacetSet} from '../../../../../features/facets/range-facets/date-facet-set/date-facet-set-slice'; @@ -102,6 +103,16 @@ describe('date facet', () => { }); }); + describe('#toggleExclude', () => { + it('dispatches a toggleExcludeDateFacetValue with the passed value', () => { + const value = buildMockDateFacetValue(); + dateFacet.toggleExclude(value); + + const action = toggleExcludeDateFacetValue({facetId, selection: value}); + expect(engine.actions).toContainEqual(action); + }); + }); + function testCommonToggleSingleSelect(facetValue: () => DateFacetValue) { it('dispatches a #toggleSelect action with the passed facet value', () => { dateFacet.toggleSingleSelect(facetValue()); @@ -133,13 +144,65 @@ describe('date facet', () => { }); describe('#toggleSingleSelect when the value state is not "idle"', () => { - const facetValue = () => buildMockDateFacetValue({state: 'selected'}); + const selectedFacetValue = () => + buildMockDateFacetValue({state: 'selected'}); + const excludedFacetValue = () => + buildMockDateFacetValue({state: 'excluded'}); - testCommonToggleSingleSelect(facetValue); + testCommonToggleSingleSelect(selectedFacetValue); + testCommonToggleSingleSelect(excludedFacetValue); it('does not dispatch a #deselectAllFacetValues action', () => { + dateFacet.toggleSingleSelect(selectedFacetValue()); + + expect(engine.actions).not.toContainEqual( + deselectAllDateFacetValues(facetId) + ); + }); + }); + + function testCommonToggleExcludeSelect(facetValue: () => DateFacetValue) { + it('dispatches a #toggleExclude action with the passed facet value', () => { + dateFacet.toggleSingleExclude(facetValue()); + + expect(engine.actions).toContainEqual( + toggleExcludeDateFacetValue({facetId, selection: facetValue()}) + ); + }); + + it('dispatches #updateFacetOptions with #freezeFacetOrder true', () => { + dateFacet.toggleSingleExclude(facetValue()); + + expect(engine.actions).toContainEqual(updateFacetOptions()); + }); + } + + describe('#toggleSingleExclude when the value state is "idle"', () => { + const facetValue = () => buildMockDateFacetValue({state: 'idle'}); + + testCommonToggleExcludeSelect(facetValue); + + it('dispatches a #deselectAllFacetValues action', () => { dateFacet.toggleSingleSelect(facetValue()); + expect(engine.actions).toContainEqual( + deselectAllDateFacetValues(facetId) + ); + }); + }); + + describe('#toggleSingleExclude when the value state is not "idle"', () => { + const selectedFacetValue = () => + buildMockDateFacetValue({state: 'selected'}); + const excludedFacetValue = () => + buildMockDateFacetValue({state: 'excluded'}); + + testCommonToggleExcludeSelect(selectedFacetValue); + testCommonToggleExcludeSelect(excludedFacetValue); + + it('does not dispatch a #deselectAllFacetValues action', () => { + dateFacet.toggleSingleSelect(selectedFacetValue()); + expect(engine.actions).not.toContainEqual( deselectAllDateFacetValues(facetId) ); diff --git a/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.ts b/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.ts index 6db810e100c..dfd8b367041 100644 --- a/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.ts +++ b/packages/headless/src/controllers/core/facets/range-facet/date-facet/headless-core-date-facet.ts @@ -6,7 +6,10 @@ import { RegisterDateFacetActionCreatorPayload, registerDateFacet, } from '../../../../../features/facets/range-facets/date-facet-set/date-facet-actions'; -import {executeToggleDateFacetSelect} from '../../../../../features/facets/range-facets/date-facet-set/date-facet-controller-actions'; +import { + executeToggleDateFacetExclude, + executeToggleDateFacetSelect, +} from '../../../../../features/facets/range-facets/date-facet-set/date-facet-controller-actions'; import {dateFacetSetReducer as dateFacetSet} from '../../../../../features/facets/range-facets/date-facet-set/date-facet-set-slice'; import { DateFacetRequest, @@ -90,6 +93,13 @@ export interface DateFacet extends Controller { */ toggleSelect(selection: DateFacetValue): void; + /** + * Toggles exclusion of the specified facet value + * + * @param selection - The facet value to toggle. + */ + toggleExclude(selection: DateFacetValue): void; + /** * Toggles the specified facet value, deselecting others. * @@ -97,6 +107,13 @@ export interface DateFacet extends Controller { */ toggleSingleSelect(selection: DateFacetValue): void; + /** + * Toggles exclusion of the specified facet value, deselecting others. + * + * @param selection - The facet value to toggle. + */ + toggleSingleExclude(selection: DateFacetValue): void; + /** * Enables the facet. I.e., undoes the effects of `disable`. */ @@ -200,6 +217,17 @@ export function buildCoreDateFacet( this.toggleSelect(selection); }, + toggleExclude: (selection: DateFacetValue) => + dispatch(executeToggleDateFacetExclude({facetId, selection})), + + toggleSingleExclude: function (selection: DateFacetValue) { + if (selection.state === 'idle') { + dispatch(deselectAllFacetValues(facetId)); + } + + this.toggleExclude(selection); + }, + get state() { return rangeFacet.state; }, diff --git a/packages/headless/src/controllers/core/facets/range-facet/headless-core-range-facet.ts b/packages/headless/src/controllers/core/facets/range-facet/headless-core-range-facet.ts index 6d9d50c4037..86321a34be5 100644 --- a/packages/headless/src/controllers/core/facets/range-facet/headless-core-range-facet.ts +++ b/packages/headless/src/controllers/core/facets/range-facet/headless-core-range-facet.ts @@ -16,7 +16,10 @@ import { } from '../../../../features/facets/range-facets/generic/interfaces/range-facet'; import {RangeFacetSortCriterion} from '../../../../features/facets/range-facets/generic/interfaces/request'; import {updateRangeFacetSortCriterion} from '../../../../features/facets/range-facets/generic/range-facet-actions'; -import {isRangeFacetValueSelected} from '../../../../features/facets/range-facets/generic/range-facet-utils'; +import { + isRangeFacetValueExcluded, + isRangeFacetValueSelected, +} from '../../../../features/facets/range-facets/generic/range-facet-utils'; import { ConfigurationSection, FacetOptionsSection, @@ -53,6 +56,8 @@ export function buildCoreRangeFacet< isValueSelected: isRangeFacetValueSelected, + isValueExcluded: isRangeFacetValueExcluded, + deselectAll() { dispatch(deselectAllFacetValues(facetId)); dispatch(updateFacetOptions()); diff --git a/packages/headless/src/controllers/facets/facet/headless-facet.test.ts b/packages/headless/src/controllers/facets/facet/headless-facet.test.ts index 684fbdf6c1b..0741503a453 100644 --- a/packages/headless/src/controllers/facets/facet/headless-facet.test.ts +++ b/packages/headless/src/controllers/facets/facet/headless-facet.test.ts @@ -199,6 +199,35 @@ describe('facet', () => { }); }); + describe('#toggleExclude', () => { + it('dispatches a #toggleExclude action with the passed facet value', () => { + const facetValue = buildMockFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(engine.actions).toContainEqual( + toggleExcludeFacetValue({facetId, selection: facetValue}) + ); + }); + + it('dispatches #updateFacetOptions with #freezeFacetOrder true', () => { + const facetValue = buildMockFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(engine.actions).toContainEqual(updateFacetOptions()); + }); + + it('dispatches a search', () => { + const facetValue = buildMockFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(engine.actions).toContainEqual( + expect.objectContaining({ + type: executeSearch.pending.type, + }) + ); + }); + }); + function testCommonToggleSingleSelect(facetValue: () => FacetValue) { it('dispatches a #toggleSelect action with the passed facet value', () => { facet.toggleSingleSelect(facetValue()); diff --git a/packages/headless/src/controllers/facets/range-facet/date-facet/headless-date-facet.ts b/packages/headless/src/controllers/facets/range-facet/date-facet/headless-date-facet.ts index 0abe8e571cd..11527b80edc 100644 --- a/packages/headless/src/controllers/facets/range-facet/date-facet/headless-date-facet.ts +++ b/packages/headless/src/controllers/facets/range-facet/date-facet/headless-date-facet.ts @@ -6,7 +6,10 @@ import { import {DateRangeRequest} from '../../../../features/facets/range-facets/date-facet-set/interfaces/request'; import {DateFacetValue} from '../../../../features/facets/range-facets/date-facet-set/interfaces/response'; import {RangeFacetSortCriterion} from '../../../../features/facets/range-facets/generic/interfaces/request'; -import {getAnalyticsActionForToggleRangeFacetSelect} from '../../../../features/facets/range-facets/generic/range-facet-utils'; +import { + getAnalyticsActionForToggleRangeFacetExclude, + getAnalyticsActionForToggleRangeFacetSelect, +} from '../../../../features/facets/range-facets/generic/range-facet-utils'; import {executeSearch} from '../../../../features/search/search-actions'; import { buildCoreDateFacet, @@ -69,6 +72,15 @@ export function buildDateFacet( ); }, + toggleExclude: (selection: DateFacetValue) => { + coreController.toggleExclude(selection); + dispatch( + executeSearch( + getAnalyticsActionForToggleRangeFacetExclude(getFacetId(), selection) + ) + ); + }, + get state() { return coreController.state; }, diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions-loader.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions-loader.ts index 7a684234e56..55fbc0c8ff8 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions-loader.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions-loader.ts @@ -11,6 +11,7 @@ import { UpdateDateFacetSortCriterionActionCreatorPayload, UpdateDateFacetValuesActionCreatorPayload, updateDateFacetValues, + toggleExcludeDateFacetValue, } from './date-facet-actions'; export type { @@ -52,6 +53,16 @@ export interface DateFacetSetActionCreators { payload: ToggleSelectDateFacetValueActionCreatorPayload ): PayloadAction; + /** + * Toggles exclusion of a date facet value + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + toggleExcludeDateFacetValue( + payload: ToggleSelectDateFacetValueActionCreatorPayload + ): PayloadAction; + /** * Updates the sort criterion of a date facet. * @@ -88,6 +99,7 @@ export function loadDateFacetSetActions( deselectAllDateFacetValues, registerDateFacet, toggleSelectDateFacetValue, + toggleExcludeDateFacetValue, updateDateFacetSortCriterion, updateDateFacetValues, }; diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions.ts index 31eaa34c8cd..49c19015e35 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-actions.ts @@ -181,6 +181,15 @@ export const toggleSelectDateFacetValue = createAction( }) ); +export const toggleExcludeDateFacetValue = createAction( + 'dateFacet/toggleExcludeValue', + (payload: ToggleSelectDateFacetValueActionCreatorPayload) => + validatePayload(payload, { + facetId: facetIdDefinition, + selection: new RecordValue({values: dateFacetValueDefinition}), + }) +); + export interface UpdateDateFacetValuesActionCreatorPayload { /** * The unique identifier of the facet (e.g., `"1"`). diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.test.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.test.ts index 2256d0b7605..1e0a281a0b9 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.test.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.test.ts @@ -1,6 +1,9 @@ import {buildMockSearchAppEngine, MockSearchEngine} from '../../../../test'; import {buildMockDateFacetValue} from '../../../../test/mock-date-facet-value'; -import {executeToggleDateFacetSelect} from './date-facet-controller-actions'; +import { + executeToggleDateFacetExclude, + executeToggleDateFacetSelect, +} from './date-facet-controller-actions'; describe('date facet controller actions', () => { let engine: MockSearchEngine; @@ -30,4 +33,25 @@ describe('date facet controller actions', () => { ]) ); }); + + it('#executeToggleDateFacetExclude dispatches the correct actions', () => { + const selection = buildMockDateFacetValue(); + engine.dispatch(executeToggleDateFacetExclude({facetId, selection})); + + expect(engine.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'dateFacet/toggleExcludeValue', + payload: {facetId, selection}, + }), + expect.objectContaining({ + type: 'rangeFacet/executeToggleExclude', + }), + expect.objectContaining({ + type: 'facetOptions/update', + payload: {freezeFacetOrder: true}, + }), + ]) + ); + }); }); diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.ts index b9cbd6adab6..a1820f121ed 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-controller-actions.ts @@ -7,9 +7,15 @@ import { } from '../../../../state/state-sections'; import {updateFacetOptions} from '../../../facet-options/facet-options-actions'; import {facetIdDefinition} from '../../generic/facet-actions-validation'; -import {executeToggleRangeFacetSelect} from '../generic/range-facet-controller-actions'; +import { + executeToggleRangeFacetExclude, + executeToggleRangeFacetSelect, +} from '../generic/range-facet-controller-actions'; import {dateFacetValueDefinition} from '../generic/range-facet-validate-payload'; -import {toggleSelectDateFacetValue} from './date-facet-actions'; +import { + toggleExcludeDateFacetValue, + toggleSelectDateFacetValue, +} from './date-facet-actions'; import {DateFacetValue} from './interfaces/response'; const definition = { @@ -33,3 +39,20 @@ export const executeToggleDateFacetSelect = createAsyncThunk< dispatch(updateFacetOptions()); } ); + +export const executeToggleDateFacetExclude = createAsyncThunk< + void, + { + facetId: string; + selection: DateFacetValue; + }, + AsyncThunkOptions +>( + 'dateFacet/executeToggleExclude', + (payload, {dispatch, extra: {validatePayload}}) => { + validatePayload(payload, definition); + dispatch(toggleExcludeDateFacetValue(payload)); + dispatch(executeToggleRangeFacetExclude(payload)); + dispatch(updateFacetOptions()); + } +); diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.test.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.test.ts index ae637873725..c88f5cd53a9 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.test.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.test.ts @@ -8,6 +8,7 @@ import {buildMockFacetRequest} from '../../../../test/mock-facet-request'; import {buildMockFacetResponse} from '../../../../test/mock-facet-response'; import {buildMockFacetSlice} from '../../../../test/mock-facet-slice'; import { + dateFacetExcludedValuesSelector, dateFacetResponseSelector, dateFacetSelectedValuesSelector, } from './date-facet-selectors'; @@ -64,16 +65,52 @@ describe('date facet selectors', () => { state: 'selected', }); const mockValue2 = buildMockDateFacetValue({ + state: 'excluded', + }); + const mockValue3 = buildMockDateFacetValue({ state: 'idle', }); state.search.response.facets = [ buildMockDateFacetResponse({ facetId, - values: [mockValue, mockValue2], + values: [mockValue, mockValue2, mockValue3], }), ]; const selectedValues = dateFacetSelectedValuesSelector(state, facetId); expect(selectedValues).toEqual([mockValue]); }); }); + + describe('#dateFacetExcludedValuesSelector', () => { + beforeEach(() => { + state.dateFacetSet[facetId] = buildMockDateFacetSlice({ + request: buildMockDateFacetRequest({facetId}), + }); + }); + + it('#dateFacetExcludedValuesSelector returns an empty array if the facet does not exist', () => { + const selectedValues = dateFacetExcludedValuesSelector(state, facetId); + expect(selectedValues).toEqual([]); + }); + + it('#dateFacetExcludedValuesSelector returns only the excluded values for the provided facetId', () => { + const mockValue = buildMockDateFacetValue({ + state: 'excluded', + }); + const mockValue2 = buildMockDateFacetValue({ + state: 'selected', + }); + const mockValue3 = buildMockDateFacetValue({ + state: 'idle', + }); + state.search.response.facets = [ + buildMockDateFacetResponse({ + facetId, + values: [mockValue, mockValue2, mockValue3], + }), + ]; + const selectedValues = dateFacetExcludedValuesSelector(state, facetId); + expect(selectedValues).toEqual([mockValue]); + }); + }); }); diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.ts index 64213dcbe23..f66e34ac938 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-selectors.ts @@ -29,9 +29,18 @@ export const dateFacetSelectedValuesSelector = ( state: SearchSection & DateFacetSection, facetId: string ): DateFacetValue[] => { - const facetResponse = dateFacetResponseSelector(state, facetId); - if (!facetResponse) { - return []; - } + const facetResponse = dateFacetResponseSelector(state, facetId) || { + values: [], + }; return facetResponse.values.filter((value) => value.state === 'selected'); }; + +export const dateFacetExcludedValuesSelector = ( + state: SearchSection & DateFacetSection, + facetId: string +): DateFacetValue[] => { + const facetResponse = dateFacetResponseSelector(state, facetId) || { + values: [], + }; + return facetResponse.values.filter((value) => value.state === 'excluded'); +}; diff --git a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-set-slice.ts b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-set-slice.ts index a1ca7521ff5..6c582e029df 100644 --- a/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-set-slice.ts +++ b/packages/headless/src/features/facets/range-facets/date-facet-set/date-facet-set-slice.ts @@ -14,6 +14,7 @@ import { defaultRangeFacetOptions, handleRangeFacetSearchParameterRestoration, updateRangeValues, + toggleExcludeRangeValue, } from '../generic/range-facet-reducers'; import { registerDateFacet, @@ -22,6 +23,7 @@ import { updateDateFacetSortCriterion, RegisterDateFacetActionCreatorPayload, updateDateFacetValues, + toggleExcludeDateFacetValue, } from './date-facet-actions'; import { getDateFacetSetInitialState, @@ -51,6 +53,10 @@ export const dateFacetSetReducer = createReducer( const {facetId, selection} = action.payload; toggleSelectRangeValue(state, facetId, selection); }) + .addCase(toggleExcludeDateFacetValue, (state, action) => { + const {facetId, selection} = action.payload; + toggleExcludeRangeValue(state, facetId, selection); + }) .addCase(updateDateFacetValues, (state, action) => { const {facetId, values} = action.payload; updateRangeValues(state, facetId, values); diff --git a/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.test.ts b/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.test.ts index b7910c6b500..c14bf00f827 100644 --- a/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.test.ts +++ b/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.test.ts @@ -1,6 +1,9 @@ import {buildMockSearchAppEngine, MockSearchEngine} from '../../../../test'; import {buildMockNumericFacetValue} from '../../../../test/mock-numeric-facet-value'; -import {executeToggleRangeFacetSelect} from './range-facet-controller-actions'; +import { + executeToggleRangeFacetExclude, + executeToggleRangeFacetSelect, +} from './range-facet-controller-actions'; describe('range facet controller actions', () => { let engine: MockSearchEngine; @@ -20,4 +23,15 @@ describe('range facet controller actions', () => { }), ]); }); + + it('#executeToggleRangeFacetExclude dispatches the correct actions', () => { + const selection = buildMockNumericFacetValue(); + engine.dispatch(executeToggleRangeFacetExclude({facetId, selection})); + + expect(engine.actions).toEqual([ + expect.objectContaining({ + type: 'rangeFacet/executeToggleExclude', + }), + ]); + }); }); diff --git a/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.ts b/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.ts index 1dfae90b6d3..4c5317d8eaa 100644 --- a/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.ts +++ b/packages/headless/src/features/facets/range-facets/generic/range-facet-controller-actions.ts @@ -13,3 +13,12 @@ export const executeToggleRangeFacetSelect = createAction( rangeFacetSelectionPayloadDefinition(payload.selection) ) ); + +export const executeToggleRangeFacetExclude = createAction( + 'rangeFacet/executeToggleExclude', + (payload: RangeFacetSelectionPayload) => + validatePayload( + payload, + rangeFacetSelectionPayloadDefinition(payload.selection) + ) +); diff --git a/packages/headless/src/features/facets/range-facets/generic/range-facet-insight-utils.ts b/packages/headless/src/features/facets/range-facets/generic/range-facet-insight-utils.ts index ac9a389f00d..089e96efe41 100644 --- a/packages/headless/src/features/facets/range-facets/generic/range-facet-insight-utils.ts +++ b/packages/headless/src/features/facets/range-facets/generic/range-facet-insight-utils.ts @@ -1,13 +1,14 @@ +import {logFacetExclude} from '../../facet-set/facet-set-analytics-actions'; import {FacetSelectionChangeMetadata} from '../../facet-set/facet-set-analytics-actions-utils'; import { logFacetDeselect, logFacetSelect, } from '../../facet-set/facet-set-insight-analytics-actions'; import {RangeFacetValue} from './interfaces/range-facet'; - -export const isRangeFacetValueSelected = (selection: RangeFacetValue) => { - return selection.state === 'selected'; -}; +import { + isRangeFacetValueExcluded, + isRangeFacetValueSelected, +} from './range-facet-utils'; export const getInsightAnalyticsActionForToggleRangeFacetSelect = ( facetId: string, @@ -20,3 +21,15 @@ export const getInsightAnalyticsActionForToggleRangeFacetSelect = ( ? logFacetDeselect(payload) : logFacetSelect(payload); }; + +export const getInsightAnalyticsActionForToggleRangeFacetExclude = ( + facetId: string, + selection: RangeFacetValue +) => { + const facetValue = `${selection.start}..${selection.end}`; + const payload: FacetSelectionChangeMetadata = {facetId, facetValue}; + + return isRangeFacetValueExcluded(selection) + ? logFacetDeselect(payload) + : logFacetExclude(payload); +}; diff --git a/packages/headless/src/features/facets/range-facets/generic/range-facet-reducers.ts b/packages/headless/src/features/facets/range-facets/generic/range-facet-reducers.ts index 48b26468944..507542b78d4 100644 --- a/packages/headless/src/features/facets/range-facets/generic/range-facet-reducers.ts +++ b/packages/headless/src/features/facets/range-facets/generic/range-facet-reducers.ts @@ -76,6 +76,28 @@ export function toggleSelectRangeValue< request.preventAutoSelect = true; } +export function toggleExcludeRangeValue< + T extends RangeFacetSlice, + U extends RangeFacetValue +>(state: RangeFacetState, facetId: string, selection: U) { + const request = state[facetId]?.request; + + if (!request) { + return; + } + + const value = findRange(request.currentValues, selection); + + if (!value) { + return; + } + + const isExcluded = value.state === 'excluded'; + value.state = isExcluded ? 'idle' : 'excluded'; + + request.preventAutoSelect = true; +} + export function handleRangeFacetDeselectAll( state: RangeFacetState, facetId: string diff --git a/packages/headless/src/features/facets/range-facets/generic/range-facet-utils.ts b/packages/headless/src/features/facets/range-facets/generic/range-facet-utils.ts index a258bed5f46..ea51f27b1aa 100644 --- a/packages/headless/src/features/facets/range-facets/generic/range-facet-utils.ts +++ b/packages/headless/src/features/facets/range-facets/generic/range-facet-utils.ts @@ -1,5 +1,6 @@ import { logFacetDeselect, + logFacetExclude, logFacetSelect, } from '../../facet-set/facet-set-analytics-actions'; import {FacetSelectionChangeMetadata} from '../../facet-set/facet-set-analytics-actions-utils'; @@ -9,6 +10,10 @@ export const isRangeFacetValueSelected = (selection: RangeFacetValue) => { return selection.state === 'selected'; }; +export const isRangeFacetValueExcluded = (selection: RangeFacetValue) => { + return selection.state === 'excluded'; +}; + export const getAnalyticsActionForToggleRangeFacetSelect = ( facetId: string, selection: RangeFacetValue @@ -20,3 +25,15 @@ export const getAnalyticsActionForToggleRangeFacetSelect = ( ? logFacetDeselect(payload) : logFacetSelect(payload); }; + +export const getAnalyticsActionForToggleRangeFacetExclude = ( + facetId: string, + selection: RangeFacetValue +) => { + const facetValue = `${selection.start}..${selection.end}`; + const payload: FacetSelectionChangeMetadata = {facetId, facetValue}; + + return isRangeFacetValueExcluded(selection) + ? logFacetDeselect(payload) + : logFacetExclude(payload); +}; diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions-loader.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions-loader.ts index c8de33048fc..d3312f5a750 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions-loader.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions-loader.ts @@ -6,6 +6,7 @@ import { registerNumericFacet, RegisterNumericFacetActionCreatorPayload, toggleSelectNumericFacetValue, + toggleExcludeNumericFacetValue, ToggleSelectNumericFacetValueActionCreatorPayload, updateNumericFacetSortCriterion, UpdateNumericFacetSortCriterionActionCreatorPayload, @@ -52,6 +53,16 @@ export interface NumericFacetSetActionCreators { payload: ToggleSelectNumericFacetValueActionCreatorPayload ): PayloadAction; + /** + * Toggles exclusion of a numeric facet value. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + toggleExcludeNumericFacetValue( + payload: ToggleSelectNumericFacetValueActionCreatorPayload + ): PayloadAction; + /** * Updates the sort criterion of a numeric facet. * @@ -88,6 +99,7 @@ export function loadNumericFacetSetActions( deselectAllNumericFacetValues, registerNumericFacet, toggleSelectNumericFacetValue, + toggleExcludeNumericFacetValue, updateNumericFacetSortCriterion, updateNumericFacetValues, }; diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions.ts index 761b94cede7..12951b90d45 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-actions.ts @@ -166,6 +166,15 @@ export const toggleSelectNumericFacetValue = createAction( }) ); +export const toggleExcludeNumericFacetValue = createAction( + 'numericFacet/toggleExcludeValue', + (payload: ToggleSelectNumericFacetValueActionCreatorPayload) => + validatePayload(payload, { + facetId: facetIdDefinition, + selection: new RecordValue({values: numericFacetValueDefinition}), + }) +); + export interface UpdateNumericFacetValuesActionCreatorPayload { /** * The unique identifier of the facet (e.g., `"1"`). diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.test.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.test.ts index 5c9adee135c..fea2f5ba52b 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.test.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.test.ts @@ -1,6 +1,9 @@ import {buildMockSearchAppEngine, MockSearchEngine} from '../../../../test'; import {buildMockNumericFacetValue} from '../../../../test/mock-numeric-facet-value'; -import {executeToggleNumericFacetSelect} from './numeric-facet-controller-actions'; +import { + executeToggleNumericFacetSelect, + executeToggleNumericFacetExclude, +} from './numeric-facet-controller-actions'; describe('numeric facet controller actions', () => { let engine: MockSearchEngine; @@ -10,7 +13,7 @@ describe('numeric facet controller actions', () => { engine = buildMockSearchAppEngine(); }); - it('#executeToggleNumericFacet dispatches the correct actions', () => { + it('#executeToggleNumericFacetSelect dispatches the correct actions', () => { const selection = buildMockNumericFacetValue(); engine.dispatch(executeToggleNumericFacetSelect({facetId, selection})); @@ -30,4 +33,25 @@ describe('numeric facet controller actions', () => { ]) ); }); + + it('#executeToggleNumericFacetExclude dispatches the correct actions', () => { + const selection = buildMockNumericFacetValue(); + engine.dispatch(executeToggleNumericFacetExclude({facetId, selection})); + + expect(engine.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'numericFacet/toggleExcludeValue', + payload: {facetId, selection}, + }), + expect.objectContaining({ + type: 'rangeFacet/executeToggleExclude', + }), + expect.objectContaining({ + type: 'facetOptions/update', + payload: {freezeFacetOrder: true}, + }), + ]) + ); + }); }); diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.ts index 5072e025605..c6cb6a7e1f3 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-controller-actions.ts @@ -7,18 +7,22 @@ import { } from '../../../../state/state-sections'; import {updateFacetOptions} from '../../../facet-options/facet-options-actions'; import {facetIdDefinition} from '../../generic/facet-actions-validation'; -import {executeToggleRangeFacetSelect} from '../generic/range-facet-controller-actions'; +import { + executeToggleRangeFacetExclude, + executeToggleRangeFacetSelect, +} from '../generic/range-facet-controller-actions'; import {numericFacetValueDefinition} from '../generic/range-facet-validate-payload'; import {NumericFacetValue} from './interfaces/response'; -import {toggleSelectNumericFacetValue} from './numeric-facet-actions'; +import { + toggleExcludeNumericFacetValue, + toggleSelectNumericFacetValue, +} from './numeric-facet-actions'; const definition = { facetId: facetIdDefinition, selection: new RecordValue({values: numericFacetValueDefinition}), }; -const executeToggleNumericFacetSelectType = 'numericFacet/executeToggleSelect'; - export const executeToggleNumericFacetSelect = createAsyncThunk< void, { @@ -27,7 +31,7 @@ export const executeToggleNumericFacetSelect = createAsyncThunk< }, AsyncThunkOptions >( - executeToggleNumericFacetSelectType, + 'numericFacet/executeToggleSelect', (payload, {dispatch, extra: {validatePayload}}) => { validatePayload(payload, definition); dispatch(toggleSelectNumericFacetValue(payload)); @@ -35,3 +39,20 @@ export const executeToggleNumericFacetSelect = createAsyncThunk< dispatch(updateFacetOptions()); } ); + +export const executeToggleNumericFacetExclude = createAsyncThunk< + void, + { + facetId: string; + selection: NumericFacetValue; + }, + AsyncThunkOptions +>( + 'numericFacet/executeToggleExclude', + (payload, {dispatch, extra: {validatePayload}}) => { + validatePayload(payload, definition); + dispatch(toggleExcludeNumericFacetValue(payload)); + dispatch(executeToggleRangeFacetExclude(payload)); + dispatch(updateFacetOptions()); + } +); diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.test.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.test.ts index 9dc38d07990..e0ae72eb9bc 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.test.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.test.ts @@ -8,6 +8,7 @@ import {buildMockNumericFacetResponse} from '../../../../test/mock-numeric-facet import {buildMockNumericFacetSlice} from '../../../../test/mock-numeric-facet-slice'; import {buildMockNumericFacetValue} from '../../../../test/mock-numeric-facet-value'; import { + numericFacetExcludedValuesSelector, numericFacetResponseSelector, numericFacetSelectedValuesSelector, } from './numeric-facet-selectors'; @@ -63,16 +64,52 @@ describe('numeric facet selectors', () => { state: 'selected', }); const mockValue2 = buildMockNumericFacetValue({ + state: 'excluded', + }); + const mockValue3 = buildMockNumericFacetValue({ state: 'idle', }); state.search.response.facets = [ buildMockNumericFacetResponse({ facetId, - values: [mockValue, mockValue2], + values: [mockValue, mockValue2, mockValue3], }), ]; const selectedValues = numericFacetSelectedValuesSelector(state, facetId); expect(selectedValues).toEqual([mockValue]); }); }); + + describe('#numericFacetExcludedValuesSelector', () => { + beforeEach(() => { + state.numericFacetSet[facetId] = buildMockNumericFacetSlice({ + request: buildMockNumericFacetRequest({facetId}), + }); + }); + + it('#numericFacetExcludedValuesSelector returns an empty array if the facet does not exist', () => { + const selectedValues = numericFacetExcludedValuesSelector(state, facetId); + expect(selectedValues).toEqual([]); + }); + + it('#numericFacetExcludedValuesSelector returns only the excluded values for the provided facetId', () => { + const mockValue = buildMockNumericFacetValue({ + state: 'excluded', + }); + const mockValue2 = buildMockNumericFacetValue({ + state: 'selected', + }); + const mockValue3 = buildMockNumericFacetValue({ + state: 'idle', + }); + state.search.response.facets = [ + buildMockNumericFacetResponse({ + facetId, + values: [mockValue, mockValue2, mockValue3], + }), + ]; + const selectedValues = numericFacetExcludedValuesSelector(state, facetId); + expect(selectedValues).toEqual([mockValue]); + }); + }); }); diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.ts index 4c2058ddbfa..8f689e691ba 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-selectors.ts @@ -29,9 +29,18 @@ export const numericFacetSelectedValuesSelector = ( state: SearchSection & NumericFacetSection, facetId: string ): NumericFacetValue[] => { - const facetResponse = numericFacetResponseSelector(state, facetId); - if (!facetResponse) { - return []; - } + const facetResponse = numericFacetResponseSelector(state, facetId) || { + values: [], + }; return facetResponse.values.filter((value) => value.state === 'selected'); }; + +export const numericFacetExcludedValuesSelector = ( + state: SearchSection & NumericFacetSection, + facetId: string +): NumericFacetValue[] => { + const facetResponse = numericFacetResponseSelector(state, facetId) || { + values: [], + }; + return facetResponse.values.filter((value) => value.state === 'excluded'); +}; diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.test.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.test.ts index 3e86f0af609..e128d470d62 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.test.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.test.ts @@ -18,6 +18,7 @@ import { deselectAllNumericFacetValues, RegisterNumericFacetActionCreatorPayload, updateNumericFacetValues, + toggleExcludeNumericFacetValue, } from './numeric-facet-actions'; import {numericFacetSetReducer} from './numeric-facet-set-slice'; import { @@ -115,6 +116,19 @@ describe('numeric-facet-set slice', () => { expect(RangeFacetReducers.toggleSelectRangeValue).toHaveBeenCalledTimes(1); }); + it('#toggleExcludeNumericFacetValue calls #toggleExcludeRangeValue', () => { + const facetId = '1'; + const selection = buildMockNumericFacetValue(); + jest.spyOn(RangeFacetReducers, 'toggleExcludeRangeValue'); + + numericFacetSetReducer( + state, + toggleExcludeNumericFacetValue({facetId, selection}) + ); + + expect(RangeFacetReducers.toggleExcludeRangeValue).toHaveBeenCalledTimes(1); + }); + it('#deselectAllNumericFacetValues calls #handleRangeFacetDeselectAll', () => { jest.spyOn(RangeFacetReducers, 'handleRangeFacetDeselectAll'); const action = deselectAllNumericFacetValues('1'); diff --git a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.ts b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.ts index dfd8ab14bdf..6dba9bc06bb 100644 --- a/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.ts +++ b/packages/headless/src/features/facets/range-facets/numeric-facet-set/numeric-facet-set-slice.ts @@ -14,6 +14,7 @@ import { defaultRangeFacetOptions, handleRangeFacetSearchParameterRestoration, updateRangeValues, + toggleExcludeRangeValue, } from '../generic/range-facet-reducers'; import {NumericFacetRequest, NumericRangeRequest} from './interfaces/request'; import {NumericFacetResponse, NumericFacetValue} from './interfaces/response'; @@ -24,6 +25,7 @@ import { updateNumericFacetSortCriterion, RegisterNumericFacetActionCreatorPayload, updateNumericFacetValues, + toggleExcludeNumericFacetValue, } from './numeric-facet-actions'; import { getNumericFacetSetInitialState, @@ -51,6 +53,10 @@ export const numericFacetSetReducer = createReducer( const {facetId, selection} = action.payload; toggleSelectRangeValue(state, facetId, selection); }) + .addCase(toggleExcludeNumericFacetValue, (state, action) => { + const {facetId, selection} = action.payload; + toggleExcludeRangeValue(state, facetId, selection); + }) .addCase(updateNumericFacetValues, (state, action) => { const {facetId, values} = action.payload; updateRangeValues(state, facetId, values); From 9e7c023a07f46be07cd00326bf3efc23b14e6218 Mon Sep 17 00:00:00 2001 From: Nathan Lafrance-Berger Date: Tue, 27 Jun 2023 14:58:24 -0400 Subject: [PATCH 02/10] feat(headless): added generated answer (Gen-Q&A) component (#2995) * feat(headless): added generative answer feature * feat(headless): added slice tests * feat(headless): added client * feat(headless): subscribe * feat(headless): temp retry logic * feat(headless): client retry * feat(headless): exports * feat(headless): added error codes * feat(headless): actually building a controller lol * feat(headless): id and error handling tweaks * feat(headless): added stream id to search event custom data * feat(headless): complete event might contain delta * fix(headless): started adding payload schemas * feat(headless): started adding analytics actions * feat(headless): citatio interface export * feat(headless): punctuation * feat(headless): new citations schema * feat(headless): removed score * feat(headless): removed oncomplete * feat(headless): added retryable state flag * feat(headless): tests for setIsLoading * feat(headless): added more controller tests * feat(headless): removed unused analytics util * feat(headless): moved handlers to asyncthunk * feat(headless): string literal * feat(headless): added like and dislike to the state * feat(headless): error payload type * feat(headless): renamed and added citation seletor * feat(headless): switched libs * feat(headless): reset error state * feat(headless): controller tests * feat(headless): changed handlers for action injection * feat(headless): onpopen handler * feat(headless): added arguments to fatalerror * feat(headless): pr feedback * feat(headless): changed client callbacks * feat(headless): switch it up * feat(headless): changed accept header * feat(headless): setting error before throw from onerror * feat(headless): wrong error --- .cspell.json | 3 +- package-lock.json | 6 + packages/headless/package.json | 1 + .../src/api/analytics/search-analytics.ts | 15 ++ .../generated-answer-client.ts | 158 ++++++++++++ .../generated-answer-event-payload.ts | 29 +++ .../generated-answer-request.ts | 7 + .../src/api/search/search/extended-results.ts | 3 + .../src/api/search/search/search-response.ts | 2 + .../src/app/search-engine/search-engine.ts | 9 + .../src/app/search-thunk-extra-arguments.ts | 6 +- .../headless/src/app/thunk-extra-arguments.ts | 5 +- .../headless-generated-answer.test.ts | 133 ++++++++++ .../headless-generated-answer.ts | 139 +++++++++++ packages/headless/src/controllers/index.ts | 7 + .../generated-answer-actions-loader.ts | 32 +++ .../generated-answer-actions.test.ts | 111 +++++++++ .../generated-answer-actions.ts | 148 +++++++++++ .../generated-answer-analytics-actions.ts | 70 ++++++ .../generated-answer-selectors.ts | 17 ++ .../generated-answer-slice.test.ts | 229 ++++++++++++++++++ .../generated-answer-slice.ts | 54 +++++ .../generated-answer-state.ts | 23 ++ .../generated-awswer-request.ts | 19 ++ packages/headless/src/features/index.ts | 1 + .../src/features/search/search-slice.ts | 1 + .../src/features/search/search-state.ts | 7 + .../headless/src/state/search-app-state.ts | 4 +- packages/headless/src/state/state-sections.ts | 8 + packages/headless/src/test/mock-citation.ts | 21 ++ .../headless/src/test/mock-search-response.ts | 1 + .../headless/src/test/mock-search-state.ts | 1 + packages/headless/src/test/mock-state.ts | 2 + packages/headless/src/utils/utils.ts | 10 + 34 files changed, 1278 insertions(+), 4 deletions(-) create mode 100644 packages/headless/src/api/generated-answer/generated-answer-client.ts create mode 100644 packages/headless/src/api/generated-answer/generated-answer-event-payload.ts create mode 100644 packages/headless/src/api/generated-answer/generated-answer-request.ts create mode 100644 packages/headless/src/api/search/search/extended-results.ts create mode 100644 packages/headless/src/controllers/generated-answer/headless-generated-answer.test.ts create mode 100644 packages/headless/src/controllers/generated-answer/headless-generated-answer.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-actions-loader.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-actions.test.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-actions.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-selectors.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-slice.test.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-slice.ts create mode 100644 packages/headless/src/features/generated-answer/generated-answer-state.ts create mode 100644 packages/headless/src/features/generated-answer/generated-awswer-request.ts create mode 100644 packages/headless/src/test/mock-citation.ts diff --git a/.cspell.json b/.cspell.json index 2fd6a01ded8..0607990b414 100644 --- a/.cspell.json +++ b/.cspell.json @@ -257,6 +257,7 @@ "ytlikecount", "ytvideoduration", "ytvideoid", - "ytviewcount" + "ytviewcount", + "genqa" ] } diff --git a/package-lock.json b/package-lock.json index ce55041bdde..75a5ff26196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5120,6 +5120,11 @@ "node": ">=4.2.0" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@microsoft/tsdoc": { "version": "0.14.1", "dev": true, @@ -51948,6 +51953,7 @@ "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", + "@microsoft/fetch-event-source": "2.0.1", "@reduxjs/toolkit": "1.8.5", "@types/pino": "6.3.12", "@types/redux-mock-store": "1.0.3", diff --git a/packages/headless/package.json b/packages/headless/package.json index 4870ff4de05..2b5e0f12219 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@coveo/bueno": "0.43.7", + "@microsoft/fetch-event-source": "2.0.1", "@reduxjs/toolkit": "1.8.5", "@types/pino": "6.3.12", "@types/redux-mock-store": "1.0.3", diff --git a/packages/headless/src/api/analytics/search-analytics.ts b/packages/headless/src/api/analytics/search-analytics.ts index 4fa71db38e7..739a663fd89 100644 --- a/packages/headless/src/api/analytics/search-analytics.ts +++ b/packages/headless/src/api/analytics/search-analytics.ts @@ -75,6 +75,21 @@ export class SearchAnalyticsProvider return hasSplitTestRun ? effectivePipelineWithSplitTestRun : undefined; } + public getBaseMetadata() { + const state = this.getState(); + const baseObject = super.getBaseMetadata(); + + const generativeQuestionAnsweringId = + state.search?.response?.extendedResults?.generativeQuestionAnsweringId; + + if (generativeQuestionAnsweringId) { + baseObject['generativeQuestionAnsweringId'] = + generativeQuestionAnsweringId; + } + + return baseObject; + } + private mapResultsToAnalyticsDocument() { return this.state.search?.response.results.map((r) => ({ documentUri: r.uri, diff --git a/packages/headless/src/api/generated-answer/generated-answer-client.ts b/packages/headless/src/api/generated-answer/generated-answer-client.ts new file mode 100644 index 00000000000..57684df7685 --- /dev/null +++ b/packages/headless/src/api/generated-answer/generated-answer-client.ts @@ -0,0 +1,158 @@ +import {fetchEventSource} from '@microsoft/fetch-event-source'; +import {Logger} from 'pino'; +import {SearchAppState} from '../..'; +import {AsyncThunkOptions} from '../../app/async-thunk-options'; +import {ClientThunkExtraArguments} from '../../app/thunk-extra-arguments'; +import {GeneratedAnswerErrorPayload} from '../../features/generated-answer/generated-answer-actions'; +import {URLPath} from '../../utils/url-utils'; +import {resetTimeout} from '../../utils/utils'; +import {SearchAPIClient} from '../search/search-api-client'; +import {GeneratedAnswerStreamEventData} from './generated-answer-event-payload'; +import {GeneratedAnswerStreamRequest} from './generated-answer-request'; + +export interface GeneratedAnswerAPIClientOptions { + logger: Logger; +} + +export interface AsyncThunkGeneratedAnswerOptions< + T extends Partial +> extends AsyncThunkOptions< + T, + ClientThunkExtraArguments + > {} + +const buildStreamingUrl = (url: string, orgId: string, streamId: string) => + new URLPath( + `${url}/rest/organizations/${orgId}/machinelearning/streaming/${streamId}` + ).href; + +const MAX_RETRIES = 3; +const MAX_TIMEOUT = 5000; +const EVENT_STREAM_CONTENT_TYPE = 'text/event-stream'; +export const RETRYABLE_STREAM_ERROR_CODE = 1; + +class RetryableError extends Error {} +class FatalError extends Error { + constructor(public payload: GeneratedAnswerErrorPayload) { + super(payload.message); + } +} + +interface StreamCallbacks { + write: (data: GeneratedAnswerStreamEventData) => void; + abort: ( + error: GeneratedAnswerErrorPayload, + abortController: AbortController + ) => void; + setIsLoading: (isLoading: boolean) => void; + resetAnswer: () => void; +} + +export class GeneratedAnswerAPIClient { + private logger: Logger; + + constructor(options: GeneratedAnswerAPIClientOptions) { + this.logger = options.logger; + } + + streamGeneratedAnswer( + params: GeneratedAnswerStreamRequest, + callbacks: StreamCallbacks + ) { + const {url, organizationId, streamId, accessToken} = params; + const {write, abort, setIsLoading, resetAnswer} = callbacks; + + if (!streamId) { + this.logger.error('No stream ID found'); + return; + } + + let retryCount = 0; + let timeout: ReturnType | undefined; + + const retryStream = () => { + abortController?.abort(); + resetAnswer(); + stream(); + }; + + const refreshTimeout = () => { + timeout = resetTimeout(retryStream, timeout, MAX_TIMEOUT); + }; + + const abortController = new AbortController(); + + const stream = () => + fetchEventSource(buildStreamingUrl(url, organizationId, streamId), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + accept: '*/*', + }, + signal: abortController.signal, + async onopen(response) { + if ( + response.ok && + response.headers.get('content-type') === EVENT_STREAM_CONTENT_TYPE + ) { + return; + } + const isClientSideError = + response.status >= 400 && + response.status < 500 && + response.status !== 429; + if (isClientSideError) { + throw new FatalError({ + message: 'Error opening stream', + code: response.status, + }); + } else { + throw new RetryableError(); + } + }, + onmessage: (event) => { + setIsLoading(false); + const data: GeneratedAnswerStreamEventData = JSON.parse(event.data); + if (data.finishReason === 'ERROR') { + clearTimeout(timeout); + abort( + { + message: data.errorMessage, + code: data.errorCode, + }, + abortController + ); + return; + } else if (data.finishReason === 'COMPLETED') { + clearTimeout(timeout); + } else { + refreshTimeout(); + } + write(data); + retryCount = 0; + }, + onerror: (err) => { + clearTimeout(timeout); + if (err instanceof FatalError) { + abort(err, abortController); + throw err; + } + if (++retryCount > MAX_RETRIES) { + this.logger.info('Maximum retry exceeded.'); + const error = { + message: 'Failed to complete stream.', + code: RETRYABLE_STREAM_ERROR_CODE, + }; + abort(error, abortController); + throw new FatalError(error); + } + this.logger.info(`Retrying...(${retryCount}/${MAX_RETRIES})`); + resetAnswer(); + }, + }); + + stream(); + + return abortController; + } +} diff --git a/packages/headless/src/api/generated-answer/generated-answer-event-payload.ts b/packages/headless/src/api/generated-answer/generated-answer-event-payload.ts new file mode 100644 index 00000000000..1dc1b69fe24 --- /dev/null +++ b/packages/headless/src/api/generated-answer/generated-answer-event-payload.ts @@ -0,0 +1,29 @@ +export type GeneratedAnswerStreamFinishReason = 'COMPLETED' | 'ERROR'; + +export type GeneratedAnswerPayloadType = + | 'genqa.messageType' + | 'genqa.citationsType'; + +export interface GeneratedAnswerCitation { + id: string; + title: string; + uri: string; + permanentid: string; + clickUri?: string; +} + +export interface GeneratedAnswerMessagePayload { + textDelta: string; +} + +export interface GeneratedAnswerCitationsPayload { + citations: GeneratedAnswerCitation[]; +} + +export interface GeneratedAnswerStreamEventData { + payloadType?: GeneratedAnswerPayloadType; + payload: string; + finishReason?: GeneratedAnswerStreamFinishReason; + errorMessage?: string; + errorCode?: number; +} diff --git a/packages/headless/src/api/generated-answer/generated-answer-request.ts b/packages/headless/src/api/generated-answer/generated-answer-request.ts new file mode 100644 index 00000000000..c7746463de1 --- /dev/null +++ b/packages/headless/src/api/generated-answer/generated-answer-request.ts @@ -0,0 +1,7 @@ +import {BaseParam} from '../platform-service-params'; + +interface GeneratedAnswerParam { + streamId?: string; +} + +export type GeneratedAnswerStreamRequest = BaseParam & GeneratedAnswerParam; diff --git a/packages/headless/src/api/search/search/extended-results.ts b/packages/headless/src/api/search/search/extended-results.ts new file mode 100644 index 00000000000..4ee613867a0 --- /dev/null +++ b/packages/headless/src/api/search/search/extended-results.ts @@ -0,0 +1,3 @@ +export interface ExtendedResults { + generativeQuestionAnsweringId?: string; +} diff --git a/packages/headless/src/api/search/search/search-response.ts b/packages/headless/src/api/search/search/search-response.ts index d3240e31a5e..b2541f2d683 100644 --- a/packages/headless/src/api/search/search/search-response.ts +++ b/packages/headless/src/api/search/search/search-response.ts @@ -6,6 +6,7 @@ import { import {Trigger} from './../trigger'; import {AutomaticFacets} from './automatic-facets'; import {ExecutionReport} from './execution-report'; +import {ExtendedResults} from './extended-results'; import {QueryCorrection} from './query-corrections'; import {QueryRankingExpression} from './query-ranking-expression'; import {QuestionsAnswers} from './question-answering'; @@ -26,6 +27,7 @@ export interface SearchResponseSuccess { questionAnswer: QuestionsAnswers; pipeline: string; splitTestRun: string; + extendedResults: ExtendedResults; } export interface SearchResponseSuccessWithDebugInfo diff --git a/packages/headless/src/app/search-engine/search-engine.ts b/packages/headless/src/app/search-engine/search-engine.ts index 455916d702e..2395147fadf 100644 --- a/packages/headless/src/app/search-engine/search-engine.ts +++ b/packages/headless/src/app/search-engine/search-engine.ts @@ -1,5 +1,6 @@ import {StateFromReducersMapObject} from '@reduxjs/toolkit'; import {Logger} from 'pino'; +import {GeneratedAnswerAPIClient} from '../../api/generated-answer/generated-answer-client'; import {NoopPreprocessRequest} from '../../api/preprocess-request'; import {SearchAPIClient} from '../../api/search/search-api-client'; import { @@ -109,10 +110,12 @@ export function buildSearchEngine(options: SearchEngineOptions): SearchEngine { validateConfiguration(options.configuration, logger); const searchAPIClient = createSearchAPIClient(options.configuration, logger); + const generatedAnswerClient = createGeneratedAnswerAPIClient(logger); const thunkArguments: SearchThunkExtraArguments = { ...buildThunkExtraArguments(options.configuration, logger), apiClient: searchAPIClient, + streamingClient: generatedAnswerClient, }; const augmentedOptions: EngineOptions = { @@ -192,3 +195,9 @@ function createSearchAPIClient( NoopPostprocessQuerySuggestResponseMiddleware, }); } + +function createGeneratedAnswerAPIClient(logger: Logger) { + return new GeneratedAnswerAPIClient({ + logger, + }); +} diff --git a/packages/headless/src/app/search-thunk-extra-arguments.ts b/packages/headless/src/app/search-thunk-extra-arguments.ts index a2c6c2fce96..487a149486f 100644 --- a/packages/headless/src/app/search-thunk-extra-arguments.ts +++ b/packages/headless/src/app/search-thunk-extra-arguments.ts @@ -1,5 +1,9 @@ +import {GeneratedAnswerAPIClient} from '../api/generated-answer/generated-answer-client'; import {SearchAPIClient} from '../api/search/search-api-client'; import {ClientThunkExtraArguments} from './thunk-extra-arguments'; export interface SearchThunkExtraArguments - extends ClientThunkExtraArguments {} + extends ClientThunkExtraArguments< + SearchAPIClient, + GeneratedAnswerAPIClient + > {} diff --git a/packages/headless/src/app/thunk-extra-arguments.ts b/packages/headless/src/app/thunk-extra-arguments.ts index 08a228b2a34..df9e09ff4b7 100644 --- a/packages/headless/src/app/thunk-extra-arguments.ts +++ b/packages/headless/src/app/thunk-extra-arguments.ts @@ -1,12 +1,15 @@ import {AnalyticsClientSendEventHook} from 'coveo.analytics'; import {Logger} from 'pino'; +import {GeneratedAnswerAPIClient} from '../api/generated-answer/generated-answer-client'; import {PreprocessRequest} from '../api/preprocess-request'; import {NoopPreprocessRequest} from '../api/preprocess-request'; import {validatePayloadAndThrow} from '../utils/validate-payload'; import {EngineConfiguration} from './engine-configuration'; -export interface ClientThunkExtraArguments extends ThunkExtraArguments { +export interface ClientThunkExtraArguments + extends ThunkExtraArguments { apiClient: T; + streamingClient?: K; } export interface ThunkExtraArguments { diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.test.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.test.ts new file mode 100644 index 00000000000..316c0c25057 --- /dev/null +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.test.ts @@ -0,0 +1,133 @@ +import { + dislikeGeneratedAnswer, + likeGeneratedAnswer, + resetAnswer, + streamAnswer, +} from '../../features/generated-answer/generated-answer-actions'; +import { + logDislikeGeneratedAnswer, + logLikeGeneratedAnswer, + logOpenGeneratedAnswerSource, +} from '../../features/generated-answer/generated-answer-analytics-actions'; +import {generatedAnswerReducer} from '../../features/generated-answer/generated-answer-slice'; +import {getGeneratedAnswerInitialState} from '../../features/generated-answer/generated-answer-state'; +import {executeSearch} from '../../features/search/search-actions'; +import {buildMockCitation} from '../../test/mock-citation'; +import { + buildMockSearchAppEngine, + MockSearchEngine, +} from '../../test/mock-engine'; +import { + buildGeneratedAnswer, + GeneratedAnswer, +} from './headless-generated-answer'; + +describe('generated answer', () => { + let generatedAnswer: GeneratedAnswer; + let engine: MockSearchEngine; + + function initGeneratedAnswer() { + generatedAnswer = buildGeneratedAnswer(engine); + } + + function findAction(actionType: string) { + return engine.actions.find((a) => a.type === actionType); + } + + beforeEach(() => { + engine = buildMockSearchAppEngine(); + initGeneratedAnswer(); + }); + + it('it adds the correct reducers to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({ + generatedAnswer: generatedAnswerReducer, + }); + }); + + it('should return the state', () => { + expect(generatedAnswer.state).toEqual(getGeneratedAnswerInitialState()); + }); + + it('should subscribe to state updates', () => { + expect(engine.subscribe).toHaveBeenCalledTimes(1); + }); + + it('#retry dispatches #executeSearch', () => { + generatedAnswer.retry(); + const action = engine.findAsyncAction(executeSearch.pending); + + expect(action).toBeTruthy(); + }); + + it('#like dispatches analytics action', () => { + generatedAnswer.like(); + const action = findAction(likeGeneratedAnswer.type); + const analyticsAction = engine.findAsyncAction( + logLikeGeneratedAnswer().pending + ); + + expect(action).toBeTruthy(); + expect(analyticsAction).toBeTruthy(); + }); + + it('#dislike dispatches analytics action', () => { + generatedAnswer.dislike(); + const action = findAction(dislikeGeneratedAnswer.type); + const analyticsAction = engine.findAsyncAction( + logDislikeGeneratedAnswer().pending + ); + + expect(action).toBeTruthy(); + expect(analyticsAction).toBeTruthy(); + }); + + it('#logCitationClick dispatches analytics action', () => { + const testCitation = buildMockCitation(); + + generatedAnswer.logCitationClick(testCitation.id); + const action = engine.findAsyncAction( + logOpenGeneratedAnswerSource(testCitation.id).pending + ); + + expect(action).toBeTruthy(); + }); + + describe('subscription to changes', () => { + function callListener() { + return (engine.subscribe as jest.Mock).mock.calls.map( + (args) => args[0] + )[0](); + } + + it('should not dispatch the stream action when there is no stream ID', () => { + callListener(); + + const action = findAction(streamAnswer.pending.type); + + expect(action).toBeFalsy(); + }); + + it('should dispatch the resetAnswer action when the requestId has changed', () => { + engine.state.search.requestId = 'some-fake-test-id'; + + callListener(); + + const action = findAction(resetAnswer.type); + + expect(action).toBeTruthy(); + }); + + it('should dispatch the stream action when there is a new stream ID', () => { + engine.state.search.extendedResults = { + generativeQuestionAnsweringId: 'some-fake-test-id', + }; + + callListener(); + + const action = findAction(streamAnswer.pending.type); + + expect(action).toBeTruthy(); + }); + }); +}); diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts new file mode 100644 index 00000000000..73460bd4734 --- /dev/null +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -0,0 +1,139 @@ +import {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload'; +import {SearchEngine} from '../../app/search-engine/search-engine'; +import { + streamAnswer, + resetAnswer, + likeGeneratedAnswer, + dislikeGeneratedAnswer, +} from '../../features/generated-answer/generated-answer-actions'; +import { + logDislikeGeneratedAnswer, + logLikeGeneratedAnswer, + logOpenGeneratedAnswerSource, + logRetryGeneratedAnswer, +} from '../../features/generated-answer/generated-answer-analytics-actions'; +import {generatedAnswerReducer as generatedAnswer} from '../../features/generated-answer/generated-answer-slice'; +import {GeneratedAnswerState} from '../../features/generated-answer/generated-answer-state'; +import {executeSearch} from '../../features/search/search-actions'; +import {GeneratedAnswerSection} from '../../state/state-sections'; +import {loadReducerError} from '../../utils/errors'; +import {buildController} from '../controller/headless-controller'; + +export type {GeneratedAnswerState, GeneratedAnswerCitation}; + +/** + * @internal + */ +export interface GeneratedAnswer { + /** + * The state of the GeneratedAnswer controller. + */ + state: GeneratedAnswerState; + /** + * Re-executes the last query to generate an answer. + */ + retry(): void; + /** + * Determines if the generated answer was liked, or upvoted by the end user. + */ + like(): void; + /** + * Determines if the generated answer was disliked, or downvoted by the end user. + */ + dislike(): void; + /** + * Logs a custom event indicating a cited source link was clicked. + * @param id The ID of the clicked citation. + */ + logCitationClick(id: string): void; +} + +/** + * @internal + */ +export function buildGeneratedAnswer(engine: SearchEngine): GeneratedAnswer { + if (!loadGeneratedAnswerReducer(engine)) { + throw loadReducerError; + } + + const {dispatch} = engine; + const controller = buildController(engine); + const getState = () => engine.state; + + let abortController: AbortController | undefined; + let lastRequestId: string; + let lastStreamId: string; + + const setAbortControllerRef = (ref: AbortController) => { + abortController = ref; + }; + + const getIsStreamInProgress = () => { + if (!abortController || abortController?.signal.aborted) { + abortController = undefined; + return false; + } + return true; + }; + + const subscribeToSearchRequests = () => { + const strictListener = () => { + const state = getState(); + const requestId = state.search.requestId; + const streamId = + state.search.extendedResults.generativeQuestionAnsweringId; + + if (lastRequestId !== requestId) { + lastRequestId = requestId; + abortController?.abort(); + dispatch(resetAnswer()); + } + + const isStreamInProgress = getIsStreamInProgress(); + if (!isStreamInProgress && streamId && streamId !== lastStreamId) { + lastStreamId = streamId; + dispatch( + streamAnswer({ + setAbortControllerRef, + }) + ); + } + }; + return engine.subscribe(strictListener); + }; + + subscribeToSearchRequests(); + + return { + ...controller, + + get state() { + return getState().generatedAnswer; + }, + + retry() { + dispatch(executeSearch(logRetryGeneratedAnswer())); + }, + + like() { + dispatch(likeGeneratedAnswer()); + dispatch(logLikeGeneratedAnswer()); + }, + + dislike() { + dispatch(dislikeGeneratedAnswer()); + dispatch(logDislikeGeneratedAnswer()); + }, + + logCitationClick(citationId: string) { + dispatch(logOpenGeneratedAnswerSource(citationId)); + }, + }; +} + +function loadGeneratedAnswerReducer( + engine: SearchEngine +): engine is SearchEngine { + engine.addReducers({generatedAnswer}); + return true; +} diff --git a/packages/headless/src/controllers/index.ts b/packages/headless/src/controllers/index.ts index 806805127e6..72c423c7377 100644 --- a/packages/headless/src/controllers/index.ts +++ b/packages/headless/src/controllers/index.ts @@ -403,3 +403,10 @@ export type { } from './field-suggestions/category-facet/headless-category-field-suggestions'; export {buildCategoryFieldSuggestions} from './field-suggestions/category-facet/headless-category-field-suggestions'; + +export type { + GeneratedAnswer, + GeneratedAnswerState, + GeneratedAnswerCitation, +} from './generated-answer/headless-generated-answer'; +export {buildGeneratedAnswer} from './generated-answer/headless-generated-answer'; diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions-loader.ts b/packages/headless/src/features/generated-answer/generated-answer-actions-loader.ts new file mode 100644 index 00000000000..4a303493412 --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-actions-loader.ts @@ -0,0 +1,32 @@ +import {PayloadAction} from '@reduxjs/toolkit'; +import {CoreEngine} from '../..'; +import {resetAnswer} from './generated-answer-actions'; +import {generatedAnswerReducer as generatedAnswer} from './generated-answer-slice'; + +/** + * The generated answer action creators. + */ +export interface GeneratedAnswerActionCreators { + /** + * Resets the generated answer state to a clean slate. + * + * @returns A dispatchable action. + */ + resetAnswer(): PayloadAction; +} + +/** + * Loads the `generatedAnswer` reducer and returns possible action creators. + * + * @param engine - The headless engine. + * @returns An object holding the action creators. + */ +export function loadGeneratedAnswerActions( + engine: CoreEngine +): GeneratedAnswerActionCreators { + engine.addReducers({generatedAnswer}); + + return { + resetAnswer, + }; +} diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.test.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.test.ts new file mode 100644 index 00000000000..9c8d90d9c6c --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.test.ts @@ -0,0 +1,111 @@ +import { + MockSearchEngine, + buildMockSearchAppEngine, + createMockState, +} from '../../test'; +import {buildMockCitation} from '../../test/mock-citation'; +import { + setIsLoading, + updateCitations, + updateError, + updateMessage, +} from './generated-answer-actions'; + +describe('generated answer', () => { + let e: MockSearchEngine; + + beforeEach(() => { + e = buildMockSearchAppEngine({state: createMockState()}); + jest.spyOn(e.apiClient, 'search'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#updateError', () => { + const actionType = 'generatedAnswer/updateError'; + + it('should accept a full payload', () => { + const testErrorPayload = { + message: 'some message', + code: 500, + }; + expect(e.dispatch(updateError(testErrorPayload))).toEqual({ + payload: testErrorPayload, + type: actionType, + }); + }); + + it('should accept a payload without a message', () => { + const testErrorPayload = { + code: 500, + }; + expect(e.dispatch(updateError(testErrorPayload))).toEqual({ + payload: testErrorPayload, + type: actionType, + }); + }); + + it('should accept a payload without a code', () => { + const testErrorPayload = { + message: 'some message', + }; + expect(e.dispatch(updateError(testErrorPayload))).toEqual({ + payload: testErrorPayload, + type: actionType, + }); + }); + }); + + describe('#setIsLoading', () => { + const actionType = 'generatedAnswer/setIsLoading'; + + it('should accept a boolean payload', () => { + expect(e.dispatch(setIsLoading(true))).toEqual({ + payload: true, + type: actionType, + }); + }); + }); + + describe('#updateMessage', () => { + const actionType = 'generatedAnswer/updateMessage'; + + it('should accept a valid payload', () => { + const testText = 'some message'; + expect( + e.dispatch( + updateMessage({ + textDelta: testText, + }) + ) + ).toEqual({ + payload: { + textDelta: testText, + }, + type: actionType, + }); + }); + }); + + describe('#updateCitations', () => { + const actionType = 'generatedAnswer/updateCitations'; + + it('should accept a valid payload', () => { + const testCitations = [buildMockCitation()]; + expect( + e.dispatch( + updateCitations({ + citations: testCitations, + }) + ) + ).toEqual({ + payload: { + citations: testCitations, + }, + type: actionType, + }); + }); + }); +}); diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts new file mode 100644 index 00000000000..83866e34929 --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -0,0 +1,148 @@ +import { + ArrayValue, + BooleanValue, + NumberValue, + RecordValue, + StringValue, +} from '@coveo/bueno'; +import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import {AsyncThunkGeneratedAnswerOptions} from '../../api/generated-answer/generated-answer-client'; +import { + GeneratedAnswerCitationsPayload, + GeneratedAnswerMessagePayload, + GeneratedAnswerPayloadType, + GeneratedAnswerStreamEventData, +} from '../../api/generated-answer/generated-answer-event-payload'; +import { + ConfigurationSection, + GeneratedAnswerSection, + SearchSection, +} from '../../state/state-sections'; +import {validatePayload} from '../../utils/validate-payload'; +import {buildStreamingRequest} from './generated-awswer-request'; + +type StateNeededByGeneratedAnswerStream = ConfigurationSection & + SearchSection & + GeneratedAnswerSection; + +const stringValue = new StringValue({required: true}); +const optionalStringValue = new StringValue(); +const booleanValue = new BooleanValue({required: true}); +const citationSchema = { + id: stringValue, + title: stringValue, + uri: stringValue, + permanentid: stringValue, + clickUri: optionalStringValue, +}; + +export interface GeneratedAnswerErrorPayload { + message?: string; + code?: number; +} + +export const updateMessage = createAction( + 'generatedAnswer/updateMessage', + (payload: GeneratedAnswerMessagePayload) => + validatePayload(payload, { + textDelta: stringValue, + }) +); + +export const updateCitations = createAction( + 'generatedAnswer/updateCitations', + (payload: GeneratedAnswerCitationsPayload) => + validatePayload(payload, { + citations: new ArrayValue({ + required: true, + each: new RecordValue({ + values: citationSchema, + }), + }), + }) +); + +export const updateError = createAction( + 'generatedAnswer/updateError', + (payload: GeneratedAnswerErrorPayload) => + validatePayload(payload, { + message: optionalStringValue, + code: new NumberValue({min: 0}), + }) +); + +export const resetAnswer = createAction('generatedAnswer/resetAnswer'); + +export const likeGeneratedAnswer = createAction('generatedAnswer/like'); + +export const dislikeGeneratedAnswer = createAction('generatedAnswer/dislike'); + +export const setIsLoading = createAction( + 'generatedAnswer/setIsLoading', + (payload: boolean) => validatePayload(payload, booleanValue) +); + +interface StreamAnswerArgs { + setAbortControllerRef: (ref: AbortController) => void; +} + +export const streamAnswer = createAsyncThunk< + void, + StreamAnswerArgs, + AsyncThunkGeneratedAnswerOptions +>('generatedAnswer/streamAnswer', async (params, config) => { + const state = config.getState(); + const {dispatch, extra} = config; + + const {setAbortControllerRef} = params; + + const request = await buildStreamingRequest(state); + + const handleStreamPayload = ( + payloadType: GeneratedAnswerPayloadType, + payload: string + ) => { + switch (payloadType) { + case 'genqa.messageType': + dispatch( + updateMessage(JSON.parse(payload) as GeneratedAnswerMessagePayload) + ); + break; + case 'genqa.citationsType': + dispatch( + updateCitations( + JSON.parse(payload) as GeneratedAnswerCitationsPayload + ) + ); + break; + default: + extra.logger.error(`Unknown payloadType: "${payloadType}"`); + } + }; + + dispatch(setIsLoading(true)); + const abortController = extra.streamingClient?.streamGeneratedAnswer( + request, + { + write: (data: GeneratedAnswerStreamEventData) => { + if (data.payload && data.payloadType) { + handleStreamPayload(data.payloadType, data.payload); + } + }, + abort: ( + error: GeneratedAnswerErrorPayload, + abortController: AbortController + ) => { + abortController.abort(); + dispatch(updateError(error)); + }, + setIsLoading: (isLoading) => dispatch(setIsLoading(isLoading)), + resetAnswer: () => dispatch(resetAnswer()), + } + ); + if (abortController) { + setAbortControllerRef(abortController); + } else { + dispatch(setIsLoading(false)); + } +}); diff --git a/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts new file mode 100644 index 00000000000..a115aa97f73 --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts @@ -0,0 +1,70 @@ +import { + AnalyticsType, + CustomAction, + SearchAction, + makeAnalyticsAction, +} from '../analytics/analytics-utils'; +import { + citationSourceSelector, + generativeQuestionAnsweringIdSelector, +} from './generated-answer-selectors'; + +export const logRetryGeneratedAnswer = (): SearchAction => + makeAnalyticsAction( + 'analytics/generatedAnswer/retry', + AnalyticsType.Search, + (client) => client.makeRetryGeneratedAnswer() + ); + +export const logOpenGeneratedAnswerSource = ( + citationId: string +): CustomAction => + makeAnalyticsAction( + 'analytics/generatedAnswer/openAnswerSource', + AnalyticsType.Custom, + (client, state) => { + const generativeQuestionAnsweringId = + generativeQuestionAnsweringIdSelector(state); + const citation = citationSourceSelector(state, citationId); + if (!generativeQuestionAnsweringId || !citation) { + return null; + } + return client.makeOpenGeneratedAnswerSource({ + generativeQuestionAnsweringId, + permanentId: citation.permanentid, + id: citation.id, + }); + } + ); + +export const logLikeGeneratedAnswer = (): CustomAction => + makeAnalyticsAction( + 'analytics/generatedAnswer/like', + AnalyticsType.Custom, + (client, state) => { + const generativeQuestionAnsweringId = + generativeQuestionAnsweringIdSelector(state); + if (!generativeQuestionAnsweringId) { + return null; + } + return client.makeLikeGeneratedAnswer({ + generativeQuestionAnsweringId, + }); + } + ); + +export const logDislikeGeneratedAnswer = (): CustomAction => + makeAnalyticsAction( + 'analytics/generatedAnswer/dislike', + AnalyticsType.Custom, + (client, state) => { + const generativeQuestionAnsweringId = + generativeQuestionAnsweringIdSelector(state); + if (!generativeQuestionAnsweringId) { + return null; + } + return client.makeDislikeGeneratedAnswer({ + generativeQuestionAnsweringId, + }); + } + ); diff --git a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts new file mode 100644 index 00000000000..8b34f9a209e --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts @@ -0,0 +1,17 @@ +import {SearchAppState} from '../../state/search-app-state'; +import {GeneratedAnswerSection} from '../../state/state-sections'; + +export function citationSourceSelector( + state: Partial, + citationId: string +) { + return state.generatedAnswer?.citations?.find( + (citation) => citation.id === citationId + ); +} + +export function generativeQuestionAnsweringIdSelector( + state: Partial +) { + return state.search?.response?.extendedResults?.generativeQuestionAnsweringId; +} diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.test.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.test.ts new file mode 100644 index 00000000000..355d72766ca --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.test.ts @@ -0,0 +1,229 @@ +import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client'; +import {buildMockCitation} from '../../test/mock-citation'; +import { + dislikeGeneratedAnswer, + likeGeneratedAnswer, + resetAnswer, + setIsLoading, + updateCitations, + updateError, + updateMessage, +} from './generated-answer-actions'; +import {generatedAnswerReducer} from './generated-answer-slice'; +import {getGeneratedAnswerInitialState} from './generated-answer-state'; + +const baseState = { + isLoading: false, + citations: [], + liked: false, + disliked: false, +}; + +describe('generated answer slice', () => { + it('initializes the state correctly', () => { + const finalState = generatedAnswerReducer(undefined, {type: ''}); + + expect(finalState).toEqual(baseState); + }); + + describe('#updateMessage', () => { + it('concatenates the given string with the answer previously in the state', () => { + const existingAnswer = 'I exist'; + const newMessage = ' therefore I am'; + const finalState = generatedAnswerReducer( + { + ...getGeneratedAnswerInitialState(), + answer: existingAnswer, + }, + updateMessage({ + textDelta: newMessage, + }) + ); + + expect(finalState.answer).toBe('I exist therefore I am'); + expect(finalState.error).toBeUndefined(); + }); + }); + + describe('#updateCitations', () => { + it('Adds the given citations to the state', () => { + const newCitations = [buildMockCitation()]; + const finalState = generatedAnswerReducer( + { + ...getGeneratedAnswerInitialState(), + }, + updateCitations({citations: newCitations}) + ); + + expect(finalState.citations).toEqual(newCitations); + expect(finalState.error).toBeUndefined(); + }); + + it('Appends the given citations to existing citations', () => { + const existingCitations = [buildMockCitation()]; + const newCitations = [ + buildMockCitation({ + id: 'some-other-id', + }), + ]; + const finalState = generatedAnswerReducer( + { + ...getGeneratedAnswerInitialState(), + citations: existingCitations, + }, + updateCitations({citations: newCitations}) + ); + + expect(finalState.citations).toEqual([ + ...existingCitations, + ...newCitations, + ]); + }); + }); + + describe('#updateError', () => { + const testPayload = { + message: 'some error message', + code: 500, + }; + it('should set isLoading to false', () => { + const finalState = generatedAnswerReducer( + {...baseState, isLoading: true}, + updateError(testPayload) + ); + + expect(finalState.isLoading).toBe(false); + }); + + it('should delete the answer', () => { + const finalState = generatedAnswerReducer( + {...baseState, answer: 'I exist'}, + updateError(testPayload) + ); + + expect(finalState.answer).toBeUndefined(); + }); + + it('should set given error values', () => { + const finalState = generatedAnswerReducer( + baseState, + updateError({ + message: 'a message', + code: 500, + }) + ); + + expect(finalState.error).toEqual({ + message: 'a message', + code: 500, + isRetryable: false, + }); + }); + + it('should set retryable to true if the error code matches the retryable error code', () => { + const finalState = generatedAnswerReducer( + {...baseState, answer: 'I exist'}, + updateError({ + message: 'a message', + code: RETRYABLE_STREAM_ERROR_CODE, + }) + ); + + expect(finalState.error).toEqual({ + message: 'a message', + code: RETRYABLE_STREAM_ERROR_CODE, + isRetryable: true, + }); + }); + + it('should accept an error payload without a message', () => { + const testErrorPayload = { + code: 500, + }; + const finalState = generatedAnswerReducer( + getGeneratedAnswerInitialState(), + updateError(testErrorPayload) + ); + + expect(finalState.error).toEqual({ + ...testErrorPayload, + isRetryable: false, + }); + }); + + it('should accept an error payload without a code', () => { + const testErrorPayload = { + message: 'some message', + }; + const finalState = generatedAnswerReducer( + getGeneratedAnswerInitialState(), + updateError(testErrorPayload) + ); + + expect(finalState.error).toEqual({ + ...testErrorPayload, + isRetryable: false, + }); + }); + }); + + it('#resetAnswer should reset the state to the initial state', () => { + const state = { + ...baseState, + isLoading: true, + answer: 'Tomato Tomato', + citations: [], + error: { + message: 'Execute order', + error: 66, + }, + }; + + const finalState = generatedAnswerReducer(state, resetAnswer()); + + expect(finalState).toEqual(getGeneratedAnswerInitialState()); + }); + + it('#likeGeneratedAnswer should set the answer as liked in the state', () => { + const finalState = generatedAnswerReducer(baseState, likeGeneratedAnswer()); + + expect(finalState).toEqual({ + ...getGeneratedAnswerInitialState(), + liked: true, + disliked: false, + }); + }); + + it('#dislikeGeneratedAnswer should set the answer as disliked in the state', () => { + const finalState = generatedAnswerReducer( + baseState, + dislikeGeneratedAnswer() + ); + + expect(finalState).toEqual({ + ...getGeneratedAnswerInitialState(), + liked: false, + disliked: true, + }); + }); + + describe('#setIsLoading', () => { + it('should set isLoading to true when given true', () => { + const finalState = generatedAnswerReducer( + {...baseState, isLoading: false}, + setIsLoading(true) + ); + + expect(finalState.isLoading).toEqual(true); + }); + + it('should set isLoading to false when given false', () => { + const finalState = generatedAnswerReducer( + {...baseState, isLoading: true}, + setIsLoading(false) + ); + + expect(finalState.isLoading).toEqual(false); + }); + }); +}); diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts new file mode 100644 index 00000000000..174bf3c3cad --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -0,0 +1,54 @@ +import {createReducer} from '@reduxjs/toolkit'; +import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client'; +import './generated-answer-actions'; +import { + dislikeGeneratedAnswer, + likeGeneratedAnswer, + resetAnswer, + setIsLoading, + updateCitations, + updateError, + updateMessage, +} from './generated-answer-actions'; +import {getGeneratedAnswerInitialState} from './generated-answer-state'; + +export const generatedAnswerReducer = createReducer( + getGeneratedAnswerInitialState(), + (builder) => + builder + .addCase(updateMessage, (state, {payload}) => { + state.isLoading = false; + if (!state.answer) { + state.answer = ''; + } + state.answer += payload.textDelta; + delete state.error; + }) + .addCase(updateCitations, (state, {payload}) => { + state.citations = state.citations.concat(payload.citations); + delete state.error; + }) + .addCase(updateError, (state, {payload}) => { + state.isLoading = false; + state.error = { + ...payload, + isRetryable: payload.code === RETRYABLE_STREAM_ERROR_CODE, + }; + state.citations = []; + delete state.answer; + }) + .addCase(likeGeneratedAnswer, (state) => { + state.liked = true; + state.disliked = false; + }) + .addCase(dislikeGeneratedAnswer, (state) => { + state.liked = false; + state.disliked = true; + }) + .addCase(resetAnswer, () => { + return getGeneratedAnswerInitialState(); + }) + .addCase(setIsLoading, (state, {payload}) => { + state.isLoading = payload; + }) +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts new file mode 100644 index 00000000000..b306518b563 --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -0,0 +1,23 @@ +import {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload'; + +export interface GeneratedAnswerState { + isLoading: boolean; + answer?: string; + citations: GeneratedAnswerCitation[]; + liked: boolean; + disliked: boolean; + error?: { + message?: string; + code?: number; + isRetryable?: boolean; + }; +} + +export function getGeneratedAnswerInitialState(): GeneratedAnswerState { + return { + isLoading: false, + citations: [], + liked: false, + disliked: false, + }; +} diff --git a/packages/headless/src/features/generated-answer/generated-awswer-request.ts b/packages/headless/src/features/generated-answer/generated-awswer-request.ts new file mode 100644 index 00000000000..dbece5586df --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-awswer-request.ts @@ -0,0 +1,19 @@ +import {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request'; +import { + ConfigurationSection, + GeneratedAnswerSection, + SearchSection, +} from '../../state/state-sections'; + +type StateNeededByGeneratedAnswerStream = ConfigurationSection & + SearchSection & + GeneratedAnswerSection; + +export const buildStreamingRequest = async ( + state: StateNeededByGeneratedAnswerStream +): Promise => ({ + accessToken: state.configuration.accessToken, + organizationId: state.configuration.organizationId, + url: state.configuration.platformUrl, + streamId: state.search.extendedResults?.generativeQuestionAnsweringId, +}); diff --git a/packages/headless/src/features/index.ts b/packages/headless/src/features/index.ts index bb7a85f5ec2..9c5e4844029 100644 --- a/packages/headless/src/features/index.ts +++ b/packages/headless/src/features/index.ts @@ -39,6 +39,7 @@ export * from './recent-queries/recent-queries-actions-loader'; export * from './recent-results/recent-results-actions-loader'; export * from './excerpt-length/excerpt-length-actions-loader'; export * from './result-preview/result-preview-actions-loader'; +export * from './generated-answer/generated-answer-actions-loader'; export type {ResultTemplatesManager} from './result-templates/result-templates-manager'; export {buildResultTemplatesManager} from './result-templates/result-templates-manager'; diff --git a/packages/headless/src/features/search/search-slice.ts b/packages/headless/src/features/search/search-slice.ts index 103cedc742f..bf99440ba76 100644 --- a/packages/headless/src/features/search/search-slice.ts +++ b/packages/headless/src/features/search/search-slice.ts @@ -47,6 +47,7 @@ function handleFulfilledNewSearch( state.results = action.payload.response.results; state.searchResponseId = action.payload.response.searchUid; state.questionAnswer = action.payload.response.questionAnswer; + state.extendedResults = action.payload.response.extendedResults; } function handlePendingSearch( diff --git a/packages/headless/src/features/search/search-state.ts b/packages/headless/src/features/search/search-state.ts index ceff89452bf..78502056b9d 100644 --- a/packages/headless/src/features/search/search-state.ts +++ b/packages/headless/src/features/search/search-state.ts @@ -1,4 +1,5 @@ import {SearchAPIErrorWithStatusCode} from '../../api/search/search-api-error-response'; +import {ExtendedResults} from '../../api/search/search/extended-results'; import {QuestionsAnswers} from '../../api/search/search/question-answering'; import {Result} from '../../api/search/search/result'; import {SearchResponseSuccess} from '../../api/search/search/search-response'; @@ -43,6 +44,10 @@ export interface SearchState { * The question and answers related to the smart snippet. */ questionAnswer: QuestionsAnswers; + /** + * The extended results. + */ + extendedResults: ExtendedResults; } export function emptyQuestionAnswer() { @@ -73,6 +78,7 @@ export function getSearchInitialState(): SearchState { splitTestRun: '', termsToHighlight: {}, phrasesToHighlight: {}, + extendedResults: {}, }, duration: 0, queryExecuted: '', @@ -83,5 +89,6 @@ export function getSearchInitialState(): SearchState { searchResponseId: '', requestId: '', questionAnswer: emptyQuestionAnswer(), + extendedResults: {}, }; } diff --git a/packages/headless/src/state/search-app-state.ts b/packages/headless/src/state/search-app-state.ts index 990e9b8da4d..806b5059af1 100644 --- a/packages/headless/src/state/search-app-state.ts +++ b/packages/headless/src/state/search-app-state.ts @@ -35,6 +35,7 @@ import { TabSection, StaticFilterSection, ExcerptLengthSection, + GeneratedAnswerSection, } from './state-sections'; export type SearchParametersState = FacetSection & @@ -74,4 +75,5 @@ export type SearchAppState = SearchParametersState & QuestionAnsweringSection & RecentResultsSection & RecentQueriesSection & - ExcerptLengthSection; + ExcerptLengthSection & + GeneratedAnswerSection; diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index 811f0ca6c0c..94d8e2cf9c3 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -21,6 +21,7 @@ import {DateFacetSetState} from '../features/facets/range-facets/date-facet-set/ import {NumericFacetSetState} from '../features/facets/range-facets/numeric-facet-set/numeric-facet-set-state'; import {FieldsState} from '../features/fields/fields-state'; import {FoldingState} from '../features/folding/folding-state'; +import {GeneratedAnswerState} from '../features/generated-answer/generated-answer-state'; import {HistoryState} from '../features/history/history-state'; import {InsightConfigurationState} from '../features/insight-configuration/insight-configuration-state'; import {InsightInterfaceState} from '../features/insight-interface/insight-interface-state'; @@ -381,3 +382,10 @@ export interface AttachedResultsSection { */ attachedResults: AttachedResultsState; } + +export interface GeneratedAnswerSection { + /** + * The properties related to generative question answering. + */ + generatedAnswer: GeneratedAnswerState; +} diff --git a/packages/headless/src/test/mock-citation.ts b/packages/headless/src/test/mock-citation.ts new file mode 100644 index 00000000000..d1cd2f7f475 --- /dev/null +++ b/packages/headless/src/test/mock-citation.ts @@ -0,0 +1,21 @@ +import {GeneratedAnswerCitation} from '../controllers'; + +/** + * For internal use only. + * + * Returns a `GeneratedAnswerCitation` for testing purposes. + * @param config - A partial `GeneratedAnswerCitation` from which to build the target `GeneratedAnswerCitation`. + * @returns The new `GeneratedAnswerCitation`. + */ +export function buildMockCitation( + config: Partial = {} +): GeneratedAnswerCitation { + return { + id: '', + title: '', + uri: '', + permanentid: '', + clickUri: '', + ...config, + }; +} diff --git a/packages/headless/src/test/mock-search-response.ts b/packages/headless/src/test/mock-search-response.ts index a61398feb95..b4266df889d 100644 --- a/packages/headless/src/test/mock-search-response.ts +++ b/packages/headless/src/test/mock-search-response.ts @@ -21,6 +21,7 @@ export function buildMockSearchResponse( splitTestRun: '', termsToHighlight: {}, phrasesToHighlight: {}, + extendedResults: {}, ...config, }; } diff --git a/packages/headless/src/test/mock-search-state.ts b/packages/headless/src/test/mock-search-state.ts index 86149726d93..de07c4e486e 100644 --- a/packages/headless/src/test/mock-search-state.ts +++ b/packages/headless/src/test/mock-search-state.ts @@ -18,6 +18,7 @@ export function buildMockSearchState( searchResponseId: '', requestId: '', questionAnswer: emptyQuestionAnswer(), + extendedResults: {}, ...config, }; } diff --git a/packages/headless/src/test/mock-state.ts b/packages/headless/src/test/mock-state.ts index e25bf981408..cfd30bf5369 100644 --- a/packages/headless/src/test/mock-state.ts +++ b/packages/headless/src/test/mock-state.ts @@ -16,6 +16,7 @@ import {getDateFacetSetInitialState} from '../features/facets/range-facets/date- import {getNumericFacetSetInitialState} from '../features/facets/range-facets/numeric-facet-set/numeric-facet-set-state'; import {getFieldsInitialState} from '../features/fields/fields-state'; import {getFoldingInitialState} from '../features/folding/folding-state'; +import {getGeneratedAnswerInitialState} from '../features/generated-answer/generated-answer-state'; import {getHistoryInitialState} from '../features/history/history-state'; import {getInstantResultsInitialState} from '../features/instant-results/instant-results-state'; import {getPaginationInitialState} from '../features/pagination/pagination-state'; @@ -82,6 +83,7 @@ export function createMockState( recentResults: getRecentResultsInitialState(), recentQueries: getRecentQueriesInitialState(), excerptLength: getExcerptLengthInitialState(), + generatedAnswer: getGeneratedAnswerInitialState(), ...config, }; } diff --git a/packages/headless/src/utils/utils.ts b/packages/headless/src/utils/utils.ts index be6817dc071..c68e41f16fb 100644 --- a/packages/headless/src/utils/utils.ts +++ b/packages/headless/src/utils/utils.ts @@ -74,3 +74,13 @@ export function fromEntries( } return newObject as Record; } + +export function resetTimeout( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (...args: any[]) => void, + timeoutId?: ReturnType, + ms?: number | undefined +) { + clearTimeout(timeoutId); + return setTimeout(callback, ms); +} From 2f2096bfd1f2c0993cb3041eb081bbe052cfcb94 Mon Sep 17 00:00:00 2001 From: Nathan Lafrance-Berger Date: Wed, 28 Jun 2023 11:27:31 -0400 Subject: [PATCH 03/10] feat(atomic): added Generated Answer component (#3003) * feat(headless): added generative answer feature * feat(headless): added slice tests * feat(headless): added client * feat(headless): subscribe * feat(headless): temp retry logic * feat(headless): client retry * feat(headless): exports * feat(headless): added error codes * feat(atomic): prelim components * feat(atomic): tag fix * feat(headless): actually building a controller lol * feat(headless): actually building a controller lol * feat(headless): id and error handling tweaks * feat(headless): added stream id to search event custom data * feat(headless): complete event might contain delta * feat(headless): complete event might contain delta * fix(headless): started adding payload schemas * feat(genqa): enum rename * feat(atomic): added feedback buttons * feat(headless): started adding analytics actions * feat(atomic): added citations * feat(headless): citatio interface export * feat(atomic): added localized strings * feat(headless): punctuation * feat(atomic): show when loading * feat(headless): new citations schema * feat(atomic): no score * feat(headless): removed score * feat(atomic): updated loader * feat(headless): removed oncomplete * feat(headless): added retryable state flag * feat(atomic): used analytics actions * feat(atomic): fixed loading transition * feat(headless): tests for setIsLoading * feat(atomic): citation max width * feat(headless): added more controller tests * feat(headless): removed unused analytics util * feat(headless): moved handlers to asyncthunk * feat(headless): string literal * feat(atomic): added genqa example page * feat(headless): added like and dislike to the state * feat(headless): error payload type * feat(headless): renamed and added citation seletor * feat(atomic): feedback button state * feat(headless): switched libs * feat(headless): reset error state * feat(atomic): feedback button dimensions * feat(headless): controller tests * feat(headless): changed handlers for action injection * feat(headless): controller tests * feat(headless): changed handlers for action injection * feat(headless): onpopen handler * feat(atomic): added retry button * feat(atomic): started tests * feat(headless): added arguments to fatalerror * feat(atomic): added tests * feat(headless): pr feedback * feat(atomic): added tests * feat(headless): changed client callbacks * feat(atomic): added tests * feat(headless): switch it up * feat(headless): changed accept header * feat(atomic): added tests * feat(atomic): added retry test * feat(headless): setting error before throw from onerror * feat(headless): wrong error * feat(atomic): retry button tests * feat(atomic): unknown instead of any * feat(atomic): title to citation links * feat(atomic): pr feedback * feat(atomic): css and a11y feedback implemented * feat(atomic): props interface * feat(atomic): removed smart snippets from genqa example * feat(atomic): ordered list * feat(atomic): key on top level element --- .../cypress/e2e/generated-answer-actions.ts | 49 +++ .../cypress/e2e/generated-answer-selectors.ts | 26 ++ .../cypress/e2e/generated-answer.cypress.ts | 174 +++++++++++ .../atomic/cypress/fixtures/fixture-common.ts | 1 + packages/atomic/src/components.d.ts | 13 + .../atomic-generated-answer.pcss | 89 ++++++ .../atomic-generated-answer.tsx | 139 +++++++++ .../feedback-button.tsx | 35 +++ .../generated-content.tsx | 25 ++ .../atomic-generated-answer/retry-prompt.tsx | 21 ++ .../source-citations.tsx | 42 +++ .../atomic-generated-answer/typing-loader.tsx | 18 ++ packages/atomic/src/images/thumbs-down.svg | 9 + packages/atomic/src/images/thumbs-up.svg | 9 + packages/atomic/src/locales.json | 24 ++ packages/atomic/src/pages/examples/genqa.html | 283 ++++++++++++++++++ packages/atomic/src/pages/header.js | 1 + packages/atomic/src/themes/accessible.css | 3 + packages/atomic/src/themes/coveo.css | 3 + 19 files changed, 964 insertions(+) create mode 100644 packages/atomic/cypress/e2e/generated-answer-actions.ts create mode 100644 packages/atomic/cypress/e2e/generated-answer-selectors.ts create mode 100644 packages/atomic/cypress/e2e/generated-answer.cypress.ts create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx create mode 100644 packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx create mode 100644 packages/atomic/src/images/thumbs-down.svg create mode 100644 packages/atomic/src/images/thumbs-up.svg create mode 100644 packages/atomic/src/pages/examples/genqa.html diff --git a/packages/atomic/cypress/e2e/generated-answer-actions.ts b/packages/atomic/cypress/e2e/generated-answer-actions.ts new file mode 100644 index 00000000000..39fa6e36637 --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer-actions.ts @@ -0,0 +1,49 @@ +import {RouteAlias} from '../fixtures/fixture-common'; +import {TestFixture, generateComponentHTML} from '../fixtures/test-fixture'; + +export const getStreamInterceptAlias = (streamId: string) => + `${RouteAlias.GenQAStream}-${streamId}`; + +export function mockStreamResponse(streamId: string, body: unknown) { + cy.intercept( + { + method: 'GET', + url: `**/machinelearning/streaming/${streamId}`, + }, + (request) => { + request.reply(200, `data: ${JSON.stringify(body)} \n\n`, { + 'content-type': 'text/event-stream', + }); + } + ).as(getStreamInterceptAlias(streamId).substring(1)); +} + +export function mockStreamError(streamId: string, errorCode: number) { + cy.intercept( + { + method: 'GET', + url: `**/machinelearning/streaming/${streamId}`, + }, + (request) => { + request.reply( + errorCode, + {}, + { + 'content-type': 'text/event-stream', + } + ); + } + ).as(getStreamInterceptAlias(streamId).substring(1)); +} + +export const addGeneratedAnswer = + (streamId?: string) => (fixture: TestFixture) => { + const element = generateComponentHTML('atomic-generated-answer'); + fixture.withElement(element).withCustomResponse((response) => { + if (streamId) { + response.extendedResults = { + generativeQuestionAnsweringId: streamId, + }; + } + }); + }; diff --git a/packages/atomic/cypress/e2e/generated-answer-selectors.ts b/packages/atomic/cypress/e2e/generated-answer-selectors.ts new file mode 100644 index 00000000000..f983ea50d95 --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer-selectors.ts @@ -0,0 +1,26 @@ +export const generatedAnswerComponent = 'atomic-generated-answer'; +export const GeneratedAnswerSelectors = { + shadow: () => cy.get(generatedAnswerComponent).shadow(), + container: () => GeneratedAnswerSelectors.shadow().find('[part="container"]'), + content: () => + GeneratedAnswerSelectors.shadow().find('[part="generated-content"]'), + answer: () => + GeneratedAnswerSelectors.shadow().find('[part="generated-text"]'), + headerLabel: () => + GeneratedAnswerSelectors.shadow().find('[part="header-label"]'), + likeButton: () => + GeneratedAnswerSelectors.shadow().find('.feedback-button.like'), + dislikeButton: () => + GeneratedAnswerSelectors.shadow().find('.feedback-button.dislike'), + citation: () => GeneratedAnswerSelectors.shadow().find('.citation'), + citationsLabel: () => + GeneratedAnswerSelectors.shadow().find('.citations-label'), + citationTitle: () => + GeneratedAnswerSelectors.citation().find('.citation-title'), + citationIndex: () => + GeneratedAnswerSelectors.citation().find('.citation-index'), + loader: () => GeneratedAnswerSelectors.shadow().find('.typing-indicator'), + retryContainer: () => + GeneratedAnswerSelectors.shadow().find('[part="retry-container"]'), + retryButton: () => GeneratedAnswerSelectors.retryContainer().find('button'), +}; diff --git a/packages/atomic/cypress/e2e/generated-answer.cypress.ts b/packages/atomic/cypress/e2e/generated-answer.cypress.ts new file mode 100644 index 00000000000..aa4b7fdd8ae --- /dev/null +++ b/packages/atomic/cypress/e2e/generated-answer.cypress.ts @@ -0,0 +1,174 @@ +import {TestFixture} from '../fixtures/test-fixture'; +import { + addGeneratedAnswer, + getStreamInterceptAlias, + mockStreamError, + mockStreamResponse, +} from './generated-answer-actions'; +import {GeneratedAnswerSelectors} from './generated-answer-selectors'; + +describe('Generated Answer Test Suites', () => { + describe('Generated Answer', () => { + function setupGeneratedAnswer(streamId?: string) { + new TestFixture().with(addGeneratedAnswer(streamId)).init(); + } + + describe('when no stream ID is returned', () => { + beforeEach(() => { + setupGeneratedAnswer(); + }); + + it('should not display the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('when a stream ID is returned', () => { + describe('when a message event is received', () => { + const streamId = crypto.randomUUID(); + + const testTextDelta = 'Some text'; + const testMessagePayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: testTextDelta, + }), + finishReason: 'COMPLETED', + }; + + beforeEach(() => { + mockStreamResponse(streamId, testMessagePayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should log the stream ID in the search event custom data', () => { + TestFixture.getUACustomData().then((customData) => { + expect(customData).to.have.property( + 'generativeQuestionAnsweringId', + streamId + ); + }); + }); + + it('should display the message', () => { + GeneratedAnswerSelectors.answer().should('have.text', testTextDelta); + }); + + it('should display feedback buttons', () => { + GeneratedAnswerSelectors.likeButton().should('exist'); + GeneratedAnswerSelectors.dislikeButton().should('exist'); + }); + }); + + describe('when a citation event is received', () => { + const streamId = crypto.randomUUID(); + + const testCitation = { + id: 'some-id-123', + title: 'Some Title', + uri: 'https://www.coveo.com', + permanentid: 'some-permanent-id-123', + clickUri: 'https://www.coveo.com/en', + }; + const testMessagePayload = { + payloadType: 'genqa.citationsType', + payload: JSON.stringify({ + citations: [testCitation], + }), + finishReason: 'COMPLETED', + }; + + beforeEach(() => { + mockStreamResponse(streamId, testMessagePayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should display the citation link', () => { + GeneratedAnswerSelectors.citationsLabel().should( + 'have.text', + 'Learn more' + ); + GeneratedAnswerSelectors.citationTitle().should( + 'have.text', + testCitation.title + ); + GeneratedAnswerSelectors.citationIndex().should('have.text', '1'); + GeneratedAnswerSelectors.citation().should( + 'have.attr', + 'href', + testCitation.clickUri + ); + }); + }); + + describe('when an error event is received', () => { + const streamId = crypto.randomUUID(); + + const testErrorPayload = { + finishReason: 'ERROR', + errorMessage: 'An error message', + errorCode: 500, + }; + + beforeEach(() => { + mockStreamResponse(streamId, testErrorPayload); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should not display the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('when the stream connection fails', () => { + const streamId = crypto.randomUUID(); + + describe('Non-retryable error (4XX)', () => { + beforeEach(() => { + mockStreamError(streamId, 406); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should not show the component', () => { + GeneratedAnswerSelectors.container().should('not.exist'); + }); + }); + + describe('Retryable error', () => { + [500, 429].forEach((errorCode) => { + describe(`${errorCode} error`, () => { + beforeEach(() => { + Cypress.on('uncaught:exception', () => false); + mockStreamError(streamId, 500); + setupGeneratedAnswer(streamId); + cy.wait(getStreamInterceptAlias(streamId)); + }); + + it('should retry the stream 3 times then offer a retry button', () => { + for (let times = 0; times < 3; times++) { + GeneratedAnswerSelectors.container().should('not.exist'); + + cy.wait(getStreamInterceptAlias(streamId)); + } + + const retryAlias = '@retrySearch'; + cy.intercept({ + method: 'POST', + url: '**/rest/search/v2?*', + }).as(retryAlias.substring(1)); + + GeneratedAnswerSelectors.retryButton().click(); + + cy.wait(retryAlias); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/atomic/cypress/fixtures/fixture-common.ts b/packages/atomic/cypress/fixtures/fixture-common.ts index 60325df129d..1f9f22fb224 100644 --- a/packages/atomic/cypress/fixtures/fixture-common.ts +++ b/packages/atomic/cypress/fixtures/fixture-common.ts @@ -28,6 +28,7 @@ export const RouteAlias = { FacetSearch: '@coveoFacetSearch', Quickview: '@coveoQuickview', Locale: '@locale', + GenQAStream: '@genQAStream', }; export const ConsoleAliases = { diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 4a9753bd93f..039dac94161 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -346,6 +346,8 @@ export namespace Components { } interface AtomicFrequentlyBoughtTogether { } + interface AtomicGeneratedAnswer { + } interface AtomicHtml { /** * Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify). @@ -2124,6 +2126,12 @@ declare global { prototype: HTMLAtomicFrequentlyBoughtTogetherElement; new (): HTMLAtomicFrequentlyBoughtTogetherElement; }; + interface HTMLAtomicGeneratedAnswerElement extends Components.AtomicGeneratedAnswer, HTMLStencilElement { + } + var HTMLAtomicGeneratedAnswerElement: { + prototype: HTMLAtomicGeneratedAnswerElement; + new (): HTMLAtomicGeneratedAnswerElement; + }; interface HTMLAtomicHtmlElement extends Components.AtomicHtml, HTMLStencilElement { } var HTMLAtomicHtmlElement: { @@ -2822,6 +2830,7 @@ declare global { "atomic-format-number": HTMLAtomicFormatNumberElement; "atomic-format-unit": HTMLAtomicFormatUnitElement; "atomic-frequently-bought-together": HTMLAtomicFrequentlyBoughtTogetherElement; + "atomic-generated-answer": HTMLAtomicGeneratedAnswerElement; "atomic-html": HTMLAtomicHtmlElement; "atomic-icon": HTMLAtomicIconElement; "atomic-insight-edit-toggle": HTMLAtomicInsightEditToggleElement; @@ -3253,6 +3262,8 @@ declare namespace LocalJSX { } interface AtomicFrequentlyBoughtTogether { } + interface AtomicGeneratedAnswer { + } interface AtomicHtml { /** * Specify if the content should be sanitized, using [`DOMPurify`](https://www.npmjs.com/package/dompurify). @@ -4779,6 +4790,7 @@ declare namespace LocalJSX { "atomic-format-number": AtomicFormatNumber; "atomic-format-unit": AtomicFormatUnit; "atomic-frequently-bought-together": AtomicFrequentlyBoughtTogether; + "atomic-generated-answer": AtomicGeneratedAnswer; "atomic-html": AtomicHtml; "atomic-icon": AtomicIcon; "atomic-insight-edit-toggle": AtomicInsightEditToggle; @@ -4917,6 +4929,7 @@ declare module "@stencil/core" { "atomic-format-number": LocalJSX.AtomicFormatNumber & JSXBase.HTMLAttributes; "atomic-format-unit": LocalJSX.AtomicFormatUnit & JSXBase.HTMLAttributes; "atomic-frequently-bought-together": LocalJSX.AtomicFrequentlyBoughtTogether & JSXBase.HTMLAttributes; + "atomic-generated-answer": LocalJSX.AtomicGeneratedAnswer & JSXBase.HTMLAttributes; "atomic-html": LocalJSX.AtomicHtml & JSXBase.HTMLAttributes; "atomic-icon": LocalJSX.AtomicIcon & JSXBase.HTMLAttributes; "atomic-insight-edit-toggle": LocalJSX.AtomicInsightEditToggle & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss new file mode 100644 index 00000000000..3e05c3d818f --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.pcss @@ -0,0 +1,89 @@ +@import '../../../global/global.pcss'; + +[part='container'] { + box-shadow: 0px 4px 8px rgba(4, 8, 31, 0.08), inset 0px 0px 0px 1px var(--atomic-neutral); +} + +[part='header-label'] { + color: var(--atomic-primary); +} + +[part='generated-text'] { + color: var(--atomic-on-background); +} + +.feedback-buttons { + .feedback-button { + width: 2.2rem; + height: 2.2rem; + + &.active { + &.like { + background: var(--atomic-success-background); + } + + &.dislike { + background: var(--atomic-error-background); + } + } + } +} + +.text-bg-blue { + color: var(--atomic-primary); + background: var(--atomic-primary-background); +} + +.source-citations { + .citations-container { + .citation { + max-width: 160px; + } + + .citation-index { + width: 20px; + height: 20px; + } + } +} + +.typing-indicator { + background-color: var(--atomic-primary-background); + will-change: transform; + width: auto; + border-radius: 50px; + padding: 8px 4px; + display: table; + margin: 0 auto; + position: relative; + animation: 2s bulge infinite ease-out; +} +.typing-indicator span { + height: 8px; + width: 8px; + float: left; + margin: 0 4px; + background-color: #1372ec; + display: block; + border-radius: 50%; + opacity: 0.4; +} +.typing-indicator span:nth-of-type(1) { + animation: 1s blink infinite 0.3333s; +} +.typing-indicator span:nth-of-type(2) { + animation: 1s blink infinite 0.6666s; +} +.typing-indicator span:nth-of-type(3) { + animation: 1s blink infinite 0.9999s; +} +@keyframes blink { + 50% { + opacity: 1; + } +} +@keyframes bulge { + 50% { + transform: scale(1.05); + } +} diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx new file mode 100644 index 00000000000..96ff0e9bf07 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.tsx @@ -0,0 +1,139 @@ +import { + SearchStatus, + SearchStatusState, + buildSearchStatus, + buildGeneratedAnswer, + GeneratedAnswer, + GeneratedAnswerState, +} from '@coveo/headless'; +import {Component, h, State} from '@stencil/core'; +import { + BindStateToController, + InitializableComponent, + InitializeBindings, +} from '../../../utils/initialization-utils'; +import {Heading} from '../../common/heading'; +import {Bindings} from '../atomic-search-interface/atomic-search-interface'; +import {FeedbackButton} from './feedback-button'; +import {GeneratedContent} from './generated-content'; +import {RetryPrompt} from './retry-prompt'; +import {TypingLoader} from './typing-loader'; + +/** + * @internal + */ +@Component({ + tag: 'atomic-generated-answer', + styleUrl: 'atomic-generated-answer.pcss', + shadow: true, +}) +export class AtomicGeneratedAnswer implements InitializableComponent { + @InitializeBindings() public bindings!: Bindings; + public generatedAnswer!: GeneratedAnswer; + public searchStatus!: SearchStatus; + + @BindStateToController('generatedAnswer') + @State() + private generatedAnswerState!: GeneratedAnswerState; + + @BindStateToController('searchStatus') + @State() + private searchStatusState!: SearchStatusState; + + @State() + public error!: Error; + + @State() + hidden = true; + + public initialize() { + this.generatedAnswer = buildGeneratedAnswer(this.bindings.engine); + this.searchStatus = buildSearchStatus(this.bindings.engine); + } + + private get hasRetryableError() { + return ( + !this.searchStatusState.hasError && + this.generatedAnswerState.error?.isRetryable + ); + } + + private get shouldBeHidden() { + const {isLoading, answer, citations} = this.generatedAnswerState; + return ( + !(isLoading || answer !== undefined || citations.length) && + !this.hasRetryableError + ); + } + + private renderContent() { + return ( +
+
+ + {this.bindings.i18n.t('generated-answer-title')} + + + {!this.hasRetryableError && ( + + )} +
+ {this.hasRetryableError ? ( + + ) : ( + + this.generatedAnswer.logCitationClick(citation.id) + } + /> + )} +
+ ); + } + + public render() { + if (this.shouldBeHidden) { + return null; + } + return ( + + ); + } +} diff --git a/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx b/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx new file mode 100644 index 00000000000..16eb2244147 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/feedback-button.tsx @@ -0,0 +1,35 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import ThumbsDownIcon from '../../../images/thumbs-down.svg'; +import ThumbsUpIcon from '../../../images/thumbs-up.svg'; +import {Button} from '../../common/button'; + +type FeedbackVariant = 'like' | 'dislike'; + +interface FeedbackButtonProps { + title: string; + variant: FeedbackVariant; + active: boolean; + onClick: () => void; +} + +export const FeedbackButton: FunctionalComponent = ( + props +) => { + const getIcon = () => { + return props.variant === 'like' ? ThumbsUpIcon : ThumbsDownIcon; + }; + + return ( + + ); +}; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx b/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx new file mode 100644 index 00000000000..899352dfc22 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/generated-content.tsx @@ -0,0 +1,25 @@ +import {GeneratedAnswerCitation} from '@coveo/headless'; +import {FunctionalComponent, h} from '@stencil/core'; +import {SourceCitations} from './source-citations'; + +interface GeneratedContentProps { + answer?: string; + citationsLabel: string; + citations: GeneratedAnswerCitation[]; + onCitationClick: (citation: GeneratedAnswerCitation) => void; +} + +export const GeneratedContent: FunctionalComponent = ( + props +) => ( +
+

+ {props.answer} +

+ +
+); diff --git a/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx b/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx new file mode 100644 index 00000000000..661a044cc11 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/retry-prompt.tsx @@ -0,0 +1,21 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import {Button} from '../../common/button'; + +interface RetryPromptProps { + message: string; + buttonLabel: string; + onClick: () => void; +} + +export const RetryPrompt: FunctionalComponent = (props) => ( +
+
{props.message}
+ +
+); diff --git a/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx b/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx new file mode 100644 index 00000000000..30a02e6f575 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/source-citations.tsx @@ -0,0 +1,42 @@ +import {GeneratedAnswerCitation} from '@coveo/headless'; +import {FunctionalComponent, h} from '@stencil/core'; + +interface SourceCitationsProps { + label: string; + citations: GeneratedAnswerCitation[]; + onCitationClick: (citation: GeneratedAnswerCitation) => void; +} + +export const SourceCitations: FunctionalComponent = ( + props +) => + props.citations.length ? ( + + ) : null; diff --git a/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx b/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx new file mode 100644 index 00000000000..2626a4b2be2 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-generated-answer/typing-loader.tsx @@ -0,0 +1,18 @@ +import {FunctionalComponent, h} from '@stencil/core'; + +interface TypingLoaderProps { + loadingLabel: string; +} + +export const TypingLoader: FunctionalComponent = (props) => ( +
+ +
+ {props.loadingLabel} +
+
+); diff --git a/packages/atomic/src/images/thumbs-down.svg b/packages/atomic/src/images/thumbs-down.svg new file mode 100644 index 00000000000..9d9e51007ec --- /dev/null +++ b/packages/atomic/src/images/thumbs-down.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/atomic/src/images/thumbs-up.svg b/packages/atomic/src/images/thumbs-up.svg new file mode 100644 index 00000000000..a3249bc96f7 --- /dev/null +++ b/packages/atomic/src/images/thumbs-up.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/atomic/src/locales.json b/packages/atomic/src/locales.json index c208ffc0e0b..470602bae21 100644 --- a/packages/atomic/src/locales.json +++ b/packages/atomic/src/locales.json @@ -5573,5 +5573,29 @@ "zh": "弹出式菜单以获取更多选项卡", "zh-cn": "用于更多选项卡的弹出式菜单", "zh-tw": "用於更多標籤的快顯式選單" + }, + "generated-answer-title": { + "en": "Generated answer for you", + "fr": "Réponse générée pour vous" + }, + "this-answer-was-helpful": { + "en": "This answer was helpful", + "fr": "Cette réponse m'a été utile" + }, + "this-answer-was-not-helpful": { + "en": "This answer was not helpful", + "fr": "Cette réponse ne m'a pas été utile" + }, + "generated-answer-loading": { + "en": "Looking for an answer...", + "fr": "Nous cherchons une réponse..." + }, + "retry": { + "en": "Retry", + "fr": "Réessayer" + }, + "retry-stream-message": { + "en": "Oops! Something went wrong while trying to generate an answer.", + "fr": "Oops! Quelque chose s'est mal passé lors de la tentative de génération d'une réponse." } } \ No newline at end of file diff --git a/packages/atomic/src/pages/examples/genqa.html b/packages/atomic/src/pages/examples/genqa.html new file mode 100644 index 00000000000..7557eb67c35 --- /dev/null +++ b/packages/atomic/src/pages/examples/genqa.html @@ -0,0 +1,283 @@ + + + + + + Coveo Atomic With Generative Question Answering + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + diff --git a/packages/atomic/src/pages/header.js b/packages/atomic/src/pages/header.js index bdbb8ed3fb7..5a757d47f78 100644 --- a/packages/atomic/src/pages/header.js +++ b/packages/atomic/src/pages/header.js @@ -20,6 +20,7 @@ const links = [ label: 'Accessible Commerce Full', }, {href: '/examples/ipx.html', label: 'IPX'}, + {href: '/examples/genqa.html', label: 'Gen Q&A'}, ]; const header = document.createElement('header'); diff --git a/packages/atomic/src/themes/accessible.css b/packages/atomic/src/themes/accessible.css index e9b1c210ca1..7b0943f801b 100644 --- a/packages/atomic/src/themes/accessible.css +++ b/packages/atomic/src/themes/accessible.css @@ -18,6 +18,9 @@ --atomic-error: #ce3f00; --atomic-visited: #752e9c; --atomic-disabled: #c5cacf; + --atomic-success-background: #c5f4e6; + --atomic-error-background: #f6a5a8; + --atomic-primary-background: #edf6ff; /* Border radius */ --atomic-border-radius: 0.25rem; diff --git a/packages/atomic/src/themes/coveo.css b/packages/atomic/src/themes/coveo.css index ba9ba8f7ee2..ae5e9d78af6 100644 --- a/packages/atomic/src/themes/coveo.css +++ b/packages/atomic/src/themes/coveo.css @@ -18,6 +18,9 @@ --atomic-error: #ce3f00; --atomic-visited: #752e9c; --atomic-disabled: #c5cacf; + --atomic-success-background: #c5f4e6; + --atomic-error-background: #f6a5a8; + --atomic-primary-background: #edf6ff; /* Border radius */ --atomic-border-radius: 0.25rem; From b717e184de6abc6eb9caf4ab3b8d3490b1e2e81f Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Wed, 28 Jun 2023 18:32:44 +0000 Subject: [PATCH 04/10] [Version Bump][skip ci]: ui-kit publish @coveo/headless@2.20.0 @coveo/atomic@2.33.0 @coveo/atomic-hosted-page@0.3.19 @coveo/quantic@2.26.1 @coveo/atomic-react@2.3.1 @coveo/atomic-angular@2.9.1 **/CHANGELOG.md **/package.json CHANGELOG.md package.json package-lock.json --- package-lock.json | 66 +++++++++---------- packages/atomic-angular/package.json | 6 +- .../projects/atomic-angular/package.json | 6 +- packages/atomic-hosted-page/package.json | 4 +- packages/atomic-react/package.json | 8 +-- packages/atomic/CHANGELOG.md | 6 ++ packages/atomic/package.json | 6 +- packages/headless/CHANGELOG.md | 9 +++ packages/headless/package.json | 2 +- packages/quantic/CHANGELOG.md | 2 + packages/quantic/package.json | 4 +- packages/samples/angular/package.json | 2 +- packages/samples/atomic-next/package.json | 6 +- packages/samples/atomic-react/package.json | 6 +- packages/samples/headless-react/package.json | 2 +- packages/samples/iife/package.json | 6 +- packages/samples/stencil/package.json | 4 +- packages/samples/vuejs/package.json | 4 +- 18 files changed, 83 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75a5ff26196..0a7ecec56e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48740,7 +48740,7 @@ }, "packages/atomic": { "name": "@coveo/atomic", - "version": "2.32.0", + "version": "2.33.0", "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", @@ -48759,7 +48759,7 @@ }, "devDependencies": { "@babel/core": "7.18.10", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@fullhuman/postcss-purgecss": "4.1.3", "@rollup/plugin-alias": "3.1.9", "@rollup/plugin-replace": "2.4.2", @@ -48796,7 +48796,7 @@ "node": ">=12.9.0" }, "peerDependencies": { - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" } }, "packages/atomic-angular": { @@ -48811,14 +48811,14 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic": "2.32.0", + "@coveo/atomic": "2.33.0", "rxjs": "7.5.6" }, "devDependencies": { "@angular-devkit/build-angular": "16.0.2", "@angular/cli": "16.0.2", "@angular/compiler-cli": "16.0.2", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@types/jasmine": "3.10.6", "@types/node": "12.20.55", "jasmine-core": "3.99.1", @@ -48832,7 +48832,7 @@ "typescript": "4.9.5" }, "peerDependencies": { - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" } }, "packages/atomic-angular/node_modules/@angular-devkit/build-angular": { @@ -51830,25 +51830,25 @@ }, "packages/atomic-angular/projects/atomic-angular": { "name": "@coveo/atomic-angular", - "version": "2.9.0", + "version": "2.9.1", "license": "Apache-2.0", "dependencies": { - "@coveo/atomic": "2.32.0", + "@coveo/atomic": "2.33.0", "tslib": "2.4.0" }, "peerDependencies": { "@angular/common": "14 - 16", "@angular/core": "14 - 16", - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" } }, "packages/atomic-hosted-page": { "name": "@coveo/atomic-hosted-page", - "version": "0.3.18", + "version": "0.3.19", "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3" }, "devDependencies": { @@ -51857,12 +51857,12 @@ }, "packages/atomic-react": { "name": "@coveo/atomic-react", - "version": "2.3.0", + "version": "2.3.1", "dependencies": { - "@coveo/atomic": "2.32.0" + "@coveo/atomic": "2.33.0" }, "devDependencies": { - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@rollup/plugin-commonjs": "^22.0.2", "@rollup/plugin-node-resolve": "^14.1.0", "@rollup/plugin-replace": "^4.0.0", @@ -51878,7 +51878,7 @@ "rollup-plugin-terser": "^7.0.2" }, "peerDependencies": { - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } @@ -51949,7 +51949,7 @@ }, "packages/headless": { "name": "@coveo/headless", - "version": "2.19.0", + "version": "2.20.0", "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", @@ -52011,12 +52011,12 @@ }, "packages/quantic": { "name": "@coveo/quantic", - "version": "2.26.0", + "version": "2.26.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" }, "devDependencies": { "@ckeditor/jsdoc-plugins": "25.4.5", @@ -53459,7 +53459,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic-angular": "2.9.0", + "@coveo/atomic-angular": "2.9.1", "rxjs": "7.5.6", "tslib": "2.4.0", "zone.js": "0.13.0" @@ -57026,9 +57026,9 @@ "name": "@coveo/atomic-next-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", + "@coveo/headless": "2.20.0", "next": "13.2.3", "react": "18.2.0", "react-dom": "18.2.0" @@ -57051,9 +57051,9 @@ "name": "@coveo/atomic-react-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", + "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -58010,7 +58010,7 @@ "version": "0.0.0", "dependencies": { "@coveo/auth": "1.10.7", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.3.0", "@testing-library/user-event": "14.4.3", @@ -58980,9 +58980,9 @@ "version": "0.1.0", "dependencies": { "@babel/standalone": "7.21.2", - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", + "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -59014,8 +59014,8 @@ "name": "@coveo/atomic-stencil-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3", "stencil-router-v2": "0.6.0" }, @@ -59961,8 +59961,8 @@ "name": "@coveo/atomic-vuejs-samples", "version": "0.1.0", "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/headless": "2.20.0", "core-js": "3.24.1", "vue": "3.2.37" }, diff --git a/packages/atomic-angular/package.json b/packages/atomic-angular/package.json index ab0fd8aa808..ade450dfc78 100644 --- a/packages/atomic-angular/package.json +++ b/packages/atomic-angular/package.json @@ -18,11 +18,11 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic": "2.32.0", + "@coveo/atomic": "2.33.0", "rxjs": "7.5.6" }, "peerDependencies": { - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" }, "devDependencies": { "@angular-devkit/build-angular": "16.0.2", @@ -39,6 +39,6 @@ "ncp": "2.0.0", "ng-packagr": "16.0.1", "typescript": "4.9.5", - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" } } diff --git a/packages/atomic-angular/projects/atomic-angular/package.json b/packages/atomic-angular/projects/atomic-angular/package.json index 30cff4ddadb..6cfdf8efeb0 100644 --- a/packages/atomic-angular/projects/atomic-angular/package.json +++ b/packages/atomic-angular/projects/atomic-angular/package.json @@ -1,14 +1,14 @@ { "name": "@coveo/atomic-angular", - "version": "2.9.0", + "version": "2.9.1", "license": "Apache-2.0", "peerDependencies": { "@angular/common": "14 - 16", "@angular/core": "14 - 16", - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" }, "dependencies": { - "@coveo/atomic": "2.32.0", + "@coveo/atomic": "2.33.0", "tslib": "2.4.0" } } diff --git a/packages/atomic-hosted-page/package.json b/packages/atomic-hosted-page/package.json index 29fdc9ceaca..2414f00c3e5 100644 --- a/packages/atomic-hosted-page/package.json +++ b/packages/atomic-hosted-page/package.json @@ -1,7 +1,7 @@ { "name": "@coveo/atomic-hosted-page", "description": "Web Component used to inject a Coveo Hosted Search Page in the DOM.", - "version": "0.3.18", + "version": "0.3.19", "repository": { "type": "git", "url": "https://github.com/coveo/ui-kit.git", @@ -32,7 +32,7 @@ }, "dependencies": { "@coveo/bueno": "0.43.7", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3" }, "devDependencies": { diff --git a/packages/atomic-react/package.json b/packages/atomic-react/package.json index 9a56be74222..297a66f8fcd 100644 --- a/packages/atomic-react/package.json +++ b/packages/atomic-react/package.json @@ -1,7 +1,7 @@ { "name": "@coveo/atomic-react", "sideEffects": false, - "version": "2.3.0", + "version": "2.3.1", "description": "React specific wrapper for the Atomic component library", "repository": { "type": "git", @@ -28,10 +28,10 @@ "recommendation/" ], "dependencies": { - "@coveo/atomic": "2.32.0" + "@coveo/atomic": "2.33.0" }, "devDependencies": { - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@rollup/plugin-commonjs": "^22.0.2", "@rollup/plugin-node-resolve": "^14.1.0", "@rollup/plugin-replace": "^4.0.0", @@ -47,7 +47,7 @@ "rollup-plugin-terser": "^7.0.2" }, "peerDependencies": { - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } diff --git a/packages/atomic/CHANGELOG.md b/packages/atomic/CHANGELOG.md index 54834eb4106..d9d01b98a42 100644 --- a/packages/atomic/CHANGELOG.md +++ b/packages/atomic/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.33.0 (2023-06-28) + +### Features + +- **atomic:** added Generated Answer component ([#3003](https://github.com/coveo/ui-kit/issues/3003)) ([2f2096b](https://github.com/coveo/ui-kit/commits/2f2096bfd1f2c0993cb3041eb081bbe052cfcb94)) + # 2.32.0 (2023-06-20) ### Features diff --git a/packages/atomic/package.json b/packages/atomic/package.json index 052bd557eb7..b1829a466c4 100644 --- a/packages/atomic/package.json +++ b/packages/atomic/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/atomic", - "version": "2.32.0", + "version": "2.33.0", "description": "A web-component library for building modern UIs interfacing with the Coveo platform", "homepage": "https://docs.coveo.com/en/atomic/latest/", "repository": { @@ -63,7 +63,7 @@ }, "devDependencies": { "@babel/core": "7.18.10", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@fullhuman/postcss-purgecss": "4.1.3", "@rollup/plugin-alias": "3.1.9", "@rollup/plugin-replace": "2.4.2", @@ -97,7 +97,7 @@ "tailwindcss": "3.0.23" }, "peerDependencies": { - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" }, "license": "Apache-2.0", "engines": { diff --git a/packages/headless/CHANGELOG.md b/packages/headless/CHANGELOG.md index 98330670efa..09d64960a05 100644 --- a/packages/headless/CHANGELOG.md +++ b/packages/headless/CHANGELOG.md @@ -1,3 +1,12 @@ +# 2.20.0 (2023-06-28) + +### Features + +- **headless:** Add new insight analytics actions ([#2997](https://github.com/coveo/ui-kit/issues/2997)) ([b2d549e](https://github.com/coveo/ui-kit/commits/b2d549e70287b479bcc9caa8609624e827f45cfd)) +- **headless:** added generated answer (Gen-Q&A) component ([#2995](https://github.com/coveo/ui-kit/issues/2995)) ([9e7c023](https://github.com/coveo/ui-kit/commits/9e7c023a07f46be07cd00326bf3efc23b14e6218)) +- **headless:** support date range facet exclusion ([#2998](https://github.com/coveo/ui-kit/issues/2998)) ([5a5c1f4](https://github.com/coveo/ui-kit/commits/5a5c1f4b2cd89219cb080ab0cbeca649cb003f41)) +- **headless:** support facet value exclusion for core facets ([#2989](https://github.com/coveo/ui-kit/issues/2989)) ([fbacc5e](https://github.com/coveo/ui-kit/commits/fbacc5e04bea4567e9ff42444dd14f5c7e528abe)) + # 2.19.0 (2023-06-20) ### Bug Fixes diff --git a/packages/headless/package.json b/packages/headless/package.json index 2b5e0f12219..3ca83ba8f26 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -14,7 +14,7 @@ }, "types": "./dist/definitions/index.d.ts", "license": "Apache-2.0", - "version": "2.19.0", + "version": "2.20.0", "files": [ "dist/", "recommendation/", diff --git a/packages/quantic/CHANGELOG.md b/packages/quantic/CHANGELOG.md index fa42a180c96..414a984d419 100644 --- a/packages/quantic/CHANGELOG.md +++ b/packages/quantic/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.26.1 (2023-06-28) + # 2.26.0 (2023-06-20) ### Bug Fixes diff --git a/packages/quantic/package.json b/packages/quantic/package.json index 36c43427b4b..24851f12621 100644 --- a/packages/quantic/package.json +++ b/packages/quantic/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/quantic", - "version": "2.26.0", + "version": "2.26.1", "description": "A Salesforce Lightning Web Component (LWC) library for building modern UIs interfacing with the Coveo platform", "author": "coveo.com", "homepage": "https://coveo.com", @@ -44,7 +44,7 @@ }, "dependencies": { "@coveo/bueno": "0.43.7", - "@coveo/headless": "2.19.0" + "@coveo/headless": "2.20.0" }, "engines": { "node": ">=14.0.0" diff --git a/packages/samples/angular/package.json b/packages/samples/angular/package.json index 415973a69bd..57948fcb52e 100644 --- a/packages/samples/angular/package.json +++ b/packages/samples/angular/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic-angular": "2.9.0", + "@coveo/atomic-angular": "2.9.1", "rxjs": "7.5.6", "tslib": "2.4.0", "zone.js": "0.13.0" diff --git a/packages/samples/atomic-next/package.json b/packages/samples/atomic-next/package.json index 05dcb8151da..a98db48217d 100644 --- a/packages/samples/atomic-next/package.json +++ b/packages/samples/atomic-next/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "private": true, "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", + "@coveo/headless": "2.20.0", "next": "13.2.3", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/packages/samples/atomic-react/package.json b/packages/samples/atomic-react/package.json index 01e5d3dc4e2..b797bfe95b8 100644 --- a/packages/samples/atomic-react/package.json +++ b/packages/samples/atomic-react/package.json @@ -4,9 +4,9 @@ "description": "Samples with atomic-react", "private": true, "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", + "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/packages/samples/headless-react/package.json b/packages/samples/headless-react/package.json index 6077eca2dca..90ac8983e04 100644 --- a/packages/samples/headless-react/package.json +++ b/packages/samples/headless-react/package.json @@ -5,7 +5,7 @@ "private": true, "dependencies": { "@coveo/auth": "1.10.7", - "@coveo/headless": "2.19.0", + "@coveo/headless": "2.20.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.3.0", "@testing-library/user-event": "14.4.3", diff --git a/packages/samples/iife/package.json b/packages/samples/iife/package.json index 3684020b9e7..cd65857d96a 100644 --- a/packages/samples/iife/package.json +++ b/packages/samples/iife/package.json @@ -12,9 +12,9 @@ "e2e": "cypress run --browser chrome" }, "dependencies": { - "@coveo/headless": "2.19.0", - "@coveo/atomic": "2.32.0", - "@coveo/atomic-react": "2.3.0", + "@coveo/headless": "2.20.0", + "@coveo/atomic": "2.33.0", + "@coveo/atomic-react": "2.3.1", "react": "18.2.0", "react-dom": "18.2.0", "@babel/standalone": "7.21.2" diff --git a/packages/samples/stencil/package.json b/packages/samples/stencil/package.json index ed5d65fc6f1..2fae6fc9f6d 100644 --- a/packages/samples/stencil/package.json +++ b/packages/samples/stencil/package.json @@ -8,8 +8,8 @@ "e2e:watch": "cypress open --browser chrome --e2e" }, "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3", "stencil-router-v2": "0.6.0" }, diff --git a/packages/samples/vuejs/package.json b/packages/samples/vuejs/package.json index 0265f34f734..3fee1dfd978 100644 --- a/packages/samples/vuejs/package.json +++ b/packages/samples/vuejs/package.json @@ -9,8 +9,8 @@ "build:assets": "ncp ../../atomic/dist/atomic/assets public/assets && ncp ../../atomic/dist/atomic/lang public/lang" }, "dependencies": { - "@coveo/atomic": "2.32.0", - "@coveo/headless": "2.19.0", + "@coveo/atomic": "2.33.0", + "@coveo/headless": "2.20.0", "core-js": "3.24.1", "vue": "3.2.37" }, From 48a95dc823408b9a4f251510a8536b12539f38c2 Mon Sep 17 00:00:00 2001 From: karoomoogon-coveo <123362004+karoomoogon-coveo@users.noreply.github.com> Date: Wed, 28 Jun 2023 15:28:52 -0400 Subject: [PATCH 05/10] feat(atomic): mark atomic-insight-result-children as internal (#3001) https://coveord.atlassian.net/browse/KIT-2570 --- .../atomic-angular.module.ts | 2 -- .../src/lib/stencil-generated/components.ts | 21 ------------------- .../src/components/stencil-generated/index.ts | 1 - .../atomic-insight-result-children.tsx | 8 +------ 4 files changed, 1 insertion(+), 31 deletions(-) diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts index f8cd854d437..0438eb47e36 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts @@ -22,7 +22,6 @@ AtomicFormatUnit, AtomicFrequentlyBoughtTogether, AtomicHtml, AtomicIcon, -AtomicInsightResultChildren, AtomicLayoutSection, AtomicLoadMoreChildrenResults, AtomicLoadMoreResults, @@ -116,7 +115,6 @@ AtomicFormatUnit, AtomicFrequentlyBoughtTogether, AtomicHtml, AtomicIcon, -AtomicInsightResultChildren, AtomicLayoutSection, AtomicLoadMoreChildrenResults, AtomicLoadMoreResults, diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts index 5b0531a7d74..40b059d14ca 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts @@ -358,27 +358,6 @@ export class AtomicIcon { } -export declare interface AtomicInsightResultChildren extends Components.AtomicInsightResultChildren {} - -@ProxyCmp({ - defineCustomElementFn: undefined, - inputs: ['imageSize', 'inheritTemplates', 'noResultText'] -}) -@Component({ - selector: 'atomic-insight-result-children', - changeDetection: ChangeDetectionStrategy.OnPush, - template: '', - inputs: ['imageSize', 'inheritTemplates', 'noResultText'] -}) -export class AtomicInsightResultChildren { - protected el: HTMLElement; - constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { - c.detach(); - this.el = r.nativeElement; - } -} - - export declare interface AtomicLayoutSection extends Components.AtomicLayoutSection {} @ProxyCmp({ diff --git a/packages/atomic-react/src/components/stencil-generated/index.ts b/packages/atomic-react/src/components/stencil-generated/index.ts index 55ba7c2e90b..298ba4a0e89 100644 --- a/packages/atomic-react/src/components/stencil-generated/index.ts +++ b/packages/atomic-react/src/components/stencil-generated/index.ts @@ -24,7 +24,6 @@ export const AtomicFormatUnit = /*@__PURE__*/createReactComponent('atomic-frequently-bought-together'); export const AtomicHtml = /*@__PURE__*/createReactComponent('atomic-html'); export const AtomicIcon = /*@__PURE__*/createReactComponent('atomic-icon'); -export const AtomicInsightResultChildren = /*@__PURE__*/createReactComponent('atomic-insight-result-children'); export const AtomicLayoutSection = /*@__PURE__*/createReactComponent('atomic-layout-section'); export const AtomicLoadMoreChildrenResults = /*@__PURE__*/createReactComponent('atomic-load-more-children-results'); export const AtomicLoadMoreResults = /*@__PURE__*/createReactComponent('atomic-load-more-results'); diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx index c9d117691d5..3319548d6f4 100644 --- a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx @@ -31,13 +31,7 @@ const childTemplateComponent = 'atomic-insight-result-children-template'; const componentTag = 'atomic-insight-result-children'; /** - * The `atomic-result-children` component is responsible for displaying child results by applying one or more child result templates. - * Includes two slots, "before-children" and "after-children", which allow for rendering content before and after the list of children, - * only when children exist. - * @part no-result-root - The wrapper for the message when there are no results. - * @part show-hide-button - The button that allows to collapse or show all child results. - * @slot before-children - Slot that allows rendering content before the list of children, only when children exist. - * @slot after-children - Slot that allows rendering content after the list of children, only when children exist. + * @internal */ @Component({ tag: 'atomic-insight-result-children', From a317e7f645f8ca4ba9fdd2aa7a465a2493dde6fe Mon Sep 17 00:00:00 2001 From: Etienne Rocheleau Date: Thu, 29 Jun 2023 11:02:26 -0400 Subject: [PATCH 06/10] fix(SFINT-5094): Attempt to fix the quantic package release (#3006) SFINT-5094 * fix package release * reject * more console logging * order --- packages/quantic/scripts/build/util/sfdx.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/quantic/scripts/build/util/sfdx.ts b/packages/quantic/scripts/build/util/sfdx.ts index edbd2a21ecb..c7477d5c1af 100644 --- a/packages/quantic/scripts/build/util/sfdx.ts +++ b/packages/quantic/scripts/build/util/sfdx.ts @@ -26,18 +26,22 @@ export function sfdx(command: string): Promise { maxBuffer: 1024 * 1024 * 1.5, }, (error, stdout) => { - if (error) { - let jsonOutput; + let jsonOutput: unknown; + if (stdout) { try { jsonOutput = JSON.parse(strip(stdout)); - } catch (e) { + if (error) { + console.error(jsonOutput); + console.error(error); + reject(jsonOutput || error); + } + resolve(jsonOutput as T); + } catch (err) { // The output is not JSON. The command most likely failed outside the SFDX CLI. + console.log(stdout); + reject(err); } - - reject(jsonOutput || error); } - - resolve(stdout ? (JSON.parse(strip(stdout)) as T) : null); } ); }); From 0fbc55dace9ab1180d2c97b898a06dc4790d9b73 Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Thu, 29 Jun 2023 16:22:40 +0000 Subject: [PATCH 07/10] [Version Bump][skip ci]: ui-kit publish @coveo/atomic@2.34.0 @coveo/quantic@2.26.2 @coveo/atomic-react@2.4.0 @coveo/atomic-angular@2.10.0 **/CHANGELOG.md **/package.json CHANGELOG.md package.json package-lock.json --- package-lock.json | 32 +++++++++---------- packages/atomic-angular/package.json | 2 +- .../projects/atomic-angular/CHANGELOG.md | 6 ++++ .../projects/atomic-angular/package.json | 4 +-- packages/atomic-react/CHANGELOG.md | 6 ++++ packages/atomic-react/package.json | 4 +-- packages/atomic/CHANGELOG.md | 6 ++++ packages/atomic/package.json | 2 +- packages/quantic/CHANGELOG.md | 6 ++++ packages/quantic/package.json | 2 +- packages/samples/angular/package.json | 2 +- packages/samples/atomic-next/package.json | 4 +-- packages/samples/atomic-react/package.json | 4 +-- packages/samples/iife/package.json | 4 +-- packages/samples/stencil/package.json | 2 +- packages/samples/vuejs/package.json | 2 +- 16 files changed, 56 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a7ecec56e4..e76c952e1d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48740,7 +48740,7 @@ }, "packages/atomic": { "name": "@coveo/atomic", - "version": "2.33.0", + "version": "2.34.0", "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.43.7", @@ -48811,7 +48811,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "rxjs": "7.5.6" }, "devDependencies": { @@ -51830,10 +51830,10 @@ }, "packages/atomic-angular/projects/atomic-angular": { "name": "@coveo/atomic-angular", - "version": "2.9.1", + "version": "2.10.0", "license": "Apache-2.0", "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "tslib": "2.4.0" }, "peerDependencies": { @@ -51857,9 +51857,9 @@ }, "packages/atomic-react": { "name": "@coveo/atomic-react", - "version": "2.3.1", + "version": "2.4.0", "dependencies": { - "@coveo/atomic": "2.33.0" + "@coveo/atomic": "2.34.0" }, "devDependencies": { "@coveo/headless": "2.20.0", @@ -52011,7 +52011,7 @@ }, "packages/quantic": { "name": "@coveo/quantic", - "version": "2.26.1", + "version": "2.26.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -53459,7 +53459,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic-angular": "2.9.1", + "@coveo/atomic-angular": "2.10.0", "rxjs": "7.5.6", "tslib": "2.4.0", "zone.js": "0.13.0" @@ -57026,8 +57026,8 @@ "name": "@coveo/atomic-next-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "@coveo/headless": "2.20.0", "next": "13.2.3", "react": "18.2.0", @@ -57051,8 +57051,8 @@ "name": "@coveo/atomic-react-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" @@ -58980,8 +58980,8 @@ "version": "0.1.0", "dependencies": { "@babel/standalone": "7.21.2", - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" @@ -59014,7 +59014,7 @@ "name": "@coveo/atomic-stencil-samples", "version": "0.0.0", "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3", "stencil-router-v2": "0.6.0" @@ -59961,7 +59961,7 @@ "name": "@coveo/atomic-vuejs-samples", "version": "0.1.0", "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "@coveo/headless": "2.20.0", "core-js": "3.24.1", "vue": "3.2.37" diff --git a/packages/atomic-angular/package.json b/packages/atomic-angular/package.json index ade450dfc78..d15105b7d86 100644 --- a/packages/atomic-angular/package.json +++ b/packages/atomic-angular/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "rxjs": "7.5.6" }, "peerDependencies": { diff --git a/packages/atomic-angular/projects/atomic-angular/CHANGELOG.md b/packages/atomic-angular/projects/atomic-angular/CHANGELOG.md index d5c3e211705..514f8a2b182 100644 --- a/packages/atomic-angular/projects/atomic-angular/CHANGELOG.md +++ b/packages/atomic-angular/projects/atomic-angular/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.10.0 (2023-06-29) + +### Features + +- **atomic:** mark atomic-insight-result-children as internal ([#3001](https://github.com/coveo/ui-kit/issues/3001)) ([48a95dc](https://github.com/coveo/ui-kit/commits/48a95dc823408b9a4f251510a8536b12539f38c2)) + # 2.9.0 (2023-06-20) ### Features diff --git a/packages/atomic-angular/projects/atomic-angular/package.json b/packages/atomic-angular/projects/atomic-angular/package.json index 6cfdf8efeb0..d0ea8565cf4 100644 --- a/packages/atomic-angular/projects/atomic-angular/package.json +++ b/packages/atomic-angular/projects/atomic-angular/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/atomic-angular", - "version": "2.9.1", + "version": "2.10.0", "license": "Apache-2.0", "peerDependencies": { "@angular/common": "14 - 16", @@ -8,7 +8,7 @@ "@coveo/headless": "2.20.0" }, "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "tslib": "2.4.0" } } diff --git a/packages/atomic-react/CHANGELOG.md b/packages/atomic-react/CHANGELOG.md index ff92d05a806..d5cbafd7734 100644 --- a/packages/atomic-react/CHANGELOG.md +++ b/packages/atomic-react/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.4.0 (2023-06-29) + +### Features + +- **atomic:** mark atomic-insight-result-children as internal ([#3001](https://github.com/coveo/ui-kit/issues/3001)) ([48a95dc](https://github.com/coveo/ui-kit/commits/48a95dc823408b9a4f251510a8536b12539f38c2)) + # 2.3.0 (2023-06-20) ### Bug Fixes diff --git a/packages/atomic-react/package.json b/packages/atomic-react/package.json index 297a66f8fcd..2174597c3e2 100644 --- a/packages/atomic-react/package.json +++ b/packages/atomic-react/package.json @@ -1,7 +1,7 @@ { "name": "@coveo/atomic-react", "sideEffects": false, - "version": "2.3.1", + "version": "2.4.0", "description": "React specific wrapper for the Atomic component library", "repository": { "type": "git", @@ -28,7 +28,7 @@ "recommendation/" ], "dependencies": { - "@coveo/atomic": "2.33.0" + "@coveo/atomic": "2.34.0" }, "devDependencies": { "@coveo/headless": "2.20.0", diff --git a/packages/atomic/CHANGELOG.md b/packages/atomic/CHANGELOG.md index d9d01b98a42..4e2f425e988 100644 --- a/packages/atomic/CHANGELOG.md +++ b/packages/atomic/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.34.0 (2023-06-29) + +### Features + +- **atomic:** mark atomic-insight-result-children as internal ([#3001](https://github.com/coveo/ui-kit/issues/3001)) ([48a95dc](https://github.com/coveo/ui-kit/commits/48a95dc823408b9a4f251510a8536b12539f38c2)) + # 2.33.0 (2023-06-28) ### Features diff --git a/packages/atomic/package.json b/packages/atomic/package.json index b1829a466c4..12f0299f3fd 100644 --- a/packages/atomic/package.json +++ b/packages/atomic/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/atomic", - "version": "2.33.0", + "version": "2.34.0", "description": "A web-component library for building modern UIs interfacing with the Coveo platform", "homepage": "https://docs.coveo.com/en/atomic/latest/", "repository": { diff --git a/packages/quantic/CHANGELOG.md b/packages/quantic/CHANGELOG.md index 414a984d419..97e4125ae8b 100644 --- a/packages/quantic/CHANGELOG.md +++ b/packages/quantic/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.26.2 (2023-06-29) + +### Bug Fixes + +- **SFINT-5094:** Attempt to fix the quantic package release ([#3006](https://github.com/coveo/ui-kit/issues/3006)) ([a317e7f](https://github.com/coveo/ui-kit/commits/a317e7f645f8ca4ba9fdd2aa7a465a2493dde6fe)) + ## 2.26.1 (2023-06-28) # 2.26.0 (2023-06-20) diff --git a/packages/quantic/package.json b/packages/quantic/package.json index 24851f12621..467fe7638b2 100644 --- a/packages/quantic/package.json +++ b/packages/quantic/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/quantic", - "version": "2.26.1", + "version": "2.26.2", "description": "A Salesforce Lightning Web Component (LWC) library for building modern UIs interfacing with the Coveo platform", "author": "coveo.com", "homepage": "https://coveo.com", diff --git a/packages/samples/angular/package.json b/packages/samples/angular/package.json index 57948fcb52e..82e8f782b1e 100644 --- a/packages/samples/angular/package.json +++ b/packages/samples/angular/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "16.0.2", "@angular/platform-browser-dynamic": "16.0.2", "@angular/router": "16.0.2", - "@coveo/atomic-angular": "2.9.1", + "@coveo/atomic-angular": "2.10.0", "rxjs": "7.5.6", "tslib": "2.4.0", "zone.js": "0.13.0" diff --git a/packages/samples/atomic-next/package.json b/packages/samples/atomic-next/package.json index a98db48217d..8d6537fb530 100644 --- a/packages/samples/atomic-next/package.json +++ b/packages/samples/atomic-next/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "@coveo/headless": "2.20.0", "next": "13.2.3", "react": "18.2.0", diff --git a/packages/samples/atomic-react/package.json b/packages/samples/atomic-react/package.json index b797bfe95b8..3fd1801f4ec 100644 --- a/packages/samples/atomic-react/package.json +++ b/packages/samples/atomic-react/package.json @@ -4,8 +4,8 @@ "description": "Samples with atomic-react", "private": true, "dependencies": { - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "@coveo/headless": "2.20.0", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/packages/samples/iife/package.json b/packages/samples/iife/package.json index cd65857d96a..1fdf4ad356a 100644 --- a/packages/samples/iife/package.json +++ b/packages/samples/iife/package.json @@ -13,8 +13,8 @@ }, "dependencies": { "@coveo/headless": "2.20.0", - "@coveo/atomic": "2.33.0", - "@coveo/atomic-react": "2.3.1", + "@coveo/atomic": "2.34.0", + "@coveo/atomic-react": "2.4.0", "react": "18.2.0", "react-dom": "18.2.0", "@babel/standalone": "7.21.2" diff --git a/packages/samples/stencil/package.json b/packages/samples/stencil/package.json index 2fae6fc9f6d..5e350b3e956 100644 --- a/packages/samples/stencil/package.json +++ b/packages/samples/stencil/package.json @@ -8,7 +8,7 @@ "e2e:watch": "cypress open --browser chrome --e2e" }, "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "@coveo/headless": "2.20.0", "@stencil/core": "2.17.3", "stencil-router-v2": "0.6.0" diff --git a/packages/samples/vuejs/package.json b/packages/samples/vuejs/package.json index 3fee1dfd978..131d7a87790 100644 --- a/packages/samples/vuejs/package.json +++ b/packages/samples/vuejs/package.json @@ -9,7 +9,7 @@ "build:assets": "ncp ../../atomic/dist/atomic/assets public/assets && ncp ../../atomic/dist/atomic/lang public/lang" }, "dependencies": { - "@coveo/atomic": "2.33.0", + "@coveo/atomic": "2.34.0", "@coveo/headless": "2.20.0", "core-js": "3.24.1", "vue": "3.2.37" From 3b668a2d18fcd979f64429076a53e0ab9180d84a Mon Sep 17 00:00:00 2001 From: Nathan Lafrance-Berger Date: Thu, 29 Jun 2023 14:48:17 -0400 Subject: [PATCH 08/10] chore(ci): limiting number of quantic package versions retrieved (#3007) * chore(ci): limiting number of quantic package versions retrieved * chore(ci): arg name --- packages/quantic/scripts/build/create-package.ts | 4 ++-- packages/quantic/scripts/build/util/sfdx-commands.ts | 8 ++++++-- packages/quantic/scripts/build/util/sfdx.ts | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/quantic/scripts/build/create-package.ts b/packages/quantic/scripts/build/create-package.ts index 9bcb948d05c..3eac786e7aa 100644 --- a/packages/quantic/scripts/build/create-package.ts +++ b/packages/quantic/scripts/build/create-package.ts @@ -34,7 +34,7 @@ function ensureEnvVariables() { } async function getMatchingPackageVersion(versionNumber: string) { - return (await sfdx.getPackageVersionList()).result.find( + return (await sfdx.getPackageVersionList(30)).result.find( (pkg) => pkg.Version === versionNumber || pkg.Version.slice(0, -2) === versionNumber @@ -151,7 +151,7 @@ async function createGithubDiscussionPost( packageVersionId: string ) { const token = process.env.GITHUB_TOKEN || ''; - const packageDetails = (await sfdx.getPackageVersionList()).result.find( + const packageDetails = (await sfdx.getPackageVersionList(0)).result.find( (pack) => pack.SubscriberPackageVersionId === packageVersionId ); const discussionTitle = `${packageDetails.Package2Name} v${options.packageVersion}`; diff --git a/packages/quantic/scripts/build/util/sfdx-commands.ts b/packages/quantic/scripts/build/util/sfdx-commands.ts index d419a3f317d..4943ffd8014 100644 --- a/packages/quantic/scripts/build/util/sfdx-commands.ts +++ b/packages/quantic/scripts/build/util/sfdx-commands.ts @@ -327,6 +327,10 @@ export async function promotePackageVersion( ); } -export async function getPackageVersionList(): Promise { - return await sfdx('force:package:version:list'); +export async function getPackageVersionList( + createdLastDays: number +): Promise { + return await sfdx( + `force:package:version:list -c ${createdLastDays}` + ); } diff --git a/packages/quantic/scripts/build/util/sfdx.ts b/packages/quantic/scripts/build/util/sfdx.ts index c7477d5c1af..9662c253433 100644 --- a/packages/quantic/scripts/build/util/sfdx.ts +++ b/packages/quantic/scripts/build/util/sfdx.ts @@ -31,8 +31,10 @@ export function sfdx(command: string): Promise { try { jsonOutput = JSON.parse(strip(stdout)); if (error) { - console.error(jsonOutput); - console.error(error); + console.error({ + error, + jsonOutput, + }); reject(jsonOutput || error); } resolve(jsonOutput as T); From f2c08547f125849ab6da18a9cd7ea457c03eb166 Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Thu, 29 Jun 2023 19:09:39 +0000 Subject: [PATCH 09/10] [Version Bump][skip ci]: ui-kit publish @coveo/quantic@2.26.3 **/CHANGELOG.md **/package.json CHANGELOG.md package.json package-lock.json --- package-lock.json | 2 +- packages/quantic/CHANGELOG.md | 2 ++ packages/quantic/package.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e76c952e1d7..442beb79568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52011,7 +52011,7 @@ }, "packages/quantic": { "name": "@coveo/quantic", - "version": "2.26.2", + "version": "2.26.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/packages/quantic/CHANGELOG.md b/packages/quantic/CHANGELOG.md index 97e4125ae8b..f78a8869626 100644 --- a/packages/quantic/CHANGELOG.md +++ b/packages/quantic/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.26.3 (2023-06-29) + ## 2.26.2 (2023-06-29) ### Bug Fixes diff --git a/packages/quantic/package.json b/packages/quantic/package.json index 467fe7638b2..9a38981ed0d 100644 --- a/packages/quantic/package.json +++ b/packages/quantic/package.json @@ -1,6 +1,6 @@ { "name": "@coveo/quantic", - "version": "2.26.2", + "version": "2.26.3", "description": "A Salesforce Lightning Web Component (LWC) library for building modern UIs interfacing with the Coveo platform", "author": "coveo.com", "homepage": "https://coveo.com", From a1e4492354b4228af86c69dd8351b8ae8d4a8ea6 Mon Sep 17 00:00:00 2001 From: Simon Milord Date: Mon, 3 Jul 2023 13:24:02 -0400 Subject: [PATCH 10/10] fix(quantic): Changed the quantic_OpensInSalesforceSubTab to something more generic (#3005) * changed the quantic_OpensInSalesforceSubTab label to something more generic * renamed label name to match value * reverting label name, keeping salesforce in value * [skip ci]: lock master * removed lock --------- Co-authored-by: developer-experience-bot[bot] <91079284+developer-experience-bot[bot]@users.noreply.github.com> Co-authored-by: Benjamin Taillon --- .../main/default/labels/CustomLabels.labels-meta.xml | 4 ++-- .../force-app/main/translations/fr.translation-meta.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml index 13472818892..58362623435 100644 --- a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml +++ b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml @@ -1094,10 +1094,10 @@ quantic_OpensInSalesforceSubTab - Opens in a Salesforce console subtab + Opens in a Salesforce subtab en_US false - Opens in a Salesforce console subtab + Opens in a Salesforce subtab quantic_OpensInBrowserTab diff --git a/packages/quantic/force-app/main/translations/fr.translation-meta.xml b/packages/quantic/force-app/main/translations/fr.translation-meta.xml index d7d34c12b56..d974b7902f6 100644 --- a/packages/quantic/force-app/main/translations/fr.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/fr.translation-meta.xml @@ -593,7 +593,7 @@ quantic_NoRelatedItems - + quantic_OpensInSalesforceSubTab