diff --git a/package-lock.json b/package-lock.json index c69685b0..66ceb558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13929,6 +13929,201 @@ "@babel/types": "^7.16.7" } }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", + "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", + "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", + "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.7.tgz", + "integrity": "sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.16.7.tgz", + "integrity": "sha512-gaqtLDxJEFCeQbYp9aLAefjhkKdjKcdh6DB7jniIGU3Pz52WAmP268zK0VgPz9hUNkMSYeH976K2/Y6yPadpng==", + "dev": true, + "optional": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", + "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true, + "optional": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", + "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", + "dev": true, + "optional": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-wrap-function": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helper-replace-supers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", + "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "dev": true, + "optional": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-simple-access": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.16.7.tgz", + "integrity": "sha512-ZIzHVyoeLMvXMN/vok/a4LWRy8G2v205mNP0XOuf9XRLyX5/u9CnVulUtDgUTama3lT+bf/UqucuZjqiGuTS1g==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", + "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", + "dev": true, + "optional": true, + "requires": { + "@babel/types": "^7.16.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true, + "optional": true + }, + "@babel/helper-wrap-function": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", + "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", + "dev": true, + "optional": true, + "requires": { + "@babel/helper-function-name": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helpers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.16.7.tgz", + "integrity": "sha512-9ZDoqtfY7AuEOt3cxchfii6C7GDyyMBffktR5B2jvWv8u2+efwvpnVKXMWzNehqy68tKgAfSwfdw/lWpthS2bw==", + "dev": true, + "optional": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, "@babel/highlight": { "version": "7.16.10", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", @@ -19391,6 +19586,16 @@ "has": "^1.0.3" } }, + "is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "dev": true, + "optional": true, + "requires": { + "has": "^1.0.3" + } + }, "is-data-descriptor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", @@ -22766,6 +22971,12 @@ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "dev": true }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, "source-map-resolve": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", @@ -24681,6 +24892,16 @@ "fd-slicer": "~1.1.0" } }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yeast": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", diff --git a/src/components/UserActions/ClickedDocumentList.ts b/src/components/UserActions/ClickedDocumentList.ts index 7feacfe4..ad08f52f 100644 --- a/src/components/UserActions/ClickedDocumentList.ts +++ b/src/components/UserActions/ClickedDocumentList.ts @@ -23,7 +23,7 @@ export interface IClickedDocumentList { /** * Number of Clicked Documents shown. * - * Default: `4` + * Default: `3` * Minimum: `1` */ numberOfItems: number; @@ -31,7 +31,7 @@ export interface IClickedDocumentList { /** * Label of the list of Clicked Documents. * - * Default: `Recent Clicked Documents` + * Default: `Most Recent Clicked Documents` */ listLabel: string; @@ -63,11 +63,11 @@ export class ClickedDocumentList extends Component { */ static readonly options: IClickedDocumentList = { numberOfItems: ComponentOptions.buildNumberOption({ - defaultValue: 4, + defaultValue: 3, min: 1, }), listLabel: ComponentOptions.buildStringOption({ - defaultValue: 'Recent Clicked Documents', + defaultValue: 'Most Recent Clicked Documents', }), userId: ComponentOptions.buildStringOption({ required: true }), template: ComponentOptions.buildTemplateOption({ diff --git a/src/components/UserActions/QueryList.ts b/src/components/UserActions/QueryList.ts index 588b710a..8c8390c6 100644 --- a/src/components/UserActions/QueryList.ts +++ b/src/components/UserActions/QueryList.ts @@ -30,7 +30,7 @@ export interface IQueryListOptions { /** * Number of User Queries shown. * - * Default: `4` + * Default: `3` * Minimum: `1` */ numberOfItems: number; @@ -38,7 +38,7 @@ export interface IQueryListOptions { /** * Label of the list of User Queries. * - * Default: `Recent Queries` + * Default: `Most Recent Queries` */ listLabel: string; @@ -73,13 +73,13 @@ export class QueryList extends Component { */ static readonly options: IQueryListOptions = { numberOfItems: ComponentOptions.buildNumberOption({ - defaultValue: 4, + defaultValue: 3, min: 1, required: true, }), listLabel: ComponentOptions.buildStringOption({ - defaultValue: 'Recent Queries', + defaultValue: 'Most Recent Queries', }), transform: ComponentOptions.buildCustomOption<(query: string) => Promise>(DEFAULT_TRANSFORMATION, { diff --git a/src/components/UserActions/Strings.ts b/src/components/UserActions/Strings.ts index fb6febc3..1b4365c1 100644 --- a/src/components/UserActions/Strings.ts +++ b/src/components/UserActions/Strings.ts @@ -23,4 +23,12 @@ Translation.register(Language.English, { UserActivity_click: 'Clicked Document', UserActivity_view: 'Page View', UserActivity_custom: 'Custom Action', + UserActivity_ticketCreated: 'Ticket Created', + UserActivity_showNewSession: 'Show new session', + UserActivity_showPastSession: 'Show past session', + UserActivity_showMore: 'Show More', + UserActivity_showMoreActions: 'More Actions', + UserActivity_session: 'Session', + UserActivity_emptySearch: 'Empty Search', + UserActivity_invalidDate: 'Invalid date for ticket creation', }); diff --git a/src/components/UserActions/UserActions.scss b/src/components/UserActions/UserActions.scss index 059c9b93..d6ac06c7 100644 --- a/src/components/UserActions/UserActions.scss +++ b/src/components/UserActions/UserActions.scss @@ -6,9 +6,14 @@ $path-line-width: 0.05em; $headers-gap: 2em; $dot-width: $headers-gap / 3; $font-size: 1em; +$font-size-titles: 1.2em; +$font-size-small: 0.8em; $icon-size: 1em; $search-ui-dropdown-header-color: #1d4f76; $timestamp-grey: #67768b; +$background-color-white: #fff; +$background-color-grey: #f7f8f9; +$clickable-blue: #004990; %bold-blue-text { color: $coveo-blue-6; @@ -26,6 +31,7 @@ $timestamp-grey: #67768b; .CoveoUserActions { display: block; } + .coveo-main-section { display: none; } @@ -35,9 +41,8 @@ $timestamp-grey: #67768b; display: none; margin-top: 10px; max-width: 1200px; - padding: 0 1em; - border: 1px solid $heather; - background-color: $grey-1; + padding: 0; + background-color: $background-color-white; > h1 { color: $coveo-blue-6; @@ -78,7 +83,7 @@ $timestamp-grey: #67768b; padding-bottom: 1.5em; .coveo-title { - font-size: 1em; + font-size: $font-size-small; font-weight: normal; margin: 0 0 0.75em; text-transform: uppercase; @@ -116,7 +121,7 @@ $timestamp-grey: #67768b; } .coveo-more-less { - font-size: 0.9em; + font-size: $font-size-small; border: 0; padding: 0; color: $coveo-blue-6; @@ -142,6 +147,7 @@ $timestamp-grey: #67768b; .coveo-cell { margin-right: 2em; + &:last-child { margin-right: 0em; } @@ -163,12 +169,11 @@ $timestamp-grey: #67768b; } $folded-grey: $grey-4; + $primary-grey: #626971; .coveo-folded { cursor: pointer; border: 0; - border-top: thin solid $folded-grey; - border-bottom: thin solid $folded-grey; padding: 0.5em 0; text-align: center; @@ -187,10 +192,29 @@ $timestamp-grey: #67768b; height: 1em; padding: 0 0.5em; background-color: $grey-1; + color: $clickable-blue; } } } + .coveo-folded-actions { + cursor: pointer; + padding-top: 0.5em; + padding-bottom: 0.8em; + display: flex; + + .coveo-text { + margin-left: 0.5em; + color: $clickable-blue; + font-size: $font-size-small; + } + + & > .coveo-icon > svg { + font-size: 0.5em; + fill: $clickable-blue; + } + } + .coveo-activity-title-section { display: table; table-layout: fixed; @@ -206,7 +230,7 @@ $timestamp-grey: #67768b; .coveo-activity-timestamp { box-sizing: border-box; - font-size: 0.85em; + font-size: $font-size-small; color: $timestamp-grey; vertical-align: top; text-align: right; @@ -214,6 +238,25 @@ $timestamp-grey: #67768b; } } + .coveo-case-creation-action { + .coveo-activity-title { + font-weight: bold; + } + + .coveo-icon { + background-color: $background-color-grey; + } + } + + .coveo-session-container { + .coveo-session-header { + font-style: italic; + font-weight: 700; + color: $primary-grey; + padding: 0.5em 0; + } + } + .coveo-bubble { height: 1.5em; border-left: thin solid $coveo-blue-6; @@ -228,6 +271,7 @@ $timestamp-grey: #67768b; word-wrap: break-word; text-decoration: none; + &:hover { text-decoration: underline; } @@ -265,18 +309,42 @@ $timestamp-grey: #67768b; padding-bottom: 0; border-left: 0; } + + .coveo-footer { + font-size: $font-size-small; + color: $primary-grey; + padding-bottom: 1.7em; + } + + .coveo-caption-for-icon { + font-size: $font-size-small; + display: none; + background: #263e55; + color: white; + border-radius: 2px; + padding: 6px 16px; + position: absolute; + white-space: nowrap; + z-index: 1; + } + + .coveo-caption-for-icon { + display: inline; + } } } } .coveo-accordion { - &:first-child { - border-top: 0; + padding: 0 1em; + border: 1px solid $heather; + border-radius: 2px; + background-color: $background-color-grey; - .coveo-accordion-header { - border-top: 0; - } + &:first-child { + margin-bottom: 1em; } + &-header { cursor: pointer; display: flex; @@ -286,11 +354,10 @@ $timestamp-grey: #67768b; align-items: baseline; justify-content: space-between; - border-top: 1px solid $heather; padding: 1em 0; &-title { - font-size: 1.5em; + font-size: $font-size-titles; font-weight: bold; text-align: left; width: auto; @@ -340,7 +407,7 @@ $timestamp-grey: #67768b; } .coveo-dropdown-header { - font-size: 12px; + font-size: $font-size-small; display: inline-block; padding: 0 7px; height: 22px; @@ -352,9 +419,11 @@ $timestamp-grey: #67768b; color: $search-ui-dropdown-header-color; cursor: pointer; text-transform: uppercase; + p { line-height: 16px; } + * { display: inline-block; margin: 0; diff --git a/src/components/UserActions/UserActions.ts b/src/components/UserActions/UserActions.ts index 4fce393a..c854a7fd 100644 --- a/src/components/UserActions/UserActions.ts +++ b/src/components/UserActions/UserActions.ts @@ -54,7 +54,7 @@ export interface IUserActionsOptions { /** * Label of the activity section. * - * Default: `User Recent Activity` + * Default: `User Activity Timeline` */ activityLabel: string; @@ -105,7 +105,7 @@ export class UserActions extends Component { defaultValue: 'Session Summary', }), activityLabel: ComponentOptions.buildStringOption({ - defaultValue: "User's Recent Activity", + defaultValue: 'User Activity Timeline', }), viewedByCustomer: ComponentOptions.buildBooleanOption({ defaultValue: true, diff --git a/src/components/UserActions/UserActivity.ts b/src/components/UserActions/UserActivity.ts index 6b31c5aa..24368b19 100644 --- a/src/components/UserActions/UserActivity.ts +++ b/src/components/UserActions/UserActivity.ts @@ -1,11 +1,17 @@ import { Component, IComponentBindings, Initialization, ComponentOptions, l, get } from 'coveo-search-ui'; -import { formatTime, formatDate, formatDateAndTime, formatDateAndTimeShort, formatTimeInterval } from '../../utils/time'; -import { UserAction, UserProfileModel } from '../../models/UserProfileModel'; -import { duplicate, search, view, dot } from '../../utils/icons'; +import { formatTime, formatDate } from '../../utils/time'; +import { UserAction, UserProfileModel, UserActionSession } from '../../models/UserProfileModel'; +import { duplicate, search, view, dot, flag } from '../../utils/icons'; import { UserActionType } from '../../rest/UserProfilingEndpoint'; -import { MANUAL_SEARCH_EVENT_CAUSE } from '../../utils/events'; import './Strings'; +const MSEC_IN_SECOND = 1000; +const SECONDS_IN_MINUTE = 60; +const MAX_MINUTES_IN_SESSION = 30; +const MAX_MSEC_IN_SESSION = MAX_MINUTES_IN_SESSION * SECONDS_IN_MINUTE * MSEC_IN_SECOND; +const SESSION_BEFORE_TO_DISPLAY = 2; +const SESSION_AFTER_TO_DISPLAY = 2; + /** * Initialization options of the **UserActivity** class. */ @@ -18,69 +24,52 @@ export interface IUserActivityOptions { userId: string; /** - * List of event cause to unfold. - * This option override the **unfoldExclude** option. - * - * Default: `['didyoumeanAutomatic','didyoumeanClick','omniboxAnalytics','omniboxFromLink','searchboxSubmit','searchFromLink','userActionsSubmit']` - */ - unfoldInclude: string[]; - - /** - * List of event cause to fold. - * This option is override by the **unfoldInclude** option. + * Identifies whenever the current Ticket was created to place the creation event in the timeline. + * If it is provided, the timeline will focus on that event, + * showing the session that corresponds to the date and time, if it is available, as well as 2 sessions more recent, + * and 2 sessions prior to the case creation session. + * If it is not provided or if the date is too old for the User Actions available, + * the timeline will show the last 5 sessions available focusing on the most recent session. * - * Default: `[]` + * The format can either be + * - an epoch number (number of milliseconds since January 1, 1970), + * - a Date string that can be parsed by JavaScript's Date object + * - a JavaScript Date */ - unfoldExclude: string[]; + ticketCreationDateTime: Date; } const MAIN_CLASS = 'coveo-user-activity'; -const CELL_CLASS = 'coveo-cell'; -const TITLE_CLASS = 'coveo-title'; -const DATA_CLASS = 'coveo-data'; const ORIGIN_CLASS = 'coveo-footer'; const ACTIVITY_TITLE_SECTION = 'coveo-activity-title-section'; const ACTIVITY_TITLE_CLASS = 'coveo-activity-title'; -const ACTIVIY_TIMESTAMP_CLASS = 'coveo-activity-timestamp'; -const HEADER_CLASS = 'coveo-header'; const ACTIVITY_CLASS = 'coveo-activity'; - +const EVENT_CLASS = 'coveo-action'; const CLICK_EVENT_CLASS = 'coveo-click'; const SEARCH_EVENT_CLASS = 'coveo-search'; const CUSTOM_EVENT_CLASS = 'coveo-custom'; const VIEW_EVENT_CLASS = 'coveo-view'; const FOLDED_CLASS = 'coveo-folded'; +const FOLDED_ACTIONS_CLASS = 'coveo-folded-actions'; const TEXT_CLASS = 'coveo-text'; const ICON_CLASS = 'coveo-icon'; -const BUBBLE_CLASS = 'coveo-bubble'; - -const WIDTH_CUTOFF = 350; +const CASE_CREATION_ACTION_CLASS = 'coveo-case-creation-action'; export class UserActivity extends Component { static readonly ID = 'UserActivity'; static readonly options: IUserActivityOptions = { userId: ComponentOptions.buildStringOption({ required: true }), - unfoldInclude: ComponentOptions.buildListOption({ - defaultValue: [ - 'didyoumeanAutomatic', - 'didyoumeanClick', - 'omniboxAnalytics', - 'omniboxFromLink', - 'searchboxSubmit', - 'searchFromLink', - 'userActionsSubmit', - ], - required: true, - }), - unfoldExclude: ComponentOptions.buildListOption({ - defaultValue: [], - required: true, + ticketCreationDateTime: ComponentOptions.buildCustomOption((value: string) => UserActivity.parseDate(value), { + required: false, }), }; private static clickable_uri_ids = ['@clickableuri']; - private actions: UserAction[]; - private foldedActions: UserAction[]; + private sessions: UserActionSession[]; + private sessionsToDisplay: UserActionSession[]; + private caseSubmitSession: UserActionSession; + private caseSubmitSessionIndex: number; + private hasExpandedActions: boolean = false; private userProfileModel: UserProfileModel; /** @@ -95,6 +84,10 @@ export class UserActivity extends Component { this.options = ComponentOptions.initComponentOptions(element, UserActivity, options); + if (typeof this.options.ticketCreationDateTime === 'string' || typeof this.options.ticketCreationDateTime === 'number') { + this.options.ticketCreationDateTime = UserActivity.parseDate(this.options.ticketCreationDateTime); + } + if (!this.options.userId) { this.disable(); return; @@ -103,126 +96,187 @@ export class UserActivity extends Component { this.userProfileModel = get(this.root, UserProfileModel) as UserProfileModel; this.userProfileModel.getActions(this.options.userId).then((actions) => { - this.actions = actions.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - this.foldedActions = this.actions.filter((action) => !this.isUnfoldByDefault(action)); + const sortMostRecentFirst = (a: UserAction, b: UserAction) => b.timestamp.getTime() - a.timestamp.getTime(); + + const sortedActions = actions.sort(sortMostRecentFirst); + this.sessions = this.splitActionsBySessions(sortedActions); + + this.buildSessionsToDisplay(); + this.render(); }); } - private isUnfoldByDefault(action: UserAction) { - const isCustom = action.type === UserActionType.Custom; - const isSearch = action.type === UserActionType.Search; - const isClick = action.type === UserActionType.Click; + public static parseDate(value: string | number): Date | null { + try { + return new Date(value); + } catch (e) { + console.warn(l(`${UserActivity.ID}_invalidDate`) + ` '${value}'`); + return null; + } + } - const cause = (isCustom && action.raw.event_value) || (isSearch && action.raw.cause) || ''; + private isPartOfTheSameSession = (action: UserAction, previousDateTime: Date): boolean => { + return Math.abs(action.timestamp.valueOf() - previousDateTime.valueOf()) < MAX_MSEC_IN_SESSION; + }; - const useInclude = this.options.unfoldInclude && this.options.unfoldInclude.length > 0; + private splitActionsBySessions(actions: UserAction[]): UserActionSession[] { + if (actions.length === 0) { + return []; + } + const splitSessions: UserActionSession[] = [new UserActionSession(actions[0].timestamp, [])]; + let previousDateTime = actions[0]?.timestamp; + let currentSession: UserActionSession = splitSessions[0]; + actions.forEach((action) => { + if (this.isPartOfTheSameSession(action, previousDateTime)) { + currentSession.actions.push(action); + } else { + splitSessions.push(new UserActionSession(action.timestamp, [action])); + currentSession = splitSessions[splitSessions.length - 1]; + } + previousDateTime = action.timestamp; + }); + return splitSessions; + } - const isExcluded = (isSearch || isCustom) && this.options.unfoldExclude.indexOf(cause) !== -1; - const isIncluded = (isSearch || isCustom) && this.options.unfoldInclude.indexOf(cause) !== -1; + private buildSessionsToDisplay() { + if (this.options.ticketCreationDateTime instanceof Date) { + ({ caseSubmitSessionIndex: this.caseSubmitSessionIndex, caseSubmitSession: this.caseSubmitSession } = this.findCaseSubmitSession()); + if (this.caseSubmitSessionIndex !== -1) { + const sessionIndexBefore = this.caseSubmitSessionIndex - SESSION_BEFORE_TO_DISPLAY; + const sessionIndexAfter = this.caseSubmitSessionIndex + SESSION_AFTER_TO_DISPLAY; + this.sessionsToDisplay = this.findSurroundingSessions(sessionIndexBefore, sessionIndexAfter); + this.caseSubmitSession.expanded = true; + + const insertTicketCreatedIndex = this.caseSubmitSession.actions.findIndex( + (action) => action.timestamp <= this.options.ticketCreationDateTime + ); + this.caseSubmitSession.actions.splice(insertTicketCreatedIndex, 0, this.buildTicketCreatedAction()); + return; + } else { + console.warn(`Could not find a user action session corresponding to this date: ${this.options.ticketCreationDateTime}.`); + } + } + this.sessionsToDisplay = this.sessions.slice(0, 5); + this.sessionsToDisplay[0].expanded = true; + } - return isClick || (useInclude && isIncluded) || (!useInclude && !isExcluded); + private buildTicketCreatedAction(): UserAction { + return new UserAction(UserActionType.TicketCreated, this.options.ticketCreationDateTime, {}); + } + + private findCaseSubmitSession(): { caseSubmitSessionIndex: number; caseSubmitSession: UserActionSession | null } { + let caseSubmitSessionIndex = this.findSessionIncludingCaseSubmit(); + let caseSubmitSession = null; + + if (caseSubmitSessionIndex !== -1) { + // If we found a session that correctly includes the timestamp when the ticket was created + caseSubmitSession = this.sessions[caseSubmitSessionIndex]; + // return { caseSubmitSessionIndex: foundCaseSubmitSessionIndex, caseSubmitSession: this.sessions[foundCaseSubmitSessionIndex] }; + } else { + // We can try to find a session that occurred just before the ticket create. + caseSubmitSessionIndex = this.findPotentialSessionJustBeforeCaseSubmit(); + if (caseSubmitSessionIndex !== -1) { + caseSubmitSession = this.sessions[caseSubmitSessionIndex]; + } + } + + return { + caseSubmitSessionIndex, + caseSubmitSession, + }; + } + + private findSessionIncludingCaseSubmit(): number { + return this.sessions.findIndex( + (session) => + session.actions[0].timestamp >= this.options.ticketCreationDateTime && + session.actions[session.actions.length - 1].timestamp <= this.options.ticketCreationDateTime + ); + } + + private findPotentialSessionJustBeforeCaseSubmit(): number { + const potentialSessionIndex = this.sessions.findIndex((session) => session.actions[0].timestamp <= this.options.ticketCreationDateTime); + + if (potentialSessionIndex !== -1) { + const lastActionInSession = this.sessions[potentialSessionIndex].actions[0]; + + if (!this.isPartOfTheSameSession(lastActionInSession, this.options.ticketCreationDateTime)) { + // If the session before the ticket create is not part of the same session, create a standalone session. + this.sessions.splice(potentialSessionIndex, 0, new UserActionSession(this.options.ticketCreationDateTime, [])); + } + return potentialSessionIndex; + } + return -1; + } + + private findSurroundingSessions(from: number, to: number): UserActionSession[] { + // +1 because with slice `end` is not included. + return this.sessions.slice(Math.max(0, from), Math.min(this.sessions.length, to + 1)); } private render() { + this.element.innerHTML = ''; + const panel = document.createElement('div'); panel.classList.add(MAIN_CLASS); - const timestampSection = document.createElement('div'); - timestampSection.classList.add(HEADER_CLASS); - - this.buildTimestampSection().forEach((el) => timestampSection.appendChild(el)); - const activitySection = this.buildActivitySection(); activitySection.classList.add(ACTIVITY_CLASS); - panel.appendChild(timestampSection); panel.appendChild(activitySection); - - this.element.innerHTML = ''; this.element.appendChild(panel); } private buildActivitySection(): HTMLElement { const list = document.createElement('ol'); - this.buildListItems(this.actions).forEach((listItem, index, array) => { - list.appendChild(listItem); - if (index < array.length - 1) { - list.appendChild(this.buildBubble()); + const sessionsBuilt = this.buildSessionsItems(this.sessionsToDisplay); + + sessionsBuilt.forEach((sessionItem) => { + if (sessionItem) { + list.appendChild(sessionItem); } }); return list; } - private buildBubble() { - const li = document.createElement('li'); - li.classList.add(BUBBLE_CLASS); - return li; - } + private buildSessionsItems(sessions: UserActionSession[]): HTMLElement[] { + let hitExpanded = false; - private buildListItems(actions: UserAction[]): HTMLElement[] { - const nbUnfoldedActions = this.actions.length - this.foldedActions.length; - - return actions - .reduce((acc, action) => { - const last = acc[acc.length - 1]; - if (this.foldedActions.indexOf(action) !== -1 && nbUnfoldedActions > 0) { - if (Array.isArray(last)) { - last.push(action); - return [...acc]; - } else { - return [...acc, [action]]; - } - } else { - return [...acc, action]; + const htmlElements: any[] = []; + + sessions.forEach((session, index) => { + if (session.expanded) { + htmlElements.push(this.buildSessionItem(session)); + hitExpanded = true; + } else { + if (!hitExpanded && sessions[index + 1]?.expanded) { + htmlElements.push(this.buildFoldedSession(session, l(`${UserActivity.ID}_showNewSession`))); } - }, []) - .map((item) => { - if (Array.isArray(item)) { - return this.buildFolded(item); - } else { - return this.buildListItem(item); + if (hitExpanded && sessions[index - 1]?.expanded) { + htmlElements.push(this.buildFoldedSession(session, l(`${UserActivity.ID}_showPastSession`))); } - }); - } + } + }); - private buildListItem(action: UserAction): HTMLLIElement { - let li: HTMLLIElement; - - switch (action.type) { - case UserActionType.Click: - li = this.buildClickEvent(action); - break; - case UserActionType.Search: - li = this.buildSearchEvent(action); - break; - case UserActionType.PageView: - li = this.buildViewEvent(action); - break; - default: - case UserActionType.Custom: - li = this.buildCustomEvent(action); - break; - } - return li; + return htmlElements; } - private buildFolded(actions: UserAction[]): HTMLLIElement { + private buildFoldedSession(sessionToExpand: UserActionSession, showMoreButtonText: string): HTMLLIElement { const li = document.createElement('li'); li.classList.add(FOLDED_CLASS); const hr = document.createElement('hr'); - const span = document.createElement('span'); span.classList.add(TEXT_CLASS); - span.innerText = `${actions.length} ${actions.length > 1 ? l(`${UserActivity.ID}_other_events`) : l(`${UserActivity.ID}_other_event`)}`; + span.innerText = showMoreButtonText || l(`${UserActivity.ID}_showMore`); hr.appendChild(span); li.addEventListener('click', () => { - this.foldedActions = this.foldedActions.filter((action) => actions.indexOf(action) === -1); + sessionToExpand.expanded = true; this.render(); }); @@ -231,67 +285,138 @@ export class UserActivity extends Component { return li; } - private buildClickEvent(action: UserAction): HTMLLIElement { + private buildSessionItem(session: UserActionSession): HTMLElement { + if (session.actions.length === 0) { + return null; + } + + const sessionContainer = document.createElement('div'); + sessionContainer.classList.add('coveo-session-container'); + sessionContainer.appendChild(this.buildSessionHeader(session)); + + this.buildSessionContent(session.actions, session === this.caseSubmitSession).forEach((actionHTML) => + sessionContainer.appendChild(actionHTML) + ); + return sessionContainer; + } + + private buildSessionHeader(session: UserActionSession): HTMLElement { + const sessionHeader = document.createElement('div'); + sessionHeader.classList.add('coveo-session-header'); + sessionHeader.innerText = l(`${UserActivity.ID}_session`) + ` ${formatDate(session.timestamp)}`; + + return sessionHeader; + } + + private buildSessionContent(actions: UserAction[], withFolded: boolean): HTMLLIElement[] { + let actionsHTML = []; + let actionsToDisplay = actions; + if (withFolded && this.options.ticketCreationDateTime && !this.hasExpandedActions) { + // Special behavior because, in the session with the Ticket Creation event, + // until the user expands them, the actions that occurred AFTER a ticket creation are collapsed. + actionsToDisplay = actionsToDisplay.filter((action) => action.timestamp <= this.options.ticketCreationDateTime); + if (actionsToDisplay.length < actions.length) { + actionsHTML.push(this.buildFoldedActions()); + } + } + actionsHTML = actionsHTML.concat(actionsToDisplay.map((action) => this.buildActionListItem(action))); + return actionsHTML; + } + + private buildFoldedActions(): HTMLLIElement { const li = document.createElement('li'); - li.classList.add(CLICK_EVENT_CLASS); + li.classList.add(FOLDED_ACTIONS_CLASS); - const dataElement = document.createElement('a'); - dataElement.classList.add(DATA_CLASS); - dataElement.innerText = (action.document && action.document.title) || ''; - dataElement.href = (action.document && action.document.clickUri) || ''; + const span = document.createElement('span'); + span.classList.add(TEXT_CLASS); + span.innerText = l(`${UserActivity.ID}_showMoreActions`); - document.createAttributeNS('svg', 'svg'); + li.addEventListener('click', () => { + this.hasExpandedActions = true; + this.render(); + }); - li.appendChild(this.buildTitleSection(action)); - if (action.document) { - li.appendChild(dataElement); + for (let i = 0; i < 3; i++) { + const el = this.buildIcon(dot); + li.appendChild(el); } - li.appendChild(this.buildOriginElement(action)); - li.appendChild(this.buildIcon(duplicate)); + li.appendChild(span); return li; } + private buildActionListItem(action: UserAction): HTMLLIElement { + try { + const defaultBuilder = (action: UserAction) => this.buildCustomEvent(action); + const buildersMap = { + [UserActionType.Click]: (action: UserAction) => this.buildClickEvent(action), + [UserActionType.Search]: (action: UserAction) => this.buildSearchEvent(action), + [UserActionType.PageView]: (action: UserAction) => this.buildViewEvent(action), + [UserActionType.TicketCreated]: (action: UserAction) => this.buildTicketCreated(action), + [UserActionType.Custom]: (action: UserAction) => this.buildCustomEvent(action), + }; + + const builder = buildersMap[action.type] || defaultBuilder; + + return builder(action); + } catch (err) { + console.error(err); + return null; + } + } + private buildSearchEvent(action: UserAction): HTMLLIElement { const li = document.createElement('li'); - li.classList.add(SEARCH_EVENT_CLASS); + li.classList.add(EVENT_CLASS, SEARCH_EVENT_CLASS); - li.appendChild(this.buildTitleSection(action)); + li.appendChild(this.buildTitleSection(action, action.query || l(`${UserActivity.ID}_emptySearch`))); + li.appendChild(this.buildFooterElement(action)); + li.appendChild(this.buildIcon(search)); - if (action.query) { - const dataElement = document.createElement('div'); - dataElement.classList.add(DATA_CLASS); - dataElement.innerText = action.query || ''; + return li; + } - li.appendChild(dataElement); - } + private buildClickEvent(action: UserAction): HTMLLIElement { + const li = document.createElement('li'); + li.classList.add(EVENT_CLASS, CLICK_EVENT_CLASS); - li.appendChild(this.buildOriginElement(action)); - li.appendChild(this.buildIcon(search)); + const titleSection = document.createElement('div'); + titleSection.classList.add(ACTIVITY_TITLE_SECTION); + + const clickedURLElement = document.createElement('a'); + clickedURLElement.classList.add(ACTIVITY_TITLE_CLASS); + clickedURLElement.innerText = (action.document && action.document.title) || ''; + clickedURLElement.href = (action.document && action.document.clickUri) || ''; + titleSection.appendChild(clickedURLElement); + + document.createAttributeNS('svg', 'svg'); + + li.appendChild(titleSection); + li.appendChild(this.buildFooterElement(action)); + li.appendChild(this.buildIcon(duplicate)); return li; } private buildViewEvent(action: UserAction): HTMLLIElement { const li = document.createElement('li'); - li.classList.add(VIEW_EVENT_CLASS); + li.classList.add(EVENT_CLASS, VIEW_EVENT_CLASS); - const dataElement = document.createElement('div'); if (UserActivity.clickable_uri_ids.indexOf(action.raw.content_id_key) !== -1) { //If the content id key is included in the clickable_uri list, make the component a link + const titleSection = document.createElement('div'); + titleSection.classList.add(ACTIVITY_TITLE_SECTION); + const a = document.createElement('a'); a.href = action.raw.content_id_value; - a.innerText = action.raw.content_id_value; - dataElement.appendChild(a); + a.innerText = action.raw.title || action.raw.content_id_value; + titleSection.appendChild(a); + li.appendChild(titleSection); } else { - dataElement.innerText = `${action.raw.content_id_key}: ${action.raw.content_id_value}`; + li.appendChild(this.buildTitleSection(action, action.raw.title || `${action.raw.content_id_key}: ${action.raw.content_id_value}`)); } - dataElement.classList.add(DATA_CLASS); - - li.appendChild(this.buildTitleSection(action)); - li.appendChild(dataElement); - li.appendChild(this.buildOriginElement(action)); + li.appendChild(this.buildFooterElement(action)); li.appendChild(this.buildIcon(view)); return li; @@ -299,110 +424,56 @@ export class UserActivity extends Component { private buildCustomEvent(action: UserAction): HTMLLIElement { const li = document.createElement('li'); - li.classList.add(CUSTOM_EVENT_CLASS); + li.classList.add(EVENT_CLASS, CUSTOM_EVENT_CLASS); - const titleElem = document.createElement('div'); - titleElem.classList.add(ACTIVITY_TITLE_CLASS); - titleElem.innerText = `${l(action.raw.event_type || `${UserActivity.ID}_custom`)}`; + li.appendChild(this.buildTitleSection(action, `${action.raw.event_value || action.raw.event_type || l(`${UserActivity.ID}_custom`)}`)); + li.appendChild(this.buildFooterElement(action)); + li.appendChild(this.buildIcon(dot)); - const titleSection = this.buildTitleSection(action); - titleSection.querySelector(`.${ACTIVITY_TITLE_CLASS}`).remove(); - titleSection.insertBefore(titleElem, titleSection.firstChild); + return li; + } - const dataElement = document.createElement('div'); - dataElement.classList.add(DATA_CLASS); - dataElement.innerText = action.raw.event_value || ''; + private buildTicketCreated(action: UserAction): HTMLLIElement { + const li = document.createElement('li'); + li.classList.add(EVENT_CLASS, CUSTOM_EVENT_CLASS, CASE_CREATION_ACTION_CLASS); - li.appendChild(titleSection); - li.appendChild(dataElement); - li.appendChild(this.buildOriginElement(action)); - li.appendChild(this.buildIcon(dot)); + li.appendChild(this.buildTitleSection(action, l(`${UserActivity.ID}_ticketCreated`))); + li.appendChild(this.buildFooterElement(action)); + li.appendChild(this.buildIcon(flag)); return li; } - private buildOriginElement(action: UserAction): HTMLElement { + private buildFooterElement(action: UserAction): HTMLElement { const el = document.createElement('div'); el.classList.add(ORIGIN_CLASS); - el.innerText = action.raw.origin_level_1 || ''; - return el; - } - - private buildTimestampElement(action: UserAction): HTMLElement { - const el = document.createElement('div'); - el.classList.add(ACTIVIY_TIMESTAMP_CLASS); - el.innerText = this.element.offsetWidth > WIDTH_CUTOFF ? formatDateAndTime(action.timestamp) : formatDateAndTimeShort(action.timestamp); + el.innerText = `${formatTime(action.timestamp)}`; + if (action.raw.origin_level_1) { + el.innerText += ` - ${action.raw.origin_level_1}`; + } return el; } - private buildTitleElement(action: UserAction): HTMLElement { - const title = this.isManualSubmitAction(action) ? 'query' : action.type.toLowerCase(); - + private buildTitleElement(_: UserAction, content: string): HTMLElement { const el = document.createElement('div'); el.classList.add(ACTIVITY_TITLE_CLASS); - el.innerText = l(`${UserActivity.ID}_${title}`); + el.innerText = content; return el; } - private buildTitleSection(action: UserAction): HTMLElement { + private buildTitleSection(action: UserAction, content: string): HTMLElement { const titleSection = document.createElement('div'); titleSection.classList.add(ACTIVITY_TITLE_SECTION); - titleSection.appendChild(this.buildTitleElement(action)); - titleSection.appendChild(this.buildTimestampElement(action)); + titleSection.appendChild(this.buildTitleElement(action, content)); return titleSection; } - private buildIcon(icon: string) { + private buildIcon(icon: string): HTMLElement { const el = document.createElement('div'); el.classList.add(ICON_CLASS); el.innerHTML = icon; return el; } - - private buildTimestampSection(): HTMLElement[] { - const startDate = this.actions[0]; - const endDate = this.actions[this.actions.length - 1]; - const duration = endDate.timestamp.getTime() - startDate.timestamp.getTime(); - - return [ - this.buildTimestampCell({ title: l(`${UserActivity.ID}_start_date`), data: formatDate(startDate.timestamp) }), - this.buildTimestampCell({ title: l(`${UserActivity.ID}_start_time`), data: formatTime(startDate.timestamp) }), - this.buildTimestampCell({ title: l(`${UserActivity.ID}_duration`), data: formatTimeInterval(duration) }), - ]; - } - - private buildTimestampCell({ title, data }: { title: string; data: string }): HTMLElement { - const cell = document.createElement('div'); - cell.classList.add(CELL_CLASS); - - const titleElement = document.createElement('div'); - titleElement.classList.add(TITLE_CLASS); - titleElement.innerText = title; - - const dataElement = document.createElement('div'); - dataElement.classList.add(DATA_CLASS); - dataElement.innerText = data; - - cell.appendChild(titleElement); - cell.appendChild(dataElement); - - return cell; - } - - /** - * Dertermine if an action is a manual search submit. - * A manual search submit is a Search event that has a query expression and that the cause is one of the above: - * + **omniboxAnalytics** - * + **userActionsSubmit** - * + **omniboxFromLink** - * + **searchboxAsYouType** - * + **searchboxSubmit** - * + **searchFromLink** - * @param action Action that will be tested. - */ - private isManualSubmitAction(action: UserAction) { - return action.type === UserActionType.Search && action.raw.query_expression && MANUAL_SEARCH_EVENT_CAUSE.indexOf(action.raw.cause) !== -1; - } } Initialization.registerAutoCreateComponent(UserActivity); diff --git a/src/models/UserProfileModel.ts b/src/models/UserProfileModel.ts index 0503fdce..a738cea1 100644 --- a/src/models/UserProfileModel.ts +++ b/src/models/UserProfileModel.ts @@ -14,6 +14,10 @@ import { import { UserActionEvents } from '../components/UserActions/Events'; import { UserProfilingEndpoint, IActionHistory, UserActionType } from '../rest/UserProfilingEndpoint'; +export class UserActionSession { + constructor(public timestamp: Date, public actions: UserAction[], public expanded: boolean = false) {} +} + /** * Represent an action that a user has made. */ @@ -31,6 +35,8 @@ export class UserAction { cause?: string; content_id_key?: string; content_id_value?: string; + language?: string; + title?: string; }, public document?: IQueryResult, public query?: string diff --git a/src/rest/UserProfilingEndpoint.ts b/src/rest/UserProfilingEndpoint.ts index 01d25ea5..9e277b2c 100644 --- a/src/rest/UserProfilingEndpoint.ts +++ b/src/rest/UserProfilingEndpoint.ts @@ -30,6 +30,7 @@ export enum UserActionType { Click = 'CLICK', PageView = 'VIEW', Custom = 'CUSTOM', + TicketCreated = 'TICKET_CREATED', } /** diff --git a/src/utils/icons.ts b/src/utils/icons.ts index 2ee8c3ef..5d46a71a 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -3,6 +3,7 @@ import * as DUPLICATE from '../../svg/duplicate.svg'; import * as SEARCH from '../../svg/search.svg'; import * as VIEW from '../../svg/view.svg'; import * as DOT from '../../svg/dot.svg'; +import * as FLAG from '../../svg/flag.svg'; import * as PAPER_CLIP from '../../svg/paperclip.svg'; import * as USER from '../../svg/user.svg'; import * as WAIT from '../../svg/wait.svg'; @@ -13,6 +14,7 @@ export const duplicate = DUPLICATE; export const search = SEARCH; export const view = VIEW; export const dot = DOT; +export const flag = FLAG; export const paperclipIcon = PAPER_CLIP; export const user = USER; export const wait = WAIT; diff --git a/svg/flag.svg b/svg/flag.svg new file mode 100644 index 00000000..b40adba4 --- /dev/null +++ b/svg/flag.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tests/components/UserActions/ClickedDocumentList.spec.ts b/tests/components/UserActions/ClickedDocumentList.spec.ts index 1d6637f3..6881f6a2 100644 --- a/tests/components/UserActions/ClickedDocumentList.spec.ts +++ b/tests/components/UserActions/ClickedDocumentList.spec.ts @@ -60,7 +60,7 @@ describe('ClickedDocumentList', () => { ); await waitForPromiseCompletion(); - expect(mock.cmp.element.querySelector('.coveo-title').innerHTML).toMatch('Recent Clicked Documents'); + expect(mock.cmp.element.querySelector('.coveo-title').innerHTML).toMatch('Most Recent Clicked Documents'); }); it('should show the title specified in "listLabel" option', async () => { @@ -77,7 +77,7 @@ describe('ClickedDocumentList', () => { expect(mock.cmp.element.querySelector('.coveo-title').innerHTML).toMatch(customTitle); }); - it('should show 4 documents by default', async () => { + it('should show 3 documents by default', async () => { sandbox.stub(Initialization, 'automaticallyCreateComponentsInsideResult'); const mock = Mock.advancedComponentSetup( @@ -91,7 +91,7 @@ describe('ClickedDocumentList', () => { const list = mock.env.element.querySelector('.coveo-list'); - expect(list.childElementCount).toBe(4); + expect(list.childElementCount).toBe(3); }); it('should show a number of documents equal to the "numberOfItems" option', async () => { @@ -125,7 +125,7 @@ describe('ClickedDocumentList', () => { const list = mock.env.element.querySelector('.coveo-list'); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 3; i++) { const icon = list.children.item(i).querySelector('svg'); expect(icon).toBeDefined; } diff --git a/tests/components/UserActions/QueryList.spec.ts b/tests/components/UserActions/QueryList.spec.ts index bb487ced..e215c247 100644 --- a/tests/components/UserActions/QueryList.spec.ts +++ b/tests/components/UserActions/QueryList.spec.ts @@ -47,7 +47,7 @@ describe('QueryList', () => { expect(emptyElement.innerText).toBe('No queries made by this user'); }); - it('should show "Recent Queries" as title', async () => { + it('should show "Most Recent Queries" as title', async () => { const mock = Mock.advancedComponentSetup( QueryList, new Mock.AdvancedComponentSetupOptions(null, { userId: 'testuserId' }, (env) => { @@ -58,10 +58,10 @@ describe('QueryList', () => { await waitForPromiseCompletion(); - expect(mock.cmp.element.querySelector('.coveo-title').innerHTML).toMatch('Recent Queries'); + expect(mock.cmp.element.querySelector('.coveo-title').innerHTML).toMatch('Most Recent Queries'); }); - it('should show 4 queries by default', async () => { + it('should show 3 queries by default', async () => { const mock = Mock.advancedComponentSetup( QueryList, new Mock.AdvancedComponentSetupOptions(null, { userId: 'testuserId' }, (env) => { @@ -74,7 +74,7 @@ describe('QueryList', () => { const list = mock.env.element.querySelector('.coveo-list'); - expect(list.childElementCount).toBe(4); + expect(list.childElementCount).toBe(3); }); it('should show a number of queries equal to the "numberOfItems" option', async () => { @@ -106,7 +106,7 @@ describe('QueryList', () => { const list = mock.env.element.querySelector('.coveo-list'); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 3; i++) { const icon = list.children.item(i).querySelector('svg'); expect(icon).toBeDefined; } diff --git a/tests/components/UserActions/UserActions.spec.ts b/tests/components/UserActions/UserActions.spec.ts index 83d7d70b..bd05a230 100644 --- a/tests/components/UserActions/UserActions.spec.ts +++ b/tests/components/UserActions/UserActions.spec.ts @@ -180,7 +180,7 @@ describe('UserActions', () => { const detailSection = mock.cmp.element.querySelector('.coveo-details'); expect(automaticallyCreateComponentsInsideStub.called).toBe(true); - expect(detailSection.querySelector('.coveo-accordion-header-title').innerText).toBe("User's Recent Activity"); + expect(detailSection.querySelector('.coveo-accordion-header-title').innerText).toBe('User Activity Timeline'); expect(detailSection.querySelector('.CoveoUserActivity')).not.toBeNull(); }); diff --git a/tests/components/UserActions/UserActivity.spec.ts b/tests/components/UserActions/UserActivity.spec.ts index 14672433..07625521 100644 --- a/tests/components/UserActions/UserActivity.spec.ts +++ b/tests/components/UserActions/UserActivity.spec.ts @@ -1,99 +1,68 @@ import { SinonSandbox, createSandbox, SinonStub } from 'sinon'; import { UserAction } from '../../../src/models/UserProfileModel'; -import { Mock, Fake } from 'coveo-search-ui-tests'; +import { Fake, Mock } from 'coveo-search-ui-tests'; import { UserActionType } from '../../../src/rest/UserProfilingEndpoint'; import { UserActivity } from '../../../src/Index'; import { fakeUserProfileModel } from '../../utils'; -import { formatDate, formatTime, formatDateAndTime, formatDateAndTimeShort, formatTimeInterval } from '../../../src/utils/time'; +import { formatDate, formatTime } from '../../../src/utils/time'; describe('UserActivity', () => { - const TEST_DATE_STRING = 'December 17, 1995 1:00:00 AM'; - const ACTIVITY_SELECTOR = '.coveo-activity'; - const ACTIVITY_TITLE_SELECTOR = '.coveo-activity-title'; - const ACTIVIY_TIMESTAMP_SELECTOR = '.coveo-activity-timestamp'; - - const FAKE_CLICK_EVENT = new UserAction(UserActionType.Click, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - uri_hash: 'product' + Math.random(), - c_contentidkey: '@sysurihash', - c_contentidvalue: '' + Math.random(), + const TEST_DATE_TIME = 1642443657767; + const TEST_DATE = new Date(TEST_DATE_TIME); + const FAKE_ORIGIN_1 = 'origin1'; + const FAKE_DOCUMENT_TITLE = 'Martine a la plage'; + const MINUTE = 60000; + + const FAKE_EVENT_SEARCH = new UserAction( + UserActionType.Search, + TEST_DATE, + { cause: 'searchFromLink', origin_level_1: FAKE_ORIGIN_1, query_expression: 'foo' }, + null, + 'foo' + ); + const FAKE_EVENT_CLICK = new UserAction(UserActionType.Click, new Date(TEST_DATE.getTime() + 1 * MINUTE), { + c_contentidkey: 'permanentid', + c_contentidvalue: 'somepermanentid', + origin_level_1: FAKE_ORIGIN_1, + title: FAKE_DOCUMENT_TITLE, + uri_hash: 'whatever', }); - FAKE_CLICK_EVENT.document = Fake.createFakeResult(); - - const FAKE_SEARCH_EVENT = new UserAction(UserActionType.Search, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - cause: 'interfaceLoad', - }); - - const FAKE_USER_SEARCH_EVENT = new UserAction(UserActionType.Search, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - query_expression: 'someSearch' + Math.random(), - cause: 'searchboxSubmit', + FAKE_EVENT_CLICK.document = Fake.createFakeResult(); + const FAKE_EVENT_CUSTOM = new UserAction(UserActionType.Custom, new Date(TEST_DATE.getTime() + 2 * MINUTE), { + event_type: 'case', + event_value: 'caseDetach', + origin_level_1: FAKE_ORIGIN_1, }); - FAKE_USER_SEARCH_EVENT.query = FAKE_USER_SEARCH_EVENT.raw.query_expression; - - const FAKE_VIEW_EVENT = new UserAction(UserActionType.PageView, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - content_id_key: '@someKey' + Math.random(), - content_id_value: 'someValue' + Math.random(), - }); - - const FAKE_CUSTOM_EVENT = new UserAction(UserActionType.Custom, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - event_type: 'Submit' + Math.random(), - event_value: 'Case Submit' + Math.random(), + const FAKE_EVENT_VIEW = new UserAction(UserActionType.PageView, new Date(TEST_DATE.getTime() + 3 * MINUTE), { + content_id_key: '@clickableuri', + content_id_value: 'whatever', + title: 'Home', + origin_level_1: FAKE_ORIGIN_1, }); - const FAKE_CUSTOM_EVENT_WITHOUT_TYPE = new UserAction(UserActionType.Custom, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - event_value: 'Case Submit' + Math.random(), - }); - - const IRRELEVANT_ACTIONS = [ - new UserAction(UserActionType.Search, new Date(TEST_DATE_STRING), { - origin_level_1: 'not relevant' + Math.random(), - query_expression: 'not relevant', - cause: 'interfaceLoad', - }), - new UserAction(UserActionType.PageView, new Date(TEST_DATE_STRING), { - origin_level_1: 'not relevant' + Math.random(), - content_id_key: '@sysurihash', - content_id_value: 'product1', - }), - new UserAction(UserActionType.Custom, new Date(TEST_DATE_STRING), { - origin_level_1: 'not relevant' + Math.random(), - c_contentidkey: '@sysurihash', - c_contentidvalue: 'headphones-gaming', - event_type: 'addPurchase', - event_value: 'headphones-gaming', - }), - new UserAction(UserActionType.Custom, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - c_contentidkey: '@sysurihash', - c_contentidvalue: 'headphones-gaming', - event_type: 'addPurchase', - event_value: 'headphones-gaming', - }), - ]; - - const FAKE_USER_ACTIONS = [ - new UserAction(UserActionType.Search, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - cause: 'searchboxSubmit', - query_expression: 'Best product', - }), - new UserAction(UserActionType.Click, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - uri_hash: 'product' + Math.random(), - c_contentidkey: '@sysurihash', - c_contentidvalue: 'product1', - }), - ]; - - const getMockComponent = async (returnedActions: UserAction | UserAction[], element = document.createElement('div')) => { + const FAKE_USER_ACTIONS_SESSION = [FAKE_EVENT_SEARCH, FAKE_EVENT_CLICK, FAKE_EVENT_CUSTOM, FAKE_EVENT_VIEW]; + + const SESSION_SELECTOR = 'div.coveo-session-container'; + const SESSION_HEADER_SELECTOR = 'div.coveo-session-header'; + const SESSION_ACTIONS_SELECTOR = 'div.coveo-session-container > li.coveo-action'; + const TICKET_CREATED_ACTION_SELECTOR = 'li.coveo-case-creation-action'; + const ACTION_FOOTER_SELECTOR = 'div.coveo-footer'; + const ACTION_TITLE_SELECTOR = '.coveo-activity-title'; + const ACTION_SEARCH_SELECTOR = 'li.coveo-action.coveo-search'; + const ACTION_CLICK_SELECTOR = 'li.coveo-action.coveo-click'; + const ACTION_CUSTOM_SELECTOR = 'li.coveo-action.coveo-custom'; + const ACTION_VIEW_SELECTOR = 'li.coveo-action.coveo-view'; + const FOLDED_ACTIONS_SELECTOR = 'li.coveo-folded-actions'; + const FOLDED_SESSIONS_SELECTOR = 'ol.coveo-activity > li.coveo-folded'; + + const getMockComponent = async ( + returnedActions: UserAction | UserAction[], + ticketCreationDateTime: Date | string | number, + element = document.createElement('div') + ) => { const mock = Mock.advancedComponentSetup( UserActivity, - new Mock.AdvancedComponentSetupOptions(element, { userId: 'testuserId' }, (env) => { + new Mock.AdvancedComponentSetupOptions(element, { userId: 'testuserId', ticketCreationDateTime: ticketCreationDateTime }, (env) => { env.element = element; getActionsPromise = Promise.resolve(returnedActions); fakeUserProfileModel(env.root, sandbox).getActions.callsFake(() => getActionsPromise); @@ -115,353 +84,385 @@ describe('UserActivity', () => { sandbox.restore(); }); - it('should show the starting date and time of the user action session', async () => { - const mock = await getMockComponent(FAKE_USER_ACTIONS); - const timestamps = FAKE_USER_ACTIONS.map((action) => action.timestamp).sort(); - - const firstAction = timestamps[0]; - - expect(mock.cmp.element.innerHTML).toMatch(formatDate(firstAction)); - expect(mock.cmp.element.innerHTML).toMatch(formatTime(firstAction)); - }); + describe('sessions', () => { + it('should regroup actions in the same session', async () => { + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, null); - it('should duration of the user action session', async () => { - const mock = await getMockComponent(FAKE_USER_ACTIONS); - const timestamps = FAKE_USER_ACTIONS.map((action) => action.timestamp).sort(); - - expect(mock.cmp.element.innerHTML).toMatch(formatTimeInterval(timestamps[timestamps.length - 1].getTime() - timestamps[0].getTime())); - }); - - it('should fold each actions that are tagged as not meaningful', async () => { - const mock = await getMockComponent([...FAKE_USER_ACTIONS, ...IRRELEVANT_ACTIONS]); - - IRRELEVANT_ACTIONS.forEach((action) => { - expect(mock.cmp.element.querySelector(ACTIVITY_SELECTOR).innerHTML).not.toMatch(action.raw.origin_level_1); + const sessionHeaders = mock.cmp.element.querySelectorAll(SESSION_HEADER_SELECTOR); + expect(sessionHeaders.length).toEqual(1); }); - }); - it('should show each actions that are tagged as meaningful', async () => { - const mock = await getMockComponent([...FAKE_USER_ACTIONS, ...IRRELEVANT_ACTIONS]); + it('should display a header with the date of the most recent action in the session', async () => { + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, null); + const expectedDate = FAKE_USER_ACTIONS_SESSION.map((action) => action.timestamp).sort((a, b) => b.getTime() - a.getTime())[0]; + const expectedSessionHeader = `Session ${formatDate(expectedDate)}`; - FAKE_USER_ACTIONS.forEach((action) => { - expect(mock.cmp.element.querySelector(ACTIVITY_SELECTOR).innerHTML).toMatch(action.raw.origin_level_1); + const sessionHeaders = mock.cmp.element.querySelectorAll(SESSION_HEADER_SELECTOR); + expect(sessionHeaders.length).toEqual(1); + expect(sessionHeaders[0].innerHTML).toMatch(expectedSessionHeader); }); - }); - it('should show all actions when no action are tagged as meaningful', async () => { - const mock = await getMockComponent(IRRELEVANT_ACTIONS); + it('should display the correct number of actions and in the correct order', async () => { + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, null); + const sortedActions = FAKE_USER_ACTIONS_SESSION.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - IRRELEVANT_ACTIONS.forEach((action) => { - expect(mock.cmp.element.querySelector(ACTIVITY_SELECTOR).innerHTML).toMatch(action.raw.origin_level_1); + const sessionActions = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); + expect(sessionActions.length).toEqual(FAKE_USER_ACTIONS_SESSION.length); + sortedActions.forEach((action, index) => { + const expectedTime = `${formatTime(action.timestamp)}`; + expect(sessionActions[index].querySelector(ACTION_FOOTER_SELECTOR).innerHTML).toMatch(expectedTime); + }); }); - }); - describe('folded events', () => { - it('should unfold on click', async () => { - const mock = await getMockComponent([...FAKE_USER_ACTIONS, ...IRRELEVANT_ACTIONS]); + it('should display a link to show a past session', async () => { + const secondSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 60 * MINUTE), + })); + const thirdSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); - const folded = mock.cmp.element.querySelector('.coveo-folded'); + const mock = await getMockComponent([...FAKE_USER_ACTIONS_SESSION, ...secondSession, ...thirdSession], null); - expect(folded).not.toBeNull(); - folded.click(); - IRRELEVANT_ACTIONS.forEach((action) => { - expect(mock.cmp.element.querySelector(ACTIVITY_SELECTOR).innerHTML).toMatch(action.raw.origin_level_1); - }); + const foldedSessionLink = mock.cmp.element.querySelectorAll(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLink.length).toBe(1); }); - }); - describe('search event', () => { - ['omniboxAnalytics', 'userActionsSubmit', 'omniboxFromLink', 'searchboxAsYouType', 'searchboxSubmit', 'searchFromLink'].map((cause) => { - it(`should display the "User Query" as event title when there is a query expression and the cause is ${cause}`, async () => { - const mock = await getMockComponent([ - new UserAction(UserActionType.Search, new Date(TEST_DATE_STRING), { - origin_level_1: 'relevant' + Math.random(), - query_expression: 'someSearch' + Math.random(), - cause: cause, - }), - ]); + it('should expand a new session when clicking on the link to show a past session', async () => { + const secondSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 60 * MINUTE), + })); + const thirdSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); - const clickElement = mock.cmp.element.querySelector('.coveo-search'); + const mock = await getMockComponent([...FAKE_USER_ACTIONS_SESSION, ...secondSession, ...thirdSession], null); - const action = await mock.cmp['userProfileModel'].getActions(''); + const visibleSessionsBeforeClick = mock.cmp.element.querySelectorAll(SESSION_SELECTOR); + expect(visibleSessionsBeforeClick.length).toBe(1); - expect(action[0].raw.cause).toBe(cause); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe('User Query'); - }); + const foldedSessionLink = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + foldedSessionLink.click(); + + const visibleSessionsAfterClick = mock.cmp.element.querySelectorAll(SESSION_SELECTOR); + expect(visibleSessionsAfterClick.length).toBe(2); }); - it('should display the "Query" as event title', async () => { - const mock = await getMockComponent([FAKE_SEARCH_EVENT]); + it('should remove the link to show past session when no more sessions can be shown', async () => { + const secondSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 60 * MINUTE), + })); + const thirdSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); - const clickElement = mock.cmp.element.querySelector('.coveo-search'); + const mock = await getMockComponent([...FAKE_USER_ACTIONS_SESSION, ...secondSession, ...thirdSession], null); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe('Query'); - }); + const visibleSessionsBeforeClick = mock.cmp.element.querySelectorAll(SESSION_SELECTOR); + expect(visibleSessionsBeforeClick.length).toBe(1); - it('should display the query made by the user as event data', async () => { - const mock = await getMockComponent([FAKE_USER_SEARCH_EVENT]); + let foldedSessionLink = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + foldedSessionLink.click(); + foldedSessionLink = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + foldedSessionLink.click(); - const clickElement = mock.cmp.element.querySelector('.coveo-search'); + const visibleSessionsAfterClick = mock.cmp.element.querySelectorAll(SESSION_SELECTOR); + expect(visibleSessionsAfterClick.length).toBe(3); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-data').innerText).toBe(FAKE_USER_SEARCH_EVENT.query); + const foldedSessionLinkAfterClick = mock.cmp.element.querySelectorAll(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLinkAfterClick.length).toBe(0); }); - it('should display the time of the event', async () => { - const mock = await getMockComponent([FAKE_SEARCH_EVENT]); + describe('with a ticket creation date', () => { + it('should not contain a Ticket Created event when the ticket creation date is too old', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() - 60 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - const searchElement = mock.cmp.element.querySelector('.coveo-search'); + const sessionActions = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); - expect(searchElement).not.toBeNull(); - expect(searchElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTimeShort(FAKE_SEARCH_EVENT.timestamp) - ); - }); - - it('should display the time of the event in long format if in a wider interface', async () => { - const element = document.createElement('div'); - Object.defineProperties(element, { - offsetWidth: { - get() { - return '500'; - }, - }, + expect(sessionActions.length).toBe(FAKE_USER_ACTIONS_SESSION.length); + expect(mock.cmp.element.querySelectorAll(TICKET_CREATED_ACTION_SELECTOR).length).toBe(0); }); - const mock = await getMockComponent([FAKE_SEARCH_EVENT], element); + it('should create a virtual session when the ticket creation date is too recent compared to the most recent session', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() + 60 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - const searchElement = mock.cmp.element.querySelector('.coveo-search'); - expect(searchElement).not.toBeNull(); - expect(searchElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTime(FAKE_SEARCH_EVENT.timestamp) - ); - }); + const sessionActions = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); - it('should display the originLevel1 as event footer', async () => { - const mock = await getMockComponent([FAKE_SEARCH_EVENT]); + expect(sessionActions.length).toBe(1); + expect(mock.cmp.element.querySelectorAll(TICKET_CREATED_ACTION_SELECTOR).length).toBe(1); + }); - const clickElement = mock.cmp.element.querySelector('.coveo-search'); + it('should become the most recent action in a session that occured within 30 minutes', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() + 4 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-footer').innerText).toMatch(FAKE_SEARCH_EVENT.raw.origin_level_1); - }); - }); + const sessionActions = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); + expect(sessionActions.length).toBe(FAKE_USER_ACTIONS_SESSION.length + 1); + expect(mock.cmp.element.querySelectorAll(TICKET_CREATED_ACTION_SELECTOR).length).toBe(1); + expect(sessionActions[0].innerHTML).toMatch('Ticket Created'); + }); - describe('click event', () => { - it('should display the "Clicked Document" as event title', async () => { - const mock = await getMockComponent([FAKE_CLICK_EVENT]); + it('should display folded actions for actions that occured after the ticket creation within a session', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() + 2.1 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - const clickElement = mock.cmp.element.querySelector('.coveo-click'); + const sessionActions = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); + expect(sessionActions.length).toBe(4); + expect(sessionActions[0].innerHTML).toMatch('Ticket Created'); + }); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe('Clicked Document'); - }); + it('should display a link to expand actions after the ticket creation within a session', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() + 2.1 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - it('should display a link to the clicked document as event data', async () => { - const mock = await getMockComponent([FAKE_CLICK_EVENT]); + const foldedActions = mock.cmp.element.querySelectorAll(FOLDED_ACTIONS_SELECTOR); + expect(foldedActions.length).toBe(1); + }); - const clickElement = mock.cmp.element.querySelector('.coveo-click'); + it('should expand the folded actions when clicking on the more actions link', async () => { + const ticketCreationDate = new Date(TEST_DATE.getTime() + 2.1 * MINUTE); + const mock = await getMockComponent(FAKE_USER_ACTIONS_SESSION, ticketCreationDate); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-data') instanceof HTMLAnchorElement).toBe(true); - expect(clickElement.querySelector('.coveo-data').innerText).toBe(FAKE_CLICK_EVENT.document.title); - expect(clickElement.querySelector('.coveo-data').href).toMatch(FAKE_CLICK_EVENT.document.clickUri); - }); - it('should display the time of the event', async () => { - const mock = await getMockComponent([FAKE_CLICK_EVENT]); + const sessionActionsBeforeClick = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); + expect(sessionActionsBeforeClick.length).toBe(4); + const foldedActions = mock.cmp.element.querySelector(FOLDED_ACTIONS_SELECTOR); + foldedActions.click(); - const clickElement = mock.cmp.element.querySelector('.coveo-click'); + const sessionActionsAfterClick = mock.cmp.element.querySelectorAll(SESSION_ACTIONS_SELECTOR); + expect(sessionActionsAfterClick.length).toBe(5); + }); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTimeShort(FAKE_CLICK_EVENT.timestamp) - ); - }); + it('should show links to view sessions before and after the ticket creation session', async () => { + const moreRecentSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); + const ticketCreationDate = new Date(TEST_DATE.getTime() + 60 * MINUTE); + const mock = await getMockComponent([...moreRecentSession, ...FAKE_USER_ACTIONS_SESSION], ticketCreationDate); - it('should display the time of the event in long format if in a wider interface', async () => { - const element = document.createElement('div'); - Object.defineProperties(element, { - offsetWidth: { - get() { - return '500'; - }, - }, + const foldedSessionLink = mock.cmp.element.querySelectorAll(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLink.length).toBe(2); }); - const mock = await getMockComponent([FAKE_CLICK_EVENT], element); + it('should expand the session before the ticket creation session when clicking on "Show new session"', async () => { + const moreRecentSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); + const ticketCreationDate = new Date(TEST_DATE.getTime() + 60 * MINUTE); + const mock = await getMockComponent([...moreRecentSession, ...FAKE_USER_ACTIONS_SESSION], ticketCreationDate); - const searchElement = mock.cmp.element.querySelector('.coveo-click'); - expect(searchElement).not.toBeNull(); - expect(searchElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTime(FAKE_CLICK_EVENT.timestamp) - ); - }); + const foldedSessionLinkBeforeClick = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLinkBeforeClick.innerText).toMatch('Show new session'); + foldedSessionLinkBeforeClick.click(); + + const sessionHeaders = mock.cmp.element.querySelectorAll(SESSION_HEADER_SELECTOR); + expect(sessionHeaders.length).toBe(2); + }); - it('should display the originLevel1 as event footer', async () => { - const mock = await getMockComponent([FAKE_CLICK_EVENT]); + it('should not display a link to show more sessions when all sessions have been expanded', async () => { + const moreRecentSession = FAKE_USER_ACTIONS_SESSION.map((action) => ({ + ...action, + timestamp: new Date(action.timestamp.getTime() + 120 * MINUTE), + })); + const ticketCreationDate = new Date(TEST_DATE.getTime() + 60 * MINUTE); + const mock = await getMockComponent([...moreRecentSession, ...FAKE_USER_ACTIONS_SESSION], ticketCreationDate); - const clickElement = mock.cmp.element.querySelector('.coveo-click'); + const foldedSessionLinkBeforeSession = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLinkBeforeSession.innerText).toMatch('Show new session'); + foldedSessionLinkBeforeSession.click(); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-footer').innerText).toMatch(FAKE_CLICK_EVENT.raw.origin_level_1); + const foldedSessionLinkAfterSession = mock.cmp.element.querySelector(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLinkAfterSession.innerText).toMatch('Show past session'); + foldedSessionLinkAfterSession.click(); + + const foldedSessionLinks = mock.cmp.element.querySelectorAll(FOLDED_SESSIONS_SELECTOR); + expect(foldedSessionLinks.length).toBe(0); + }); }); }); - describe('page view event', () => { - it('should display the "Page View" as event title', async () => { - const mock = await getMockComponent([FAKE_VIEW_EVENT]); + describe('actions', () => { + describe('search', () => { + it('should display the query as the action title', async () => { + const mock = await getMockComponent([FAKE_EVENT_SEARCH], null); - const viewElement = mock.cmp.element.querySelector('.coveo-view'); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_SEARCH_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector(ACTION_TITLE_SELECTOR); + expect(actionTitleEl.innerHTML).toMatch(FAKE_EVENT_SEARCH.raw.query_expression); + }); - expect(viewElement).not.toBeNull(); - expect(viewElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe('Page View'); - }); + it('should display the time of the action as footer', async () => { + const mock = await getMockComponent([FAKE_EVENT_SEARCH], null); - it('should display the content id key and value as event data', async () => { - const mock = await getMockComponent([FAKE_VIEW_EVENT]); + const actionEl = mock.cmp.element.querySelector(ACTION_SEARCH_SELECTOR); + const actionFooter = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooter.innerHTML).toMatch(`${formatTime(FAKE_EVENT_SEARCH.timestamp)}`); + }); - const viewElement = mock.cmp.element.querySelector('.coveo-view'); + it('should display the originLevel1 of the action as footer if available', async () => { + const mock = await getMockComponent([FAKE_EVENT_SEARCH], null); - expect(viewElement).not.toBeNull(); - expect(viewElement.querySelector('.coveo-data').innerText).toMatch(FAKE_VIEW_EVENT.raw.content_id_key); - expect(viewElement.querySelector('.coveo-data').innerText).toMatch(FAKE_VIEW_EVENT.raw.content_id_value); + const actionEl = mock.cmp.element.querySelector(ACTION_SEARCH_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooterEl.innerHTML).toMatch(FAKE_EVENT_SEARCH.raw.origin_level_1); + }); }); - it('should display the time of the event', async () => { - const mock = await getMockComponent([FAKE_VIEW_EVENT]); + describe('click', () => { + it('should display an anchor as the action title', async () => { + const mock = await getMockComponent([FAKE_EVENT_CLICK], null); - const viewElement = mock.cmp.element.querySelector('.coveo-view'); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_CLICK_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector(ACTION_TITLE_SELECTOR); + expect(actionTitleEl instanceof HTMLAnchorElement).toBe(true); + expect(actionTitleEl.innerText).toBe(FAKE_EVENT_CLICK.document.title); + expect(actionTitleEl.href).toMatch(FAKE_EVENT_CLICK.document.clickUri); + }); - expect(viewElement).not.toBeNull(); - expect(viewElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTimeShort(FAKE_VIEW_EVENT.timestamp) - ); - }); + it('should display the time of the action as footer', async () => { + const mock = await getMockComponent([FAKE_EVENT_CLICK], null); - it('should display the time of the event in long format if in a wider interface', async () => { - const element = document.createElement('div'); - Object.defineProperties(element, { - offsetWidth: { - get() { - return '500'; - }, - }, + const actionEl = mock.cmp.element.querySelector(ACTION_CLICK_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooterEl.innerHTML).toMatch(`${formatTime(FAKE_EVENT_CLICK.timestamp)}`); }); - const mock = await getMockComponent([FAKE_VIEW_EVENT], element); + it('should display the originLevel1 of the action as footer if available', async () => { + const mock = await getMockComponent([FAKE_EVENT_CLICK], null); - const searchElement = mock.cmp.element.querySelector('.coveo-view'); - expect(searchElement).not.toBeNull(); - expect(searchElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTime(FAKE_VIEW_EVENT.timestamp) - ); + const actionEl = mock.cmp.element.querySelector(ACTION_CLICK_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooterEl.innerHTML).toMatch(FAKE_EVENT_CLICK.raw.origin_level_1); + }); }); - it('should display the originLevel1 as event footer', async () => { - const mock = await getMockComponent([FAKE_VIEW_EVENT]); + describe('pageview', () => { + it('should display the title of the pageview action', async () => { + const viewEvent = { + ...FAKE_EVENT_VIEW, + raw: { + ...FAKE_EVENT_VIEW.raw, + content_id_key: 'foo', + }, + }; + const mock = await getMockComponent([viewEvent], null); - const viewElement = mock.cmp.element.querySelector('.coveo-view'); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_VIEW_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector(ACTION_TITLE_SELECTOR); + expect(actionTitleEl.innerText).toBe(viewEvent.raw.title); + }); - expect(viewElement).not.toBeNull(); - expect(viewElement.querySelector('.coveo-footer').innerText).toMatch(FAKE_VIEW_EVENT.raw.origin_level_1); - }); - }); + it('should display an anchor as the title of the pageview action when content_id_key is @clickableuri', async () => { + const mock = await getMockComponent([FAKE_EVENT_VIEW], null); - describe('custom event', () => { - it('should display the event type as event title', async () => { - const mock = await getMockComponent([FAKE_CUSTOM_EVENT]); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_VIEW_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector('.coveo-activity-title-section'); + const customActionAnchorEl = actionTitleEl.firstElementChild as HTMLAnchorElement; + expect(customActionAnchorEl instanceof HTMLAnchorElement).toBe(true); + expect(customActionAnchorEl.innerText).toBe(FAKE_EVENT_VIEW.raw.title); + expect(customActionAnchorEl.href).toMatch(FAKE_EVENT_VIEW.raw.content_id_value); + }); - const clickElement = mock.cmp.element.querySelector('.coveo-custom'); + it('should display the time of the action as footer', async () => { + const mock = await getMockComponent([FAKE_EVENT_VIEW], null); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe(FAKE_CUSTOM_EVENT.raw.event_type); - }); + const actionEl = mock.cmp.element.querySelector(ACTION_VIEW_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooterEl.innerHTML).toMatch(`${formatTime(FAKE_EVENT_VIEW.timestamp)}`); + }); - it('should display "Custom Action" as event title when the event type is unavailable', async () => { - const mock = await getMockComponent([FAKE_CUSTOM_EVENT_WITHOUT_TYPE]); + it('should display the originLevel1 of the action as footer if available', async () => { + const mock = await getMockComponent([FAKE_EVENT_VIEW], null); - const clickElement = mock.cmp.element.querySelector('.coveo-custom'); + const actionEl = mock.cmp.element.querySelector(ACTION_VIEW_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVITY_TITLE_SELECTOR).innerText).toBe('Custom Action'); + expect(actionFooterEl.innerHTML).toMatch(FAKE_EVENT_VIEW.raw.origin_level_1); + }); }); - it('should display the event value as event data', async () => { - const mock = await getMockComponent([FAKE_CUSTOM_EVENT]); - - const clickElement = mock.cmp.element.querySelector('.coveo-custom'); + describe('custom', () => { + it('should display the event value as the title', async () => { + const mock = await getMockComponent([FAKE_EVENT_CUSTOM], null); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-data').innerText).toBe(FAKE_CUSTOM_EVENT.raw.event_value); - }); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_CUSTOM_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector(ACTION_TITLE_SELECTOR); + expect(actionTitleEl.innerHTML).toMatch(FAKE_EVENT_CUSTOM.raw.event_value); + }); - it('should display the time of the event', async () => { - const mock = await getMockComponent([FAKE_CUSTOM_EVENT]); + it('should fallback on the event type as the title if there are no event value', async () => { + const customEventWithoutValue = { + ...FAKE_EVENT_CUSTOM, + raw: { + ...FAKE_EVENT_CUSTOM.raw, + event_value: '', + }, + }; + const mock = await getMockComponent([customEventWithoutValue], null); - const clickElement = mock.cmp.element.querySelector('.coveo-custom'); + const actionsEl = mock.cmp.element.querySelectorAll(ACTION_CUSTOM_SELECTOR); + expect(actionsEl.length).toBe(1); + const actionTitleEl = actionsEl[0].querySelector(ACTION_TITLE_SELECTOR); + expect(actionTitleEl.innerHTML).toMatch(customEventWithoutValue.raw.event_type); + }); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTimeShort(FAKE_CUSTOM_EVENT.timestamp) - ); - }); + it('should display the time of the action as footer', async () => { + const mock = await getMockComponent([FAKE_EVENT_CUSTOM], null); - it('should display the time of the event in long format if in a wider interface', async () => { - const element = document.createElement('div'); - Object.defineProperties(element, { - offsetWidth: { - get() { - return '500'; - }, - }, + const actionEl = mock.cmp.element.querySelector(ACTION_CUSTOM_SELECTOR); + const actionFooter = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooter.innerHTML).toMatch(`${formatTime(FAKE_EVENT_CUSTOM.timestamp)}`); }); - const mock = await getMockComponent([FAKE_CUSTOM_EVENT], element); + it('should display the originLevel1 of the action as footer if available', async () => { + const mock = await getMockComponent([FAKE_EVENT_CUSTOM], null); - const searchElement = mock.cmp.element.querySelector('.coveo-custom'); - expect(searchElement).not.toBeNull(); - expect(searchElement.querySelector(ACTIVIY_TIMESTAMP_SELECTOR).innerText).toMatch( - formatDateAndTime(FAKE_CUSTOM_EVENT.timestamp) - ); + const actionEl = mock.cmp.element.querySelector(ACTION_CUSTOM_SELECTOR); + const actionFooterEl = actionEl.querySelector(ACTION_FOOTER_SELECTOR); + expect(actionFooterEl.innerHTML).toMatch(FAKE_EVENT_CUSTOM.raw.origin_level_1); + }); }); + }); - it('should display the originLevel1 as event footer', async () => { - const mock = await getMockComponent([FAKE_CUSTOM_EVENT]); - - const clickElement = mock.cmp.element.querySelector('.coveo-custom'); + it('Should disable itself when the userId is falsey', () => { + let getActionStub: SinonStub<[HTMLElement, UserActivity], void>; + const mock = Mock.advancedComponentSetup( + UserActivity, + new Mock.AdvancedComponentSetupOptions(null, { userId: null }, (env) => { + getActionStub = fakeUserProfileModel(env.root, sandbox).getActions; + return env; + }) + ); - expect(clickElement).not.toBeNull(); - expect(clickElement.querySelector('.coveo-footer').innerText).toMatch(FAKE_CUSTOM_EVENT.raw.origin_level_1); - }); + expect(getActionStub.called).toBe(false); + expect(mock.cmp.disabled).toBe(true); + }); - it('Should disable itself when the userId is falsey', () => { - let getActionStub: SinonStub<[HTMLElement, UserActivity], void>; - const mock = Mock.advancedComponentSetup( - UserActivity, - new Mock.AdvancedComponentSetupOptions(null, { userId: null }, (env) => { - getActionStub = fakeUserProfileModel(env.root, sandbox).getActions; - return env; - }) - ); - - expect(getActionStub.called).toBe(false); - expect(mock.cmp.disabled).toBe(true); - }); + it('Should disable itself when the userId is empty string', () => { + let getActionStub: SinonStub<[HTMLElement, UserActivity], void>; + const mock = Mock.advancedComponentSetup( + UserActivity, + new Mock.AdvancedComponentSetupOptions(null, { userId: '' }, (env) => { + getActionStub = fakeUserProfileModel(env.root, sandbox).getActions; + return env; + }) + ); - it('Should disable itself when the userId is empty string', () => { - let getActionStub: SinonStub<[HTMLElement, UserActivity], void>; - const mock = Mock.advancedComponentSetup( - UserActivity, - new Mock.AdvancedComponentSetupOptions(null, { userId: '' }, (env) => { - getActionStub = fakeUserProfileModel(env.root, sandbox).getActions; - return env; - }) - ); - - expect(getActionStub.called).toBe(false); - expect(mock.cmp.disabled).toBe(true); - }); + expect(getActionStub.called).toBe(false); + expect(mock.cmp.disabled).toBe(true); }); });