From b52953e2053ba7fd4b9fe4bec35000075fc95af5 Mon Sep 17 00:00:00 2001 From: mmitiche <86681870+mmitiche@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:07:42 -0400 Subject: [PATCH] feat(headless): creation of the new headless insight user actions controller (#4192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # [SFINT-5613](https://coveord.atlassian.net/browse/SFINT-5613) ## Overview about the user actions feature: The user actions feature allows the agent using the Insight Panel to check all the actions that have beed made by a user who created a case, these knowledge about the actions performed by the user should help the agent to understand the context of the case and issue the user is facing and help him solve the case more quickly. From a UI perspective, that's what this feature look like: Screenshot 2024-07-16 at 9 21 10 AM ## From where we get the user actions data: We get the user action data from the the following machine learning API: [/rest/organizations/{organizationId}/machinelearning/user/actions]( https://platformdev.cloud.coveo.com/docs?urls.primaryName=Machine%20Learning#/User%20Profiles/rest_organizations_paramId_machinelearning_user_actions_post) ## Example request: ``` curl -X 'POST' \ 'https://platform.cloud.coveo.com/rest/organizations/MyOrgId/machinelearning/user/actions' \ -H 'accept: */*' \ -H 'Authorization: Bearer XXXXXXX' \ -H 'Content-Type: application/json' \ -d '{ "objectId": "MyUserId" }' ``` ## Example response: ``` { "executionTime":0.011054937, "modelSubType":"userActionHistory", "modelType":"Profile", "responseTime":1721136841424, "value":[ { "name":"SEARCH", "value":"{\"cause\":\"userActionLoad\",\"origin_level_1\":\"coveoLightningInsightPanel\",\"origin_level_2\":\"default\"}", "time":"1721012966908" }, { "name":"CUSTOM", "value":"{\"event_type\":\"User Actions\",\"event_value\":\"openUserActions\",\"origin_level_1\":\"coveoLightningInsightPanel\",\"origin_level_2\":\"default\"}", "time":"1721012966186" }, .... ] ``` ## What is being done in the Headless library: ### In this first iteration(this PR): - Created the Headless user actions controller. - Created the Headless user actions dispatchable actions. - Created the Headless user actions slice. - Created the Headless user actions state. - Updated the the insight API client to target the correct user actions endpoint. - Unit tests created. ### In a future iteration(a following PR): - Will add the logic that preprocess the data returned by the API, this logic will organize and filter the data to make it ready to display by the future UI components: https://coveord.atlassian.net/browse/SFINT-5639 - Expose the Headless user actions controller. - Add the and expose the headless user action actions loader. - Add more unit tests. [SFINT-5613]: https://coveord.atlassian.net/browse/SFINT-5613?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../bueno/src/values/string-value.test.ts | 14 ++ packages/bueno/src/values/string-value.ts | 15 +- .../insight/insight-api-client.test.ts | 45 ++---- .../src/api/service/insight/insight-params.ts | 40 ++++- .../user-actions/user-actions-request.ts | 49 +----- .../user-actions/user-actions-response.ts | 24 +-- .../headless-user-actions.test.ts | 74 +++++++++ .../user-actions/headless-user-actions.ts | 90 +++++++++++ .../insight-user-actions-actions.ts | 66 ++++++++ .../insight-user-actions-request.test.ts | 39 +++++ .../insight-user-actions-request.ts | 14 ++ .../insight-user-actions-slice.test.ts | 142 ++++++++++++++++++ .../insight-user-actions-slice.ts | 32 ++++ .../insight-user-actions-state.ts | 70 +++++++++ .../headless/src/state/insight-app-state.ts | 4 +- packages/headless/src/state/state-sections.ts | 8 + .../headless/src/test/mock-insight-state.ts | 2 + 17 files changed, 631 insertions(+), 97 deletions(-) create mode 100644 packages/headless/src/controllers/insight/user-actions/headless-user-actions.test.ts create mode 100644 packages/headless/src/controllers/insight/user-actions/headless-user-actions.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-actions.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-request.test.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-request.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-slice.test.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-slice.ts create mode 100644 packages/headless/src/features/insight-user-actions/insight-user-actions-state.ts diff --git a/packages/bueno/src/values/string-value.test.ts b/packages/bueno/src/values/string-value.test.ts index 87c4042524e..1634e3d3181 100644 --- a/packages/bueno/src/values/string-value.test.ts +++ b/packages/bueno/src/values/string-value.test.ts @@ -89,5 +89,19 @@ describe('string value', () => { value = new StringValue({regex: /ab/}); expect(value.validate('cd')).toContain('ab'); }); + + it(`when ISODate is true + when passing an invalid date string + it returns an error description`, () => { + value = new StringValue({ISODate: true}); + expect(value.validate('hello')).not.toBeNull(); + }); + + it(`when ISODate is true + when passing a valid URL value + it returns null`, () => { + value = new StringValue({ISODate: true}); + expect(value.validate('2024-07-16T22:02:06.553Z')).toBeNull(); + }); }); }); diff --git a/packages/bueno/src/values/string-value.ts b/packages/bueno/src/values/string-value.ts index a7fa5e9c789..78f9a04d452 100644 --- a/packages/bueno/src/values/string-value.ts +++ b/packages/bueno/src/values/string-value.ts @@ -6,11 +6,14 @@ interface StringValueConfig extends ValueConfig { url?: boolean; regex?: RegExp; constrainTo?: readonly T[]; + ISODate?: boolean; } // Source: https://github.com/jquery-validation/jquery-validation/blob/c1db10a34c0847c28a5bd30e3ee1117e137ca834/src/core.js#L1349 const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i; +const ISODateStringRegex = + /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i; export class StringValue implements SchemaValue @@ -27,7 +30,7 @@ export class StringValue } public validate(value: T) { - const {emptyAllowed, url, regex, constrainTo} = this.config; + const {emptyAllowed, url, regex, constrainTo, ISODate} = this.config; const valueValidation = this.value.validate(value); if (valueValidation) { return valueValidation; @@ -58,6 +61,16 @@ export class StringValue return `value should be one of: ${values}.`; } + if ( + ISODate && + !( + ISODateStringRegex.test(value) && + new Date(value).toString() !== 'Invalid Date' + ) + ) { + return 'value is not a valid ISO8601 date string'; + } + return null; } diff --git a/packages/headless/src/api/service/insight/insight-api-client.test.ts b/packages/headless/src/api/service/insight/insight-api-client.test.ts index 9e701a30511..776f543c4b7 100644 --- a/packages/headless/src/api/service/insight/insight-api-client.test.ts +++ b/packages/headless/src/api/service/insight/insight-api-client.test.ts @@ -4,12 +4,16 @@ import {NoopPreprocessRequest} from '../../preprocess-request'; import {InsightAPIClient} from './insight-api-client'; describe('insight api client', () => { - const insightRequest = { + const configuration = { accessToken: 'some token', - insightId: 'some insight id', organizationId: 'some organization id', url: 'https://some.platform.com', }; + const insightRequest = { + ...configuration, + insightId: 'some insight id', + }; + const exampleUserId = 'John Doe'; let client: InsightAPIClient; @@ -192,12 +196,8 @@ describe('insight api client', () => { describe('userActions', () => { const userActionsRequest = { - ...insightRequest, - ticketCreationDate: new Date().toISOString(), - numberSessionsBefore: 50, - numberSessionsAfter: 250, - maximumSessionInactivityMinutes: 60, - excludedCustomActions: ['unknown', 'irrelevant'], + ...configuration, + userId: exampleUserId, }; it('should call the platform endpoint with the correct arguments', async () => { @@ -211,35 +211,10 @@ describe('insight api client', () => { accessToken: userActionsRequest.accessToken, method: 'POST', contentType: 'application/json', - url: `${userActionsRequest.url}/rest/organizations/${userActionsRequest.organizationId}/insight/v1/configs/${userActionsRequest.insightId}/useractions`, + url: `${userActionsRequest.url}/rest/organizations/${userActionsRequest.organizationId}/machinelearning/user/actions`, origin: 'insightApiFetch', requestParams: { - ticketCreationDate: userActionsRequest.ticketCreationDate, - numberSessionsBefore: userActionsRequest.numberSessionsBefore, - numberSessionsAfter: userActionsRequest.numberSessionsAfter, - maximumSessionInactivityMinutes: - userActionsRequest.maximumSessionInactivityMinutes, - excludedCustomActions: userActionsRequest.excludedCustomActions, - }, - }); - }); - - it('should call the platform endpoint with the default values when not specified', async () => { - const callSpy = setupCallMock(true, 'some content'); - - await client.userActions({ - ...insightRequest, - ticketCreationDate: new Date().toISOString(), - }); - - expect(callSpy).toHaveBeenCalled(); - const request = callSpy.mock.calls[0][0]; - expect(request).toMatchObject({ - requestParams: { - numberSessionsBefore: 50, - numberSessionsAfter: 50, - maximumSessionInactivityMinutes: 30, - excludedCustomActions: [], + objectId: userActionsRequest.userId, }, }); }); diff --git a/packages/headless/src/api/service/insight/insight-params.ts b/packages/headless/src/api/service/insight/insight-params.ts index d7effd4df94..4d1351f167d 100644 --- a/packages/headless/src/api/service/insight/insight-params.ts +++ b/packages/headless/src/api/service/insight/insight-params.ts @@ -5,6 +5,7 @@ import { PlatformClientCallOptions, } from '../../platform-client'; import {BaseParam} from '../../platform-service-params'; +import {InsightUserActionsRequest} from './user-actions/user-actions-request'; export interface InsightIdParam { insightId: string; @@ -17,6 +18,9 @@ export const baseInsightUrl = (req: InsightParam, path?: string) => req.insightId }${path ?? ''}`; +export const baseInsightUserActionsUrl = (req: InsightUserActionsRequest) => + `${req.url}/rest/organizations/${req.organizationId}/machinelearning/user/actions`; + export const baseInsightRequest = ( req: InsightParam, method: HttpMethods, @@ -39,12 +43,33 @@ export const baseInsightRequest = ( }; }; +export const baseInsightUserActionRequest = ( + req: InsightUserActionsRequest, + method: HttpMethods, + contentType: HTTPContentType +): Pick< + PlatformClientCallOptions, + 'accessToken' | 'method' | 'contentType' | 'url' | 'origin' +> => { + validateInsightUserActionRequestParams(req); + + const baseUrl = baseInsightUserActionsUrl(req); + + return { + accessToken: req.accessToken, + method, + contentType, + url: baseUrl, + origin: 'insightApiFetch', + }; +}; + export const pickNonInsightParams = (req: InsightParam) => { const {insightId, ...nonInsightParams} = pickNonBaseParams(req); return nonInsightParams; }; -const validateInsightRequestParams = (req: InsightParam) => { +const validateConfigParams = (req: BaseParam) => { if (!req.url) { throw new Error("The 'url' attribute must contain a valid platform URL."); } @@ -58,9 +83,22 @@ const validateInsightRequestParams = (req: InsightParam) => { "The 'accessToken' attribute must contain a valid platform access token." ); } +}; + +const validateInsightRequestParams = (req: InsightParam) => { + validateConfigParams(req); if (!req.insightId) { throw new Error( "The 'insightId' attribute must contain a valid Insight Panel configuration ID." ); } }; + +const validateInsightUserActionRequestParams = ( + req: InsightUserActionsRequest +) => { + validateConfigParams(req); + if (!req.userId) { + throw new Error("The 'userId' attribute must contain a valid user ID."); + } +}; diff --git a/packages/headless/src/api/service/insight/user-actions/user-actions-request.ts b/packages/headless/src/api/service/insight/user-actions/user-actions-request.ts index 8b0f94b1b36..f3f11532583 100644 --- a/packages/headless/src/api/service/insight/user-actions/user-actions-request.ts +++ b/packages/headless/src/api/service/insight/user-actions/user-actions-request.ts @@ -1,55 +1,22 @@ -import { - baseInsightRequest, - InsightParam, - pickNonInsightParams, -} from '../insight-params'; +import {BaseParam} from '../../../platform-service-params'; +import {baseInsightUserActionRequest} from '../insight-params'; -export type InsightUserActionsRequest = InsightParam & - TicketCreationDateParam & - NumberSessionsBeforeParam & - NumberSessionsAfterParam & - MaximumSessionInactivityMinutesParam & - ExcludedCustomActionsParam; +export type InsightUserActionsRequest = BaseParam & UserIdParam; -interface TicketCreationDateParam { +interface UserIdParam { /** - * The ticket creation date in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + * Identifier of the user from which Clicked Documents are shown. */ - ticketCreationDate: string; -} - -interface NumberSessionsBeforeParam { - numberSessionsBefore?: number; -} - -interface NumberSessionsAfterParam { - numberSessionsAfter?: number; -} - -interface MaximumSessionInactivityMinutesParam { - maximumSessionInactivityMinutes?: number; -} - -interface ExcludedCustomActionsParam { - excludedCustomActions?: string[]; + userId: string; } export const buildInsightUserActionsRequest = ( req: InsightUserActionsRequest ) => { - const params = pickNonInsightParams( - req - ) as Partial; - return { - ...baseInsightRequest(req, 'POST', 'application/json', '/useractions'), + ...baseInsightUserActionRequest(req, 'POST', 'application/json'), requestParams: { - ticketCreationDate: params.ticketCreationDate, - numberSessionsBefore: params.numberSessionsBefore ?? 50, - numberSessionsAfter: params.numberSessionsAfter ?? 50, - maximumSessionInactivityMinutes: - params.maximumSessionInactivityMinutes ?? 30, - excludedCustomActions: params.excludedCustomActions ?? [], + objectId: req.userId, }, }; }; diff --git a/packages/headless/src/api/service/insight/user-actions/user-actions-response.ts b/packages/headless/src/api/service/insight/user-actions/user-actions-response.ts index f80cc3b16f6..45dd88f16cc 100644 --- a/packages/headless/src/api/service/insight/user-actions/user-actions-response.ts +++ b/packages/headless/src/api/service/insight/user-actions/user-actions-response.ts @@ -1,23 +1,11 @@ -export interface InsightUserActionsResponse { - timeline?: UserActionTimeline; -} +import {UserActionType} from '../../../../features/insight-user-actions/insight-user-actions-state'; -interface UserActionTimeline { - sessions: UserSession[]; -} - -interface UserSession { - start: Date; - end: Date; - actions: UserAction[]; +export interface InsightUserActionsResponse { + value: Array; } -type UserActionType = 'SEARCH' | 'CLICK' | 'VIEW' | 'CUSTOM'; interface UserAction { - actionType: UserActionType; - timestamp: Date; - raw: Record; - searchHub?: string; - document?: string; - query?: string; + name: UserActionType; + time: string; + value: string; } diff --git a/packages/headless/src/controllers/insight/user-actions/headless-user-actions.test.ts b/packages/headless/src/controllers/insight/user-actions/headless-user-actions.test.ts new file mode 100644 index 00000000000..2287446c6e0 --- /dev/null +++ b/packages/headless/src/controllers/insight/user-actions/headless-user-actions.test.ts @@ -0,0 +1,74 @@ +import {configuration} from '../../../app/common-reducers'; +import { + fetchUserActions, + registerUserActions, +} from '../../../features/insight-user-actions/insight-user-actions-actions'; +import {insightUserActionsReducer} from '../../../features/insight-user-actions/insight-user-actions-slice'; +import { + buildMockInsightEngine, + MockedInsightEngine, +} from '../../../test/mock-engine-v2'; +import {buildMockInsightState} from '../../../test/mock-insight-state'; +import { + UserActions, + UserActionsOptions, + buildUserActions, +} from './headless-user-actions'; + +jest.mock( + '../../../features/insight-user-actions/insight-user-actions-actions' +); + +describe('UserActions', () => { + let engine: MockedInsightEngine; + + let options: UserActionsOptions; + let userActions: UserActions; + + function initUserActions() { + userActions = buildUserActions(engine, {options}); + } + + const exampleUserId = 'John Doe'; + const exampleTicketCreationDate = '2024-07-16T21:00:42.741Z'; + const exampleExcludedCustomActions = ['badAction']; + + beforeEach(() => { + options = { + ticketCreationDate: exampleTicketCreationDate, + excludedCustomActions: exampleExcludedCustomActions, + }; + engine = buildMockInsightEngine(buildMockInsightState()); + + initUserActions(); + }); + + it('initializes', () => { + expect(userActions).toBeTruthy(); + }); + + it('it adds the correct reducers to the engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({ + configuration, + insightuserActions: insightUserActionsReducer, + }); + }); + + it('exposes a subscribe method', () => { + expect(userActions.subscribe).toBeTruthy(); + }); + + it('registers and updates the state with the given options', () => { + expect(registerUserActions).toHaveBeenCalled(); + expect(registerUserActions).toHaveBeenCalledWith({ + ticketCreationDate: exampleTicketCreationDate, + excludedCustomActions: exampleExcludedCustomActions, + }); + }); + + it('#fetchUserActions dispatches #fetchUserActions', () => { + userActions.fetchUserActions(exampleUserId); + expect(fetchUserActions).toHaveBeenCalled(); + expect(fetchUserActions).toHaveBeenCalledWith(exampleUserId); + }); +}); diff --git a/packages/headless/src/controllers/insight/user-actions/headless-user-actions.ts b/packages/headless/src/controllers/insight/user-actions/headless-user-actions.ts new file mode 100644 index 00000000000..214fea9ec51 --- /dev/null +++ b/packages/headless/src/controllers/insight/user-actions/headless-user-actions.ts @@ -0,0 +1,90 @@ +import {configuration} from '../../../app/common-reducers'; +import {InsightEngine} from '../../../app/insight-engine/insight-engine'; +import { + fetchUserActions, + registerUserActions, +} from '../../../features/insight-user-actions/insight-user-actions-actions'; +import {insightUserActionsReducer} from '../../../features/insight-user-actions/insight-user-actions-slice'; +import {UserActionsState} from '../../../features/insight-user-actions/insight-user-actions-state'; +import { + ConfigurationSection, + InsightUserActionSection, +} from '../../../state/state-sections'; +import {loadReducerError} from '../../../utils/errors'; +import { + buildController, + Controller, +} from '../../controller/headless-controller'; + +export type {UserActionsState} from '../../../features/insight-user-actions/insight-user-actions-state'; + +export interface UserActionsProps { + /** + * The options for the `UserActions` controller. + */ + options: UserActionsOptions; +} + +export interface UserActionsOptions { + /** + * The ticket creation date in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + */ + ticketCreationDate: string; + /** + * The names of custom events to exclude. + * @defaultValue `[]` + */ + excludedCustomActions?: string[]; +} + +/** + * The UserActions controller is responsible for fetching user actions surrounding a given case creation event. + */ +export interface UserActions extends Controller { + /** + * Fetch the list of user actions surrounding the ticket creation. + * @param userId The user ID to which the user's actions belong. + */ + fetchUserActions(userId: string): void; + /** + * The state of the UserActions controller. + */ + state: UserActionsState; +} + +export function buildUserActions( + engine: InsightEngine, + props: UserActionsProps +): UserActions { + if (!loadUserActionsReducers(engine)) { + throw loadReducerError; + } + + const {dispatch} = engine; + const getState = () => engine.state.insightUserAction; + const controller = buildController(engine); + const {ticketCreationDate, excludedCustomActions} = props.options; + + dispatch(registerUserActions({ticketCreationDate, excludedCustomActions})); + return { + ...controller, + + get state() { + return getState(); + }, + + fetchUserActions(userId: string) { + dispatch(fetchUserActions(userId)); + }, + }; +} + +function loadUserActionsReducers( + engine: InsightEngine +): engine is InsightEngine { + engine.addReducers({ + configuration, + insightuserActions: insightUserActionsReducer, + }); + return true; +} diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-actions.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-actions.ts new file mode 100644 index 00000000000..823705509c3 --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-actions.ts @@ -0,0 +1,66 @@ +import {ArrayValue, StringValue} from '@coveo/bueno'; +import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import {isErrorResponse} from '../../api/search/search-api-client'; +import {AsyncThunkInsightOptions} from '../../api/service/insight/insight-api-client'; +import {InsightUserActionsResponse} from '../../api/service/insight/user-actions/user-actions-response'; +import { + ConfigurationSection, + InsightUserActionSection, +} from '../../state/state-sections'; +import {nonEmptyString, validatePayload} from '../../utils/validate-payload'; +import {buildFetchUserActionsRequest} from './insight-user-actions-request'; + +interface RegisterUserActionsPayload { + ticketCreationDate: string; + excludedCustomActions?: string[]; +} + +const registerUserActionsPayloadSchema = { + ticketCreationDate: new StringValue({ + emptyAllowed: false, + ISODate: true, + }), + excludedCustomActions: new ArrayValue({ + required: false, + each: nonEmptyString, + }), +}; + +export const registerUserActions = createAction( + 'insight/userActions/registerUserActions', + (payload: RegisterUserActionsPayload) => + validatePayload(payload, registerUserActionsPayloadSchema) +); + +export interface FetchUserActionsThunkReturn { + /** The successful user actions response. */ + response: InsightUserActionsResponse; +} + +export type StateNeededByFetchUserActions = ConfigurationSection & + InsightUserActionSection; + +export type UserId = string; + +export const fetchUserActions = createAsyncThunk< + FetchUserActionsThunkReturn, + UserId, + AsyncThunkInsightOptions +>( + 'insight/userActions/fetch', + async (userId, {getState, rejectWithValue, extra: {apiClient}}) => { + const state = getState(); + + const fetched = await apiClient.userActions( + await buildFetchUserActionsRequest(state, userId) + ); + + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); + } + + return { + response: fetched.success, + }; + } +); diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-request.test.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-request.test.ts new file mode 100644 index 00000000000..3fccb1ffbaf --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-request.test.ts @@ -0,0 +1,39 @@ +import {InsightAppState} from '../../state/insight-app-state'; +import {buildMockInsightState} from '../../test/mock-insight-state'; +import {buildFetchUserActionsRequest} from './insight-user-actions-request'; + +describe('insight user actions request', () => { + let state: InsightAppState; + const exampleUserId = 'John Doe'; + + beforeEach(() => { + state = buildMockInsightState(); + }); + + it('#buildFetchUserActionsRequest returns the state #accessToken', async () => { + state.configuration.accessToken = 'xxx-access-token'; + const params = await buildFetchUserActionsRequest(state, exampleUserId); + + expect(params.accessToken).toBe(state.configuration.accessToken); + }); + + it('#buildFetchUserActionsRequest returns the state #organizationId', async () => { + state.configuration.organizationId = 'example org id'; + const params = await buildFetchUserActionsRequest(state, exampleUserId); + + expect(params.organizationId).toBe(state.configuration.organizationId); + }); + + it('#buildFetchUserActionsRequest returns the state #platformUrl', async () => { + state.configuration.platformUrl = 'https://platform.coveo.com'; + const params = await buildFetchUserActionsRequest(state, exampleUserId); + + expect(params.url).toBe(state.configuration.platformUrl); + }); + + it('#buildFetchUserActionsRequest returns the state #userId', async () => { + const params = await buildFetchUserActionsRequest(state, exampleUserId); + + expect(params.userId).toBe(exampleUserId); + }); +}); diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-request.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-request.ts new file mode 100644 index 00000000000..680ed782889 --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-request.ts @@ -0,0 +1,14 @@ +import {InsightUserActionsRequest} from '../../api/service/insight/user-actions/user-actions-request'; +import {StateNeededByFetchUserActions} from './insight-user-actions-actions'; + +export const buildFetchUserActionsRequest = async ( + state: StateNeededByFetchUserActions, + userId: string +): Promise => { + return { + accessToken: state.configuration.accessToken, + organizationId: state.configuration.organizationId, + url: state.configuration.platformUrl, + userId, + }; +}; diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.test.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.test.ts new file mode 100644 index 00000000000..e5834dcaec9 --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.test.ts @@ -0,0 +1,142 @@ +import { + fetchUserActions, + registerUserActions, +} from './insight-user-actions-actions'; +import {insightUserActionsReducer} from './insight-user-actions-slice'; +import {getInsightUserActionsInitialState} from './insight-user-actions-state'; + +describe('insight user actions slice', () => { + const requestId = 'some-request-id'; + const exampleUserId = 'John Doe'; + const exampleTicketCreationDate = '2024-07-16T20:05:13.741Z'; + + const errorResponse = { + message: 'something bad happened', + statusCode: 400, + type: 'badluck', + }; + + it('should have an initial state', () => { + expect(insightUserActionsReducer(undefined, {type: 'foo'})).toEqual( + getInsightUserActionsInitialState() + ); + }); + + describe('#fetchUserActions', () => { + it('should set #loading to #true when fetching the user actions', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + fetchUserActions.pending(exampleUserId, requestId) + ); + + expect(modifiedState.loading).toBe(true); + }); + + it('should clear the #error when fetching the user actions', () => { + const errorState = { + ...getInsightUserActionsInitialState(), + error: errorResponse, + }; + + const modifiedState = insightUserActionsReducer( + errorState, + fetchUserActions.pending(exampleUserId, requestId) + ); + + expect(modifiedState.error).toBeUndefined(); + }); + + describe('when fetching user actions fails', () => { + const failedAction = { + type: fetchUserActions.rejected.type, + payload: errorResponse, + }; + + it('should set #loading to #false', () => { + const modifiedState = insightUserActionsReducer( + { + ...getInsightUserActionsInitialState(), + loading: true, + }, + failedAction + ); + + expect(modifiedState.loading).toBe(false); + }); + + it('should set #error', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + failedAction + ); + + expect(modifiedState.error).toStrictEqual(errorResponse); + }); + + it('should set #timeline to #undefined', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + failedAction + ); + + expect(modifiedState.timeline).toBeUndefined(); + }); + }); + + describe('when fetching the user actions succeeds', () => { + it('should set #loading to #false', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + fetchUserActions.fulfilled( + {response: {value: []}}, + exampleUserId, + requestId + ) + ); + + expect(modifiedState.loading).toBe(false); + }); + + it('should set #error to #undefined', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + fetchUserActions.fulfilled( + {response: {value: []}}, + exampleUserId, + requestId + ) + ); + + expect(modifiedState.error).toBeUndefined(); + }); + }); + }); + + describe('registerUserActions', () => { + it('should set the #ticketCreationDate', () => { + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + registerUserActions({ + ticketCreationDate: exampleTicketCreationDate, + }) + ); + + expect(modifiedState.ticketCreationDate).toBe(exampleTicketCreationDate); + }); + + it('should set valid #excludedCustomActions', () => { + const testExcludedCustomActions = ['badAction']; + const modifiedState = insightUserActionsReducer( + getInsightUserActionsInitialState(), + registerUserActions({ + ticketCreationDate: exampleTicketCreationDate, + excludedCustomActions: testExcludedCustomActions, + }) + ); + + expect(modifiedState.excludedCustomActions).toBe( + testExcludedCustomActions + ); + }); + }); +}); diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.ts new file mode 100644 index 00000000000..22956d875b2 --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-slice.ts @@ -0,0 +1,32 @@ +import {createReducer} from '@reduxjs/toolkit'; +import { + fetchUserActions, + registerUserActions, +} from './insight-user-actions-actions'; +import {getInsightUserActionsInitialState} from './insight-user-actions-state'; + +export const insightUserActionsReducer = createReducer( + getInsightUserActionsInitialState(), + (builder) => { + builder + .addCase(registerUserActions, (state, action) => { + state.ticketCreationDate = action.payload.ticketCreationDate; + if (action.payload.excludedCustomActions) { + state.excludedCustomActions = action.payload.excludedCustomActions; + } + }) + .addCase(fetchUserActions.pending, (state) => { + state.loading = true; + state.error = undefined; + }) + .addCase(fetchUserActions.rejected, (state, action) => { + state.loading = false; + state.error = action.payload; + }) + .addCase(fetchUserActions.fulfilled, (state, _action) => { + state.loading = false; + state.error = undefined; + // TODO: SFINT-5639 Preprocess the user actions data returned from the API and set the state. + }); + } +); diff --git a/packages/headless/src/features/insight-user-actions/insight-user-actions-state.ts b/packages/headless/src/features/insight-user-actions/insight-user-actions-state.ts new file mode 100644 index 00000000000..b5ed7243624 --- /dev/null +++ b/packages/headless/src/features/insight-user-actions/insight-user-actions-state.ts @@ -0,0 +1,70 @@ +import {InsightAPIErrorStatusResponse} from '../../api/service/insight/insight-api-client'; + +export interface UserActionTimeline { + precedingSessions: UserSession[]; + session: UserSession; + followingSessions: UserSession[]; +} + +export interface UserSession { + start: string; + end: string; + actions: UserAction[]; +} + +export interface UserAction { + actionType: UserActionType; + timestamp: string; + eventData: { + type: string; + value?: string; + }; + cause?: string; + searchHub?: string; + document?: { + title: string; + clickUri: string; + uriHash?: string; + contentIdKey?: string; + contentIdValue?: string; + }; + query?: string; +} + +export enum UserActionType { + SEARCH = 'SEARCH', + CLICK = 'CLICK', + VIEW = 'VIEW', + CUSTOM = 'CUSTOM', +} + +export interface UserActionsState { + /** + * The timeline of user actions. + */ + timeline?: UserActionTimeline; + /** + * The names of custom actions to exclude from the user actions. + */ + excludedCustomActions: string[]; + /** + * The ticket creation date in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + */ + ticketCreationDate?: string; + /** + * `true` if fetching the user actions is in progress and `false` otherwise. + */ + loading: boolean; + /** + * The error response if fetching the user actions failed. + */ + error?: InsightAPIErrorStatusResponse; +} + +export function getInsightUserActionsInitialState(): UserActionsState { + return { + timeline: undefined, + excludedCustomActions: [], + loading: false, + }; +} diff --git a/packages/headless/src/state/insight-app-state.ts b/packages/headless/src/state/insight-app-state.ts index e01979e479e..935ec276afd 100644 --- a/packages/headless/src/state/insight-app-state.ts +++ b/packages/headless/src/state/insight-app-state.ts @@ -27,6 +27,7 @@ import { FoldingSection, GeneratedAnswerSection, ContextSection, + InsightUserActionSection, } from './state-sections'; export type InsightSearchParametersState = FacetSection & @@ -60,4 +61,5 @@ export type InsightAppState = InsightSearchParametersState & QuestionAnsweringSection & FoldingSection & GeneratedAnswerSection & - ContextSection; + ContextSection & + InsightUserActionSection; diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index b34f5983729..d570771e07d 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -40,6 +40,7 @@ import {GeneratedAnswerState} from '../features/generated-answer/generated-answe 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'; +import {UserActionsState} from '../features/insight-user-actions/insight-user-actions-state'; import {InstantResultsState} from '../features/instant-results/instant-results-state'; import {PaginationState} from '../features/pagination/pagination-state'; import {OldProductListingState} from '../features/product-listing/old-product-listing-state'; @@ -504,6 +505,13 @@ export interface GeneratedAnswerSection { generatedAnswer: GeneratedAnswerState; } +export interface InsightUserActionSection { + /** + * The insight user action state. + */ + insightUserAction: UserActionsState; +} + export interface ManualRangeSection { manualNumericFacetSet: ManualNumericFacetSetState; } diff --git a/packages/headless/src/test/mock-insight-state.ts b/packages/headless/src/test/mock-insight-state.ts index 1c00ca8dba6..337c7853a8e 100644 --- a/packages/headless/src/test/mock-insight-state.ts +++ b/packages/headless/src/test/mock-insight-state.ts @@ -14,6 +14,7 @@ import {getFoldingInitialState} from '../features/folding/folding-state'; import {getGeneratedAnswerInitialState} from '../features/generated-answer/generated-answer-state'; import {getInsightConfigurationInitialState} from '../features/insight-configuration/insight-configuration-state'; import {getInsightInterfaceInitialState} from '../features/insight-interface/insight-interface-state'; +import {getInsightUserActionsInitialState} from '../features/insight-user-actions/insight-user-actions-state'; import {getPaginationInitialState} from '../features/pagination/pagination-state'; import {getQuerySetInitialState} from '../features/query-set/query-set-state'; import {getQuerySuggestSetInitialState} from '../features/query-suggest/query-suggest-state'; @@ -59,6 +60,7 @@ export function buildMockInsightState( folding: getFoldingInitialState(), generatedAnswer: getGeneratedAnswerInitialState(), context: getContextInitialState(), + insightUserAction: getInsightUserActionsInitialState(), ...config, }; }