Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(headless): creation of the new headless insight user actions controller #4192

Merged
merged 11 commits into from
Jul 22, 2024
14 changes: 14 additions & 0 deletions packages/bueno/src/values/string-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
15 changes: 14 additions & 1 deletion packages/bueno/src/values/string-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ interface StringValueConfig<T extends string> extends ValueConfig<T> {
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<T extends string = string>
implements SchemaValue<string>
Expand All @@ -27,7 +30,7 @@ export class StringValue<T extends string = string>
}

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;
Expand Down Expand Up @@ -58,6 +61,16 @@ export class StringValue<T extends string = string>
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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,
},
});
});
Expand Down
40 changes: 39 additions & 1 deletion packages/headless/src/api/service/insight/insight-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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.");
}
Expand All @@ -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.");
}
};
Original file line number Diff line number Diff line change
@@ -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<InsightUserActionsRequest>;

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,
},
};
};
Original file line number Diff line number Diff line change
@@ -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<UserAction>;
}

type UserActionType = 'SEARCH' | 'CLICK' | 'VIEW' | 'CUSTOM';
interface UserAction {
actionType: UserActionType;
timestamp: Date;
raw: Record<string, string>;
searchHub?: string;
document?: string;
query?: string;
name: UserActionType;
time: string;
value: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 = {
userId: exampleUserId,
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({
userId: exampleUserId,
ticketCreationDate: exampleTicketCreationDate,
excludedCustomActions: exampleExcludedCustomActions,
});
});

it('#fetchUserActions dispatches #fetchUserActions', () => {
userActions.fetchUserActions();
expect(fetchUserActions).toHaveBeenCalled();
});
});
Loading
Loading