mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[MVP] Product Intercept Dialog (#209571)
## Summary This PR aims to answer questions about the implementation details for https://github.com/elastic/kibana-team/issues/1328 We'd like to trigger an intercept dialog to users at specific time intervals that vary and depend on different parameters, see the PRD linked in the aforementioned issue. This MVP takes an approach such that the constraints listed below are resolved; - Have the intercept be specific to a single user - Define a strategy to configure triggers that can vary for the intercept that's not dependent on the client How does this work? - A generic plugin has been created which when declared as dependency can be used to register and schedule an intercept that should be presented to the user, this plugin is what's been used for the product intercept dialog. - To handle rendering the UI component that gets displayed to the users, in this PR we hook into core's existing notification system, through the notification coordinator system that's been created so that we don't have a situation where an intercept is being displayed whilst a user might have a toast being rendered to them, if there's an intercept to be displayed said intercept would not be displayed till the user is completely done interacting with the toast. [See it's implementation details here](src/core/packages/notifications/browser-internal/src/notification_coordinator.ts) and [here](x-pack/platform/plugins/private/intercepts/public/prompter/service/intercept_dialog_service.tsx) for how it's integrated. - The plugin provides some bootstrap data through an endpoint that every user calls on page load, ideally this would need to happen just the once on page load, we then compute when a user should see a trigger leveraging the bootstrap data provided for the particular registered trigger in question, the returned data returns the following data as seen below; <img width="476" alt="Screenshot 2025-03-27 at 18 01 12" src="https://github.com/user-attachments/assets/c747b6c8-70d0-4305-b555-ec9998b180c1" /> Given we have this data we might then have a flow for triggering the intercept on the client based of the bootstrap data like so; ```mermaid flowchart TD A[State Bootstrap] --> B{Has data?} B -->|No| C(Do Nothing) B -->|Yes| D{Does computed runs since trigger registration match stored user trigger run feedback value?} D -->|No| E[setup timer to display an intercept in that time] E -->|on completion| G[setup interval to display intercept in the future] D -->|Yes| G G -->|repeat| G ``` reloading the page restarts the entire process. ## Telemetry The intercept component provided by defaults records telemetry for intercept registration, acknowledgement (differentiated into dismissal and completion), alongside registration overload. ## Visuals <!-- https://github.com/user-attachments/assets/b39a506c-a119-40e8-9152-258d78691f28 --> <!-- https://github.com/user-attachments/assets/f564b4bc-9ad9-4e19-8158-6e154ef52fc2 --> <img width="738" alt="Screenshot 2025-05-07 at 19 41 23" src="https://github.com/user-attachments/assets/902c6d0b-9299-44bd-8808-4ad97227d0da" /> ## Testing this PR - Pull this branch to your machine - Add the following to your `kibana.dev.yml`, to enable the intercept to run and be visible ```yml xpack.intercepts.enabled: true xpack.product_intercept.enabled: true xpack.product_intercept.interval: '30s' ``` <!-- ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b6d71ea7e5
commit
f635e2a3b0
65 changed files with 3342 additions and 3 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -878,12 +878,14 @@ x-pack/platform/plugins/private/global_search_providers @elastic/appex-sharedux
|
|||
x-pack/platform/plugins/private/graph @elastic/kibana-visualizations
|
||||
x-pack/platform/plugins/private/grokdebugger @elastic/kibana-management
|
||||
x-pack/platform/plugins/private/index_lifecycle_management @elastic/kibana-management
|
||||
x-pack/platform/plugins/private/intercepts @elastic/appex-sharedux
|
||||
x-pack/platform/plugins/private/license_api_guard @elastic/kibana-management
|
||||
x-pack/platform/plugins/private/logstash @elastic/logstash
|
||||
x-pack/platform/plugins/private/monitoring @elastic/stack-monitoring
|
||||
x-pack/platform/plugins/private/monitoring_collection @elastic/stack-monitoring
|
||||
x-pack/platform/plugins/private/observability_ai_assistant_management @elastic/obs-ai-assistant
|
||||
x-pack/platform/plugins/private/painless_lab @elastic/kibana-management
|
||||
x-pack/platform/plugins/private/product_intercept @elastic/appex-sharedux
|
||||
x-pack/platform/plugins/private/remote_clusters @elastic/kibana-management
|
||||
x-pack/platform/plugins/private/reporting @elastic/response-ops
|
||||
x-pack/platform/plugins/private/rollup @elastic/kibana-management
|
||||
|
|
|
@ -156,6 +156,7 @@ mapped_pages:
|
|||
| [inferenceEndpoint](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_endpoint/README.md) | A Kibana plugin |
|
||||
| [infra](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/infra/README.md) | This is the home of the infra plugin, which aims to provide a solution for the infrastructure monitoring use-case within Kibana. |
|
||||
| [ingestPipelines](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_pipelines/README.md) | The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest pipelines. |
|
||||
| [intercepts](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/intercepts/README.md) | Contains business logic and orchestration for displaying the intercept dialog suited to the needs of Kibana, and is made available so that other solution teams might leverage this to register and schedule an intercept of their choosing |
|
||||
| [inventory](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/inventory/README.md) | Home of the Inventory plugin, which renders the... inventory. |
|
||||
| [lens](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/lens/readme.md) | Lens is a visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. |
|
||||
| [licenseApiGuard](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/license_api_guard/README.md) | This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. |
|
||||
|
@ -182,6 +183,7 @@ mapped_pages:
|
|||
| [osquery](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/osquery/README.md) | This plugin adds extended support to Security Solution Fleet Osquery integration |
|
||||
| [painlessLab](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/painless_lab/README.md) | This plugin helps users learn how to use the Painless scripting language. |
|
||||
| [productDocBase](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ai_infra/product_doc_base/README.md) | This plugin contains the product documentation base service. |
|
||||
| [productIntercept](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/product_intercept/README.md) | This is a standalone plugin that leverages the intercept plugin to display product intercept used to gather information that is turn used to compute CSAT about user's experience of Kibana. |
|
||||
| [profiling](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/profiling/README.md) | Universal Profiling provides fleet-wide, whole-system, continuous profiling with zero instrumentation. Get a comprehensive understanding of what lines of code are consuming compute resources throughout your entire fleet by visualizing your data in Kibana using the flamegraph, stacktraces, and top functions views. |
|
||||
| [profilingDataAccess](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/profiling_data_access) | WARNING: Missing or empty README. |
|
||||
| [remoteClusters](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/remote_clusters/README.md) | This plugin helps users manage their remote clusters, which enable cross-cluster search and cross-cluster replication. |
|
||||
|
|
|
@ -603,6 +603,7 @@
|
|||
"@kbn/inspector-plugin": "link:src/platform/plugins/shared/inspector",
|
||||
"@kbn/interactive-setup-plugin": "link:src/platform/plugins/private/interactive_setup",
|
||||
"@kbn/interactive-setup-test-endpoints-plugin": "link:src/platform/test/interactive_setup_api_integration/plugins/test_endpoints",
|
||||
"@kbn/intercepts-plugin": "link:x-pack/platform/plugins/private/intercepts",
|
||||
"@kbn/interpreter": "link:src/platform/packages/shared/kbn-interpreter",
|
||||
"@kbn/inventory-plugin": "link:x-pack/solutions/observability/plugins/inventory",
|
||||
"@kbn/io-ts-utils": "link:src/platform/packages/shared/kbn-io-ts-utils",
|
||||
|
@ -741,6 +742,7 @@
|
|||
"@kbn/presentation-util-plugin": "link:src/platform/plugins/shared/presentation_util",
|
||||
"@kbn/product-doc-base-plugin": "link:x-pack/platform/plugins/shared/ai_infra/product_doc_base",
|
||||
"@kbn/product-doc-common": "link:x-pack/platform/packages/shared/ai-infra/product-doc-common",
|
||||
"@kbn/product-intercept-plugin": "link:x-pack/platform/plugins/private/product_intercept",
|
||||
"@kbn/profiler-cli": "link:x-pack/platform/packages/shared/kbn-profiler-cli",
|
||||
"@kbn/profiling-data-access-plugin": "link:x-pack/solutions/observability/plugins/profiling_data_access",
|
||||
"@kbn/profiling-plugin": "link:x-pack/solutions/observability/plugins/profiling",
|
||||
|
|
|
@ -746,6 +746,13 @@
|
|||
"use_space_awareness_migration_started_at",
|
||||
"use_space_awareness_migration_status"
|
||||
],
|
||||
"intercept_interaction_record": [],
|
||||
"intercept_trigger_record": [
|
||||
"firstRegisteredAt",
|
||||
"installedOn",
|
||||
"recurrent",
|
||||
"triggerAfter"
|
||||
],
|
||||
"inventory-view": [],
|
||||
"kql-telemetry": [],
|
||||
"legacy-url-alias": [
|
||||
|
|
|
@ -2484,6 +2484,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"intercept_interaction_record": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
},
|
||||
"intercept_trigger_record": {
|
||||
"dynamic": false,
|
||||
"properties": {
|
||||
"firstRegisteredAt": {
|
||||
"type": "date"
|
||||
},
|
||||
"installedOn": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"recurrent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"triggerAfter": {
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inventory-view": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
|
@ -4022,4 +4043,4 @@
|
|||
"dynamic": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,6 +82,7 @@ pageLoadAssetSize:
|
|||
inputControlVis: 172675
|
||||
inspector: 18000
|
||||
interactiveSetup: 80000
|
||||
intercepts: 26158
|
||||
inventory: 27430
|
||||
kibanaOverview: 56279
|
||||
kibanaReact: 74422
|
||||
|
@ -117,6 +118,7 @@ pageLoadAssetSize:
|
|||
presentationPanel: 11550
|
||||
presentationUtil: 9000
|
||||
productDocBase: 22500
|
||||
productIntercept: 23846
|
||||
profiling: 36694
|
||||
remoteClusters: 51327
|
||||
reporting: 58600
|
||||
|
|
|
@ -44,8 +44,8 @@ export class NotificationsService {
|
|||
|
||||
public setup({ uiSettings, analytics }: SetupDeps): NotificationsSetup {
|
||||
const notificationSetup = {
|
||||
toasts: this.toasts.setup({ uiSettings, analytics }),
|
||||
coordinator: this.coordinator,
|
||||
toasts: this.toasts.setup({ uiSettings, analytics }),
|
||||
};
|
||||
|
||||
this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => {
|
||||
|
|
|
@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration';
|
|||
|
||||
// set minimum number of registered saved objects to ensure no object types are removed after 8.8
|
||||
// declared in internal implementation exclicilty to prevent unintended changes.
|
||||
export const SAVED_OBJECT_TYPES_COUNT = 130 as const;
|
||||
export const SAVED_OBJECT_TYPES_COUNT = 132 as const;
|
||||
|
|
|
@ -129,6 +129,8 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"ingest-outputs": "6743521f501bd77b1523dbb1df48d7c47fdad529",
|
||||
"ingest-package-policies": "6a80000fdf2544f2485b0c6a51ecc434b6a12987",
|
||||
"ingest_manager_settings": "111a616eb72627c002029c19feb9e6c439a10505",
|
||||
"intercept_interaction_record": "13587751af378409df5cadd08aeb0d3884b1645a",
|
||||
"intercept_trigger_record": "9223039379bf9997781ad91df120eb360c3e6b77",
|
||||
"inventory-view": "fd2b7fe713956f261018dded00d8f8c986417763",
|
||||
"kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad",
|
||||
"legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8",
|
||||
|
|
|
@ -85,6 +85,8 @@ const previouslyRegisteredTypes = [
|
|||
'guided-onboarding-guide-state',
|
||||
'guided-onboarding-plugin-state',
|
||||
'index-pattern',
|
||||
'intercept_interaction_record',
|
||||
'intercept_trigger_record',
|
||||
'infrastructure-monitoring-log-view',
|
||||
'infrastructure-ui-source',
|
||||
'infra-custom-dashboards',
|
||||
|
|
|
@ -84,6 +84,8 @@ export default function () {
|
|||
pattern: '[%date][%level][%logger] %message %meta',
|
||||
},
|
||||
})}`,
|
||||
// disable product intercept for all ftr tests by default
|
||||
'--xpack.intercepts.enabled=false',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -42,6 +42,9 @@ export default async function ({ readConfigFile }) {
|
|||
|
||||
// disable fleet task that writes to metrics.fleet_server.* data streams, impacting functional tests
|
||||
`--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify(['Fleet-Metrics-Task'])}`,
|
||||
|
||||
// disable product intercept
|
||||
'--xpack.intercepts.enabled=false',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -1104,6 +1104,8 @@
|
|||
"@kbn/interactive-setup-plugin/*": ["src/platform/plugins/private/interactive_setup/*"],
|
||||
"@kbn/interactive-setup-test-endpoints-plugin": ["src/platform/test/interactive_setup_api_integration/plugins/test_endpoints"],
|
||||
"@kbn/interactive-setup-test-endpoints-plugin/*": ["src/platform/test/interactive_setup_api_integration/plugins/test_endpoints/*"],
|
||||
"@kbn/intercepts-plugin": ["x-pack/platform/plugins/private/intercepts"],
|
||||
"@kbn/intercepts-plugin/*": ["x-pack/platform/plugins/private/intercepts/*"],
|
||||
"@kbn/interpreter": ["src/platform/packages/shared/kbn-interpreter"],
|
||||
"@kbn/interpreter/*": ["src/platform/packages/shared/kbn-interpreter/*"],
|
||||
"@kbn/inventory-e2e": ["x-pack/solutions/observability/plugins/inventory/e2e"],
|
||||
|
@ -1434,6 +1436,8 @@
|
|||
"@kbn/product-doc-base-plugin/*": ["x-pack/platform/plugins/shared/ai_infra/product_doc_base/*"],
|
||||
"@kbn/product-doc-common": ["x-pack/platform/packages/shared/ai-infra/product-doc-common"],
|
||||
"@kbn/product-doc-common/*": ["x-pack/platform/packages/shared/ai-infra/product-doc-common/*"],
|
||||
"@kbn/product-intercept-plugin": ["x-pack/platform/plugins/private/product_intercept"],
|
||||
"@kbn/product-intercept-plugin/*": ["x-pack/platform/plugins/private/product_intercept/*"],
|
||||
"@kbn/profiler-cli": ["x-pack/platform/packages/shared/kbn-profiler-cli"],
|
||||
"@kbn/profiler-cli/*": ["x-pack/platform/packages/shared/kbn-profiler-cli/*"],
|
||||
"@kbn/profiling-data-access-plugin": ["x-pack/solutions/observability/plugins/profiling_data_access"],
|
||||
|
|
60
x-pack/platform/plugins/private/intercepts/README.md
Normal file
60
x-pack/platform/plugins/private/intercepts/README.md
Normal file
|
@ -0,0 +1,60 @@
|
|||
## Intercept plugin
|
||||
|
||||
Contains business logic and orchestration for displaying the intercept dialog suited to the needs of Kibana, and is made available so that other solution teams might leverage this to register and schedule an intercept of their choosing
|
||||
|
||||
Exposes the following public api;
|
||||
|
||||
All exposed APIs existing solely on the start contract;
|
||||
|
||||
On the server;
|
||||
|
||||
- `registerTriggerDefinition`: This method registers a new trigger, and accepts an id for said trigger, alongside a callback that should return a duration interval. The callback provides values that can be used to conditionally return said interval depending on the use case. When a falsy value is returned no action is taken, if the the id provide already exists and a different trigger interval is provided we update said trigger's interval in place, if the same interval is turned no action is taken. One might define a new trigger like so;
|
||||
|
||||
```ts
|
||||
const TRIGGER_ID = 'some_trigger';
|
||||
|
||||
const isServerless = (function evaluateIfIsServerless() {/* ... */})()
|
||||
|
||||
registerTriggerDefinition(TRIGGER_ID, ({ }) => {
|
||||
|
||||
return isServerless ? '30d' : '180d';
|
||||
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
|
||||
On the client;
|
||||
|
||||
- `registerIntercept`: The method provides a mechanism to configure the intercept that would get displayed to the user, along sides some hooks to act on the feedback provided as the user journeys through the configured steps;
|
||||
|
||||
when calling this method, the value of id should match the ID that was registered on the server
|
||||
|
||||
```ts
|
||||
|
||||
registerIntercept({
|
||||
id: TRIGGER_DEF_ID,
|
||||
steps: [],
|
||||
onProgress({ stepId, stepResponse, runId }) {
|
||||
// step response received on the last completed step
|
||||
// allows the developer to act on the data immediately if
|
||||
// they so wish especially that users might close the
|
||||
// intercept at any point
|
||||
},
|
||||
onFinish({ response, runId }) {
|
||||
// response here will be an object that contains all the
|
||||
// input received provided the user completed all steps
|
||||
// for the intercept
|
||||
},
|
||||
onDismiss({ stepId, runId }) {
|
||||
// callback called when a user dismisses the intercept
|
||||
}
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
Invoking the `registerIntercept` method returns a cold observable, that when subscribed to will kick off computation for when the intercept should be displayed and queue the intercept at the time it should be displayed, said subscription simply returns the ID of the last run.
|
||||
|
||||
This plugin also exposes the following config
|
||||
|
||||
- `xpack.intercepts.enabled`: Expects boolean value, denotes if the intercept plugin service will run, disabling this plugin implies that any downstream dependent of this plugin will not function.
|
30
x-pack/platform/plugins/private/intercepts/common/config.ts
Normal file
30
x-pack/platform/plugins/private/intercepts/common/config.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import type { PluginConfigDescriptor, ExposedToBrowserDescriptor } from '@kbn/core/server';
|
||||
|
||||
/**
|
||||
* Config used by plugin to determine if orchestration is invoked,
|
||||
* and if the product intercept gets displayed on the client.
|
||||
*/
|
||||
export const configSchema = schema.object({
|
||||
enabled: schema.boolean({
|
||||
defaultValue: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export type ServerConfigSchema = TypeOf<typeof configSchema>;
|
||||
|
||||
const browserConfigSchemaDescriptor: ExposedToBrowserDescriptor<ServerConfigSchema> = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const config: PluginConfigDescriptor<ServerConfigSchema> = {
|
||||
exposeToBrowser: browserConfigSchemaDescriptor,
|
||||
schema: configSchema,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const TRIGGER_INFO_API_ROUTE = '/internal/product_intercept/trigger_info' as const;
|
||||
|
||||
export const TRIGGER_USER_INTERACTION_METADATA_API_ROUTE =
|
||||
'/internal/api/intercept/user_interaction/{triggerId}' as const;
|
12
x-pack/platform/plugins/private/intercepts/common/types.ts
Normal file
12
x-pack/platform/plugins/private/intercepts/common/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export type TriggerInfo = {
|
||||
registeredAt: ReturnType<Date['toISOString']>;
|
||||
triggerIntervalInMs: number;
|
||||
recurrent: boolean;
|
||||
} | null;
|
12
x-pack/platform/plugins/private/intercepts/jest.config.js
Normal file
12
x-pack/platform/plugins/private/intercepts/jest.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/platform/plugins/private/intercepts'],
|
||||
};
|
16
x-pack/platform/plugins/private/intercepts/kibana.jsonc
Normal file
16
x-pack/platform/plugins/private/intercepts/kibana.jsonc
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/intercepts-plugin",
|
||||
"owner": ["@elastic/appex-sharedux"],
|
||||
"group": "platform",
|
||||
"visibility": "shared",
|
||||
"description": "This plugin provides implementation for system level conditions which are then used in soliciting product feedback from users.",
|
||||
"plugin": {
|
||||
"id": "intercepts",
|
||||
"browser": true,
|
||||
"server": true,
|
||||
"requiredPlugins": ["cloud"],
|
||||
"requiredBundles": [],
|
||||
"configPath": ["xpack", "intercepts"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
<svg width="113" height="81" viewBox="0 0 113 81" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_1084)">
|
||||
<path d="M109.006 0.500443H37.0027C35.071 0.500443 33.5096 2.06182 33.5096 3.99353V49.4438C33.5096 51.3755 35.071 52.9368 37.0027 52.9368H83.3337L99.8884 65.8883V68.6207H103.832L103.582 53.2271L108.996 52.9469C110.928 52.9469 112.49 51.3855 112.49 49.4538V3.99353C112.49 2.06182 110.928 0.500443 108.996 0.500443H109.006Z" fill="#153385" stroke="#101C3F" stroke-miterlimit="10"/>
|
||||
<path d="M105.834 0.500443H33.8299C31.8982 0.500443 30.3368 2.06182 30.3368 3.99353V49.4438C30.3368 51.3755 31.8982 52.9368 33.8299 52.9368H80.1609L99.438 68.5206C99.7182 68.7408 100.129 68.5507 100.129 68.1903V52.9368H105.824C107.755 52.9368 109.317 51.3755 109.317 49.4438V3.99353C109.317 2.06182 107.755 0.500443 105.824 0.500443H105.834Z" fill="#0B64DD" stroke="#101C3F" stroke-miterlimit="10"/>
|
||||
<path d="M50.7149 21.0386H23.3106C10.9597 21.0386 0.61054 30.8673 0.500443 43.2182C0.410363 52.9569 5.95527 61.2542 14.5529 64.457V79.8807C14.5529 80.0909 14.723 80.261 14.9332 80.261H19.7975C20.0077 80.261 20.1779 80.0909 20.1779 79.8807V76.6378L33.1393 65.8783H50.1744C62.3352 65.8783 72.6843 56.4299 73.1147 44.2792C73.5651 31.5279 63.3661 21.0386 50.7049 21.0386H50.7149Z" fill="#153385" stroke="#101C3F" stroke-miterlimit="10"/>
|
||||
<path d="M55.4891 21.0386H27.6945C15.3136 21.0386 5.27467 31.0775 5.27467 43.4585C5.27467 53.097 11.3701 61.2842 19.8976 64.457V80.261L37.9236 65.8783H55.4891C67.8701 65.8783 77.9089 55.8394 77.9089 43.4585C77.9089 31.0775 67.8701 21.0386 55.4891 21.0386Z" fill="white" stroke="#101C3F" stroke-linejoin="round"/>
|
||||
<path d="M25.2223 46.4211C27.3284 46.4211 29.0357 44.7138 29.0357 42.6077C29.0357 40.5016 27.3284 38.7943 25.2223 38.7943C23.1163 38.7943 21.4089 40.5016 21.4089 42.6077C21.4089 44.7138 23.1163 46.4211 25.2223 46.4211Z" fill="#101C3F"/>
|
||||
<path d="M41.5968 46.4211C43.7029 46.4211 45.4102 44.7138 45.4102 42.6077C45.4102 40.5016 43.7029 38.7943 41.5968 38.7943C39.4907 38.7943 37.7834 40.5016 37.7834 42.6077C37.7834 44.7138 39.4907 46.4211 41.5968 46.4211Z" fill="#101C3F"/>
|
||||
<path d="M57.9713 46.4211C60.0774 46.4211 61.7847 44.7138 61.7847 42.6077C61.7847 40.5016 60.0774 38.7943 57.9713 38.7943C55.8652 38.7943 54.1579 40.5016 54.1579 42.6077C54.1579 44.7138 55.8652 46.4211 57.9713 46.4211Z" fill="#101C3F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_1084">
|
||||
<rect width="112.9" height="80.69" fill="white" transform="scale(1.00089)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
14
x-pack/platform/plugins/private/intercepts/public/index.ts
Normal file
14
x-pack/platform/plugins/private/intercepts/public/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PluginInitializerContext } from '@kbn/core/public';
|
||||
import { InterceptPublicPlugin } from './plugin';
|
||||
export type { InterceptsSetup, InterceptsStart } from './plugin';
|
||||
|
||||
export function plugin(initializerContext: PluginInitializerContext) {
|
||||
return new InterceptPublicPlugin(initializerContext);
|
||||
}
|
54
x-pack/platform/plugins/private/intercepts/public/plugin.ts
Normal file
54
x-pack/platform/plugins/private/intercepts/public/plugin.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
|
||||
import { InterceptPrompter } from './prompter';
|
||||
import type { ServerConfigSchema } from '../common/config';
|
||||
|
||||
export class InterceptPublicPlugin implements Plugin {
|
||||
private readonly prompter?: InterceptPrompter;
|
||||
private interceptsTargetDomElement?: HTMLDivElement;
|
||||
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
const { enabled } = initializerContext.config.get<ServerConfigSchema>();
|
||||
|
||||
if (enabled) {
|
||||
this.prompter = new InterceptPrompter();
|
||||
}
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup) {
|
||||
this.prompter?.setup({
|
||||
analytics: core.analytics,
|
||||
notifications: core.notifications,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
this.interceptsTargetDomElement = document.createElement('div');
|
||||
|
||||
const prompterStart = this.prompter?.start({
|
||||
http: core.http,
|
||||
analytics: core.analytics,
|
||||
rendering: core.rendering,
|
||||
targetDomElement: this.interceptsTargetDomElement,
|
||||
});
|
||||
|
||||
return {
|
||||
registerIntercept: prompterStart?.registerIntercept.bind(prompterStart),
|
||||
};
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.interceptsTargetDomElement?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export type InterceptsSetup = ReturnType<InterceptPublicPlugin['setup']>;
|
||||
export type InterceptsStart = ReturnType<InterceptPublicPlugin['start']>;
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export type { Intercept } from './intercept_display_manager';
|
||||
export { InterceptDisplayManager } from './intercept_display_manager';
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as Rx from 'rxjs';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { InterceptDisplayManager, type Intercept } from './intercept_display_manager';
|
||||
|
||||
const staticAssetsHelperMock = httpServiceMock.createSetupContract().staticAssets;
|
||||
|
||||
const mockPerformanceMark = jest.fn(
|
||||
(name) =>
|
||||
({
|
||||
name,
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
entryType: 'mark',
|
||||
detail: {},
|
||||
toJSON: () => ({}),
|
||||
} as PerformanceMark)
|
||||
);
|
||||
|
||||
const mockPerformanceMeasure = jest.fn(
|
||||
(name) =>
|
||||
({
|
||||
name,
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
entryType: 'measure',
|
||||
detail: {},
|
||||
toJSON: () => ({}),
|
||||
} as PerformanceMeasure)
|
||||
);
|
||||
|
||||
describe('InterceptDisplayManager', () => {
|
||||
beforeAll(() => {
|
||||
window.performance.mark = mockPerformanceMark;
|
||||
window.performance.measure = mockPerformanceMeasure;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not render the dialog shell when there is no intercept to display', () => {
|
||||
const ackProductIntercept = jest.fn();
|
||||
|
||||
render(
|
||||
<InterceptDisplayManager
|
||||
ackIntercept={ackProductIntercept}
|
||||
intercept$={Rx.EMPTY}
|
||||
staticAssetsHelper={staticAssetsHelperMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the dialog shell when there is an intercept to display', () => {
|
||||
const ackProductIntercept = jest.fn();
|
||||
|
||||
const interceptStep: Intercept['steps'][number] = {
|
||||
id: 'hello',
|
||||
title: 'Hello World',
|
||||
content: () => <>{'This is a test'}</>,
|
||||
};
|
||||
|
||||
const intercept$ = new Rx.BehaviorSubject<Intercept>({
|
||||
id: '1',
|
||||
runId: 1,
|
||||
steps: [
|
||||
{ ...interceptStep, id: 'start' },
|
||||
interceptStep,
|
||||
{ ...interceptStep, id: 'completion' },
|
||||
],
|
||||
onFinish: jest.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<InterceptDisplayManager
|
||||
ackIntercept={ackProductIntercept}
|
||||
intercept$={intercept$.asObservable()}
|
||||
staticAssetsHelper={staticAssetsHelperMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeNull();
|
||||
expect(screen.getByTestId(`interceptStep-start`)).not.toBeNull();
|
||||
expect(screen.getByText('Hello World')).not.toBeNull();
|
||||
expect(screen.getByText('This is a test')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes the dialog and calls the provided ack function when the close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const ackProductIntercept = jest.fn();
|
||||
const interceptStep: Intercept['steps'][number] = {
|
||||
id: 'hello',
|
||||
title: 'Hello World',
|
||||
content: () => <>{'This is a test'}</>,
|
||||
};
|
||||
|
||||
const intercept$ = new Rx.BehaviorSubject<Intercept>({
|
||||
id: '1',
|
||||
runId: 1,
|
||||
steps: [
|
||||
{ ...interceptStep, id: 'start' },
|
||||
interceptStep,
|
||||
{ ...interceptStep, id: 'completion' },
|
||||
],
|
||||
onFinish: jest.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<InterceptDisplayManager
|
||||
ackIntercept={ackProductIntercept}
|
||||
intercept$={intercept$.asObservable()}
|
||||
staticAssetsHelper={staticAssetsHelperMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeNull();
|
||||
|
||||
await user.click(screen.getByTestId('productInterceptDismiss'));
|
||||
|
||||
expect(ackProductIntercept).toHaveBeenCalledWith({
|
||||
ackType: 'dismissed',
|
||||
interceptId: '1',
|
||||
runId: 1,
|
||||
interactionDuration: expect.any(Number),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes the passed onProgress handler with the response the user provides as feedback', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const ackProductIntercept = jest.fn();
|
||||
|
||||
const interceptStep: Intercept['steps'][number] = {
|
||||
id: 'hello',
|
||||
title: 'Hello World',
|
||||
content: () => <>{'This is a test'}</>,
|
||||
};
|
||||
|
||||
const productIntercept: Intercept = {
|
||||
id: '1',
|
||||
runId: 1,
|
||||
steps: [
|
||||
{ ...interceptStep, id: 'start' },
|
||||
{
|
||||
...interceptStep,
|
||||
content: function InterceptContentTest({ onValue }) {
|
||||
return (
|
||||
<form
|
||||
data-test-subj="intercept-test-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
// @ts-expect-error -- the value we seek is defined, else our tests would fail
|
||||
onValue((e.target as HTMLFormElement).elements.drone!.value);
|
||||
}}
|
||||
>
|
||||
<fieldset>
|
||||
<legend>Select a maintenance drone:</legend>
|
||||
<div>
|
||||
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
|
||||
<label htmlFor="huey">Huey</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="dewey" name="drone" value="dewey" />
|
||||
<label htmlFor="dewey">Dewey</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="louie" name="drone" value="louie" />
|
||||
<label htmlFor="louie">Louie</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Submit</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ ...interceptStep, id: 'completion' },
|
||||
],
|
||||
onProgress: jest.fn(),
|
||||
onFinish: jest.fn(),
|
||||
};
|
||||
|
||||
const intercept$ = new Rx.BehaviorSubject<Intercept>(productIntercept);
|
||||
|
||||
render(
|
||||
<InterceptDisplayManager
|
||||
ackIntercept={ackProductIntercept}
|
||||
intercept$={intercept$.asObservable()}
|
||||
staticAssetsHelper={staticAssetsHelperMock}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeNull();
|
||||
|
||||
// transition user to next step in intercept dialog
|
||||
await user.click(screen.getByTestId('productInterceptProgressionButton'));
|
||||
|
||||
expect(screen.queryByTestId('intercept-test-form')).not.toBeNull();
|
||||
|
||||
await user.click(screen.getByText('Louie'));
|
||||
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(productIntercept.onProgress).toHaveBeenCalledWith({
|
||||
runId: 1,
|
||||
stepId: 'hello',
|
||||
stepResponse: 'louie',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as Rx from 'rxjs';
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiPortal,
|
||||
useEuiTheme,
|
||||
EuiTitle,
|
||||
EuiTourStepIndicator,
|
||||
euiFlyoutSlideInRight,
|
||||
euiCanAnimate,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSplitPanel,
|
||||
EuiForm,
|
||||
EuiButtonIcon,
|
||||
} from '@elastic/eui';
|
||||
import type { HttpStart } from '@kbn/core-http-browser';
|
||||
import type { EuiTourStepStatus } from '@elastic/eui/src/components/tour/tour_step_indicator';
|
||||
import { InterceptDialogApi } from '../../service/intercept_dialog_api';
|
||||
|
||||
export type Intercept = Rx.ObservedValueOf<ReturnType<InterceptDialogApi['get$']>>[number];
|
||||
|
||||
const INTERCEPT_ILLUSTRATION_WIDTH = 89; // Magic number was provided by Ryan
|
||||
|
||||
interface InterceptDialogManagerProps {
|
||||
ackIntercept: (args: Parameters<InterceptDialogApi['ack']>[0] & Pick<Intercept, 'runId'>) => void;
|
||||
/**
|
||||
* Observable that emits the intercept to be displayed
|
||||
*/
|
||||
intercept$: Rx.Observable<Intercept>;
|
||||
/**
|
||||
* Helper to load static assets pertinent to the intercept plugin
|
||||
*/
|
||||
staticAssetsHelper: HttpStart['staticAssets'];
|
||||
}
|
||||
|
||||
interface InterceptProgressIndicatorProps {
|
||||
stepsTotal: number;
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
const InterceptProgressIndicator = React.memo(
|
||||
({ stepsTotal, currentStep }: InterceptProgressIndicatorProps) => {
|
||||
if (!stepsTotal) return null;
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ul className="euiTourFooter__stepList">
|
||||
{[...Array(stepsTotal).keys()].map((_, i) => {
|
||||
let status: EuiTourStepStatus = 'complete';
|
||||
if (currentStep === i) {
|
||||
status = 'active';
|
||||
} else if (currentStep <= i) {
|
||||
status = 'incomplete';
|
||||
}
|
||||
return <EuiTourStepIndicator key={i} number={i + 1} status={status} />;
|
||||
})}
|
||||
</ul>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function InterceptDisplayManager({
|
||||
ackIntercept,
|
||||
intercept$,
|
||||
staticAssetsHelper,
|
||||
}: InterceptDialogManagerProps) {
|
||||
const interceptRenderMark = useRef<PerformanceMark>();
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [currentIntercept, setCurrentIntercept] = useState<Intercept | null>(null);
|
||||
const feedbackStore = useRef<Record<string, unknown>>({});
|
||||
const startIllustrationStyle = useRef(css`
|
||||
background: var(
|
||||
--intercept-background,
|
||||
url(${staticAssetsHelper.getPluginAssetHref('communication.svg')})
|
||||
);
|
||||
background-size: ${INTERCEPT_ILLUSTRATION_WIDTH}px 64px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top ${euiTheme.size.base} right ${euiTheme.size.base};
|
||||
`);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = intercept$.subscribe((intercept) => {
|
||||
setCurrentStepIndex(0);
|
||||
setCurrentIntercept(intercept);
|
||||
interceptRenderMark.current = performance.mark(`intercept-${intercept.id}-RenderMark`);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [intercept$]);
|
||||
|
||||
const currentInterceptStep = useMemo(() => {
|
||||
return currentIntercept?.steps?.[currentStepIndex];
|
||||
}, [currentIntercept, currentStepIndex]);
|
||||
|
||||
const nextStep = useCallback(
|
||||
(isLastStep?: boolean) => {
|
||||
setCurrentStepIndex((prevStepIndex) => {
|
||||
if (isLastStep) {
|
||||
currentIntercept?.onFinish?.({
|
||||
response: feedbackStore.current,
|
||||
runId: currentIntercept!.runId,
|
||||
});
|
||||
setCurrentStepIndex(0);
|
||||
// this will cause the component to unmount
|
||||
ackIntercept({
|
||||
runId: currentIntercept!.runId,
|
||||
interceptId: currentIntercept!.id,
|
||||
ackType: 'completed',
|
||||
interactionDuration: performance.measure('interceptCompleteMark', {
|
||||
start: interceptRenderMark.current!.startTime,
|
||||
end: performance.now(),
|
||||
}).duration,
|
||||
});
|
||||
}
|
||||
|
||||
return Math.min(prevStepIndex + 1, currentIntercept!.steps.length);
|
||||
});
|
||||
},
|
||||
[ackIntercept, currentIntercept]
|
||||
);
|
||||
|
||||
const dismissProductIntercept = useCallback(() => {
|
||||
const runId = currentIntercept!.runId;
|
||||
|
||||
ackIntercept({
|
||||
interceptId: currentIntercept!.id,
|
||||
runId,
|
||||
ackType: 'dismissed',
|
||||
interactionDuration: performance.measure('interceptDismissedMark', {
|
||||
start: interceptRenderMark.current!.startTime,
|
||||
end: performance.now(),
|
||||
}).duration,
|
||||
});
|
||||
|
||||
currentIntercept?.onDismiss?.({ runId, stepId: currentInterceptStep!.id });
|
||||
setCurrentIntercept(null);
|
||||
}, [ackIntercept, currentIntercept, currentInterceptStep]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(value: unknown) => {
|
||||
feedbackStore.current[currentInterceptStep!.id] = value;
|
||||
currentIntercept?.onProgress?.({
|
||||
stepId: currentInterceptStep!.id,
|
||||
stepResponse: value,
|
||||
runId: currentIntercept!.runId,
|
||||
});
|
||||
nextStep();
|
||||
},
|
||||
[currentIntercept, currentInterceptStep, nextStep]
|
||||
);
|
||||
|
||||
let isLastStep = false;
|
||||
const isStartStep = currentStepIndex === 0;
|
||||
|
||||
return currentIntercept && currentInterceptStep ? (
|
||||
<EuiPortal>
|
||||
<EuiSplitPanel.Outer
|
||||
grow
|
||||
role="dialog"
|
||||
css={css`
|
||||
position: fixed;
|
||||
inline-size: 400px;
|
||||
max-block-size: auto;
|
||||
z-index: ${euiTheme.levels.toast};
|
||||
inset-inline-end: ${euiTheme.size.l};
|
||||
inset-block-end: ${euiTheme.size.xxl};
|
||||
|
||||
${euiCanAnimate} {
|
||||
animation: ${euiFlyoutSlideInRight} ${euiTheme.animation.normal}
|
||||
${euiTheme.animation.resistance};
|
||||
}
|
||||
`}
|
||||
data-test-subj={`intercept-${currentIntercept.id}`}
|
||||
>
|
||||
<EuiSplitPanel.Inner
|
||||
css={css`
|
||||
min-height: 112px;
|
||||
position: relative;
|
||||
${isStartStep && startIllustrationStyle.current};
|
||||
`}
|
||||
data-test-subj={`interceptStep-${currentInterceptStep.id}`}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
direction="column"
|
||||
css={css({
|
||||
...(isStartStep
|
||||
? {
|
||||
width: `calc(100% - ${INTERCEPT_ILLUSTRATION_WIDTH}px - ${euiTheme.size.base})`,
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xs">
|
||||
<h2>{currentInterceptStep!.title}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{currentStepIndex > 0 &&
|
||||
!(isLastStep = currentStepIndex === currentIntercept.steps.length - 1) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="cross"
|
||||
aria-label="Close dialog"
|
||||
onClick={dismissProductIntercept}
|
||||
color="text"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiForm fullWidth>
|
||||
{React.createElement(currentInterceptStep!.content, {
|
||||
onValue: onInputChange,
|
||||
})}
|
||||
</EuiForm>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiSplitPanel.Inner>
|
||||
<EuiSplitPanel.Inner
|
||||
grow={false}
|
||||
color="subdued"
|
||||
css={css`
|
||||
border-top: ${euiTheme.border.thin};
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<InterceptProgressIndicator
|
||||
stepsTotal={currentIntercept.steps.length}
|
||||
currentStep={currentStepIndex}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{(isStartStep || isLastStep) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
{isStartStep && (
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
data-test-subj="productInterceptDismiss"
|
||||
onClick={dismissProductIntercept}
|
||||
color="text"
|
||||
>
|
||||
{i18n.translate('core.notifications.productIntercept.dismiss', {
|
||||
defaultMessage: 'Not now',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiButton
|
||||
size="s"
|
||||
data-test-subj="productInterceptProgressionButton"
|
||||
onClick={() => nextStep(isLastStep)}
|
||||
>
|
||||
{isLastStep
|
||||
? i18n.translate('core.notifications.productIntercept.nextStep', {
|
||||
defaultMessage: 'Close',
|
||||
})
|
||||
: i18n.translate('core.notifications.productIntercept.nextStep', {
|
||||
defaultMessage: 'Next',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiSplitPanel.Inner>
|
||||
</EuiSplitPanel.Outer>
|
||||
</EuiPortal>
|
||||
) : null;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { InterceptPrompter } from './prompter';
|
|
@ -0,0 +1,385 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import * as Rx from 'rxjs';
|
||||
import React from 'react';
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks';
|
||||
import { InterceptDialogService } from './service';
|
||||
import { InterceptPrompter } from './prompter';
|
||||
import { TRIGGER_INFO_API_ROUTE } from '../../common/constants';
|
||||
import type { TriggerInfo } from '../../common/types';
|
||||
|
||||
describe('ProductInterceptPrompter', () => {
|
||||
it('defines a setup method', () => {
|
||||
const prompter = new InterceptPrompter();
|
||||
expect(prompter).toHaveProperty('setup', expect.any(Function));
|
||||
});
|
||||
|
||||
it('defines a start method', () => {
|
||||
const prompter = new InterceptPrompter();
|
||||
expect(prompter).toHaveProperty('start', expect.any(Function));
|
||||
});
|
||||
|
||||
describe('#start', () => {
|
||||
const http = httpServiceMock.createStartContract();
|
||||
const rendering = renderingServiceMock.create();
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
|
||||
|
||||
const interceptDialogServiceStartFnSpy = jest.spyOn(InterceptDialogService.prototype, 'start');
|
||||
|
||||
let prompter: InterceptPrompter;
|
||||
|
||||
beforeAll(() => {
|
||||
prompter = new InterceptPrompter();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
prompter.setup({
|
||||
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
|
||||
notifications: notificationServiceMock.createSetupContract(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an object with a specific shape', () => {
|
||||
expect(
|
||||
prompter.start({
|
||||
http,
|
||||
rendering,
|
||||
analytics,
|
||||
targetDomElement: document.createElement('div'),
|
||||
})
|
||||
).toEqual({
|
||||
registerIntercept: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerIntercept', () => {
|
||||
let registerIntercept: ReturnType<InterceptPrompter['start']>['registerIntercept'];
|
||||
|
||||
const mockQueueInterceptFn = jest.fn();
|
||||
|
||||
const interceptSteps: Parameters<
|
||||
ReturnType<InterceptPrompter['start']>['registerIntercept']
|
||||
>[0]['steps'] = [
|
||||
{
|
||||
id: 'start' as const,
|
||||
title: 'Hello',
|
||||
content: () => React.createElement('p', null, 'Couple of questions for you sir'),
|
||||
},
|
||||
{
|
||||
id: 'interest',
|
||||
title: 'Are you interested?',
|
||||
content: () => React.createElement('p', null, '...'),
|
||||
},
|
||||
{
|
||||
id: 'completion' as const,
|
||||
title: 'Goodbye',
|
||||
content: () => React.createElement('p', null, 'Goodbye sir'),
|
||||
},
|
||||
];
|
||||
|
||||
const intercept: Parameters<ReturnType<InterceptPrompter['start']>['registerIntercept']>[0] =
|
||||
{
|
||||
id: 'test-intercept',
|
||||
steps: interceptSteps,
|
||||
onFinish: jest.fn(),
|
||||
onDismiss: jest.fn(),
|
||||
onProgress: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
interceptDialogServiceStartFnSpy.mockImplementation(() => {
|
||||
return {
|
||||
add: mockQueueInterceptFn,
|
||||
};
|
||||
});
|
||||
|
||||
({ registerIntercept } = prompter.start({
|
||||
http,
|
||||
rendering,
|
||||
analytics,
|
||||
targetDomElement: document.createElement('div'),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('invoking the registerIntercept method returns an observable', () => {
|
||||
jest.spyOn(http, 'post').mockResolvedValue({
|
||||
registeredAt: new Date().toISOString(),
|
||||
triggerIntervalInMs: 1000,
|
||||
});
|
||||
|
||||
const intercept$ = registerIntercept(intercept);
|
||||
|
||||
expect(intercept$).toBeInstanceOf(Rx.Observable);
|
||||
});
|
||||
|
||||
it('subscribing to the returned observable makes a request to the trigger info api endpoint', async () => {
|
||||
jest.spyOn(http, 'post').mockResolvedValue({
|
||||
registeredAt: new Date().toISOString(),
|
||||
triggerIntervalInMs: 1000,
|
||||
});
|
||||
|
||||
const intercept$ = registerIntercept(intercept);
|
||||
|
||||
expect(intercept$).toBeInstanceOf(Rx.Observable);
|
||||
|
||||
const subscriptionHandler = jest.fn();
|
||||
|
||||
const subscription = intercept$.subscribe(subscriptionHandler);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({ triggerId: intercept.id }),
|
||||
});
|
||||
|
||||
expect(subscriptionHandler).not.toHaveBeenCalled();
|
||||
expect(mockQueueInterceptFn).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('adds an intercept if the user has not already encountered the next scheduled run', async () => {
|
||||
const triggerInfo: TriggerInfo = {
|
||||
registeredAt: new Date(
|
||||
'26 March 2025 19:08 GMT+0100 (Central European Standard Time)'
|
||||
).toISOString(),
|
||||
triggerIntervalInMs: 30000,
|
||||
recurrent: true,
|
||||
};
|
||||
|
||||
const triggerRuns = 30;
|
||||
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
|
||||
|
||||
// return the configured trigger info
|
||||
jest.spyOn(http, 'post').mockResolvedValue(triggerInfo);
|
||||
jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns });
|
||||
|
||||
// set system time to time in the future, where there would have been 30 runs of the received trigger,
|
||||
// with just about 1/3 of the time before the next trigger
|
||||
jest.setSystemTime(
|
||||
new Date(
|
||||
Date.parse(triggerInfo.registeredAt) +
|
||||
triggerInfo.triggerIntervalInMs * triggerRuns +
|
||||
triggerInfo.triggerIntervalInMs -
|
||||
timeInMsTillNextRun
|
||||
)
|
||||
);
|
||||
|
||||
const subscriptionHandler = jest.fn();
|
||||
|
||||
const intercept$ = registerIntercept(intercept);
|
||||
|
||||
const subscription = intercept$.subscribe(subscriptionHandler);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({ triggerId: intercept.id }),
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(timeInMsTillNextRun);
|
||||
|
||||
expect(mockQueueInterceptFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: intercept.id,
|
||||
runId: triggerRuns + 1,
|
||||
})
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('does not add an intercept if the user has already encountered the currently scheduled run', async () => {
|
||||
const triggerInfo: TriggerInfo = {
|
||||
registeredAt: new Date(
|
||||
'26 March 2025 19:08 GMT+0100 (Central European Standard Time)'
|
||||
).toISOString(),
|
||||
triggerIntervalInMs: 30000,
|
||||
recurrent: true,
|
||||
};
|
||||
|
||||
const triggerRuns = 30;
|
||||
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
|
||||
|
||||
// return the configured trigger info
|
||||
jest.spyOn(http, 'post').mockResolvedValue(triggerInfo);
|
||||
jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns + 1 });
|
||||
|
||||
// set system time to time in the future, where there would have been 30 runs of the received trigger,
|
||||
// with just about 1/3 of the time before the next trigger
|
||||
jest.setSystemTime(
|
||||
new Date(
|
||||
Date.parse(triggerInfo.registeredAt) +
|
||||
triggerInfo.triggerIntervalInMs * triggerRuns +
|
||||
triggerInfo.triggerIntervalInMs -
|
||||
timeInMsTillNextRun
|
||||
)
|
||||
);
|
||||
|
||||
const subscriptionHandler = jest.fn();
|
||||
|
||||
const intercept$ = registerIntercept(intercept);
|
||||
|
||||
const subscription = intercept$.subscribe(subscriptionHandler);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({ triggerId: intercept.id }),
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(timeInMsTillNextRun);
|
||||
|
||||
expect(mockQueueInterceptFn).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('does not add an intercept if the trigger is expected to be shown only once and the user already encountered that single run of the intercept', async () => {
|
||||
const triggerInfo = {
|
||||
registeredAt: new Date(
|
||||
'26 March 2025 19:08 GMT+0100 (Central European Standard Time)'
|
||||
).toISOString(),
|
||||
triggerIntervalInMs: 30000,
|
||||
recurrent: false,
|
||||
};
|
||||
|
||||
const triggerRuns = 30;
|
||||
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
|
||||
|
||||
// return the configured trigger info
|
||||
jest.spyOn(http, 'post').mockResolvedValue(triggerInfo);
|
||||
// configure a user that encountered the intercept on the 30th run
|
||||
jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns });
|
||||
|
||||
// set system time to time in the future, where there would have been 30 runs of the received trigger,
|
||||
// with just about 1/3 of the time before the next trigger
|
||||
jest.setSystemTime(
|
||||
new Date(
|
||||
Date.parse(triggerInfo.registeredAt) +
|
||||
triggerInfo.triggerIntervalInMs * triggerRuns +
|
||||
triggerInfo.triggerIntervalInMs -
|
||||
timeInMsTillNextRun
|
||||
)
|
||||
);
|
||||
|
||||
const subscriptionHandler = jest.fn();
|
||||
|
||||
const intercept$ = registerIntercept(intercept);
|
||||
|
||||
const subscription = intercept$.subscribe(subscriptionHandler);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({ triggerId: intercept.id }),
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(timeInMsTillNextRun);
|
||||
|
||||
// we should not queue the intercept,
|
||||
// because the user already encountered it especially that it's a one off
|
||||
expect(mockQueueInterceptFn).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('queue another intercept automatically after the configured trigger interval when the time for displaying the intercept for the initial run has elapsed', async () => {
|
||||
const triggerInfo = {
|
||||
registeredAt: new Date(
|
||||
'26 March 2025 19:08 GMT+0100 (Central European Standard Time)'
|
||||
).toISOString(),
|
||||
triggerIntervalInMs: 30000,
|
||||
recurrent: true,
|
||||
};
|
||||
|
||||
const triggerRuns = 30;
|
||||
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
|
||||
|
||||
// return the configured trigger info
|
||||
jest.spyOn(http, 'post').mockResolvedValue(triggerInfo);
|
||||
jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns });
|
||||
|
||||
// set system time to time in the future, where there would have been 30 runs of the received trigger,
|
||||
// with just about 1/3 of the time before the next trigger
|
||||
jest.setSystemTime(
|
||||
new Date(
|
||||
Date.parse(triggerInfo.registeredAt) +
|
||||
triggerInfo.triggerIntervalInMs * triggerRuns +
|
||||
triggerInfo.triggerIntervalInMs -
|
||||
timeInMsTillNextRun
|
||||
)
|
||||
);
|
||||
|
||||
const _intercept = {
|
||||
...intercept,
|
||||
id: 'test-repeat-intercept',
|
||||
};
|
||||
|
||||
const subscriptionHandler = jest.fn(({ lastInteractedInterceptId }) => {
|
||||
// simulate persistence of the user interaction with the intercept
|
||||
jest
|
||||
.spyOn(http, 'get')
|
||||
.mockResolvedValue({ lastInteractedInterceptId: lastInteractedInterceptId + 1 });
|
||||
});
|
||||
|
||||
const intercept$ = registerIntercept(_intercept);
|
||||
|
||||
const subscription = intercept$.subscribe(subscriptionHandler);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({ triggerId: _intercept.id }),
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(timeInMsTillNextRun);
|
||||
|
||||
expect(mockQueueInterceptFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: _intercept.id,
|
||||
runId: triggerRuns + 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(subscriptionHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastInteractedInterceptId: triggerRuns,
|
||||
})
|
||||
);
|
||||
|
||||
// advance to next run and wait for all promises to resolve
|
||||
await jest.advanceTimersByTimeAsync(triggerInfo.triggerIntervalInMs);
|
||||
|
||||
expect(mockQueueInterceptFn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
id: _intercept.id,
|
||||
runId: triggerRuns + 2,
|
||||
})
|
||||
);
|
||||
|
||||
expect(subscriptionHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastInteractedInterceptId: triggerRuns + 1,
|
||||
})
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as Rx from 'rxjs';
|
||||
import type { CoreStart, CoreSetup } from '@kbn/core/public';
|
||||
import { InterceptDialogService, InterceptServiceStartDeps } from './service';
|
||||
import { UserInterceptRunPersistenceService } from './service/user_intercept_run_persistence_service';
|
||||
import { Intercept } from './service';
|
||||
import { TRIGGER_INFO_API_ROUTE } from '../../common/constants';
|
||||
import { TriggerInfo } from '../../common/types';
|
||||
|
||||
type ProductInterceptPrompterSetupDeps = Pick<CoreSetup, 'analytics' | 'notifications'>;
|
||||
type ProductInterceptPrompterStartDeps = Omit<
|
||||
InterceptServiceStartDeps,
|
||||
'persistInterceptRunId' | 'staticAssetsHelper'
|
||||
> &
|
||||
Pick<CoreStart, 'http'>;
|
||||
|
||||
export class InterceptPrompter {
|
||||
private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService();
|
||||
private interceptDialogService = new InterceptDialogService();
|
||||
private queueIntercept?: ReturnType<InterceptDialogService['start']>['add'];
|
||||
|
||||
setup({ analytics, notifications }: ProductInterceptPrompterSetupDeps) {
|
||||
this.interceptDialogService.setup({ analytics, notifications });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
start({ http, ...dialogServiceDeps }: ProductInterceptPrompterStartDeps) {
|
||||
const { getUserTriggerData$, updateUserTriggerData } =
|
||||
this.userInterceptRunPersistenceService.start(http);
|
||||
|
||||
({ add: this.queueIntercept } = this.interceptDialogService.start({
|
||||
...dialogServiceDeps,
|
||||
persistInterceptRunId: updateUserTriggerData,
|
||||
staticAssetsHelper: http.staticAssets,
|
||||
}));
|
||||
|
||||
return {
|
||||
/**
|
||||
* Configures the intercept journey that will be shown to the user, and returns an observable
|
||||
* that manages displaying the intercept at the appropriate time based on the interval that's been pre-configured for the
|
||||
* trigger ID matching the ID of this particular journey being configured.
|
||||
*/
|
||||
registerIntercept: this.registerIntercept.bind(this, http, getUserTriggerData$),
|
||||
};
|
||||
}
|
||||
|
||||
private registerIntercept(
|
||||
http: CoreStart['http'],
|
||||
getUserTriggerData$: ReturnType<
|
||||
UserInterceptRunPersistenceService['start']
|
||||
>['getUserTriggerData$'],
|
||||
intercept: Intercept
|
||||
) {
|
||||
let nextRunId: number;
|
||||
|
||||
return Rx.from(
|
||||
http.post<NonNullable<TriggerInfo>>(TRIGGER_INFO_API_ROUTE, {
|
||||
body: JSON.stringify({
|
||||
triggerId: intercept.id,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.pipe(Rx.filter((response) => !!response))
|
||||
.pipe(
|
||||
Rx.mergeMap((response) => {
|
||||
const now = Date.now();
|
||||
let diff = 0;
|
||||
|
||||
// Calculate the number of runs since the trigger was registered
|
||||
const runs = Math.floor(
|
||||
(diff = now - Date.parse(response.registeredAt)) / response.triggerIntervalInMs
|
||||
);
|
||||
|
||||
nextRunId = runs + 1;
|
||||
|
||||
// Calculate the time until the next run
|
||||
const nextRun = nextRunId * response.triggerIntervalInMs - diff;
|
||||
|
||||
return Rx.timer(nextRun, response.triggerIntervalInMs).pipe(
|
||||
Rx.switchMap(() => getUserTriggerData$(intercept.id)),
|
||||
Rx.takeWhile((triggerData) => {
|
||||
// Stop the timer if lastInteractedInterceptId is defined and matches nextRunId
|
||||
if (!response.recurrent && triggerData.lastInteractedInterceptId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
Rx.tap((triggerData) => {
|
||||
if (nextRunId !== triggerData.lastInteractedInterceptId) {
|
||||
this.queueIntercept?.({ ...intercept, runId: nextRunId });
|
||||
nextRunId++;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { InterceptDialogService } from './intercept_dialog_service';
|
||||
export type { InterceptServiceStartDeps } from './intercept_dialog_service';
|
||||
export type {
|
||||
InterceptDialogApi,
|
||||
InterceptWithoutRunId as Intercept,
|
||||
} from './intercept_dialog_api';
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { InterceptDialogApi } from './intercept_dialog_api';
|
||||
|
||||
describe('InterceptDialogApi', () => {
|
||||
it('exposes the setup and start public methods', () => {
|
||||
const intercept = new InterceptDialogApi();
|
||||
|
||||
expect(intercept).toHaveProperty('setup', expect.any(Function));
|
||||
expect(intercept).toHaveProperty('start', expect.any(Function));
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
|
||||
|
||||
let interceptApi: ReturnType<InterceptDialogApi['start']>;
|
||||
|
||||
const intercept: Parameters<ReturnType<InterceptDialogApi['start']>['add']>[0] = {
|
||||
id: 'test-intercept',
|
||||
runId: 1234,
|
||||
steps: [
|
||||
{
|
||||
id: 'start' as const,
|
||||
title: 'Hello',
|
||||
content: () => <>{'Couple of questions for you sir'}</>,
|
||||
},
|
||||
{
|
||||
id: 'interest',
|
||||
title: 'Are you interested?',
|
||||
content: () => <>{'...'}</>,
|
||||
},
|
||||
{ id: 'completion' as const, title: 'Goodbye', content: () => <>{'Goodbye sir'}</> },
|
||||
],
|
||||
onFinish: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const interceptDialog = new InterceptDialogApi();
|
||||
|
||||
interceptApi = interceptDialog.start({ analytics });
|
||||
});
|
||||
|
||||
it('has specific properties', () => {
|
||||
expect(interceptApi).toHaveProperty('add', expect.any(Function));
|
||||
expect(interceptApi).toHaveProperty('ack', expect.any(Function));
|
||||
expect(interceptApi).toHaveProperty('get$', expect.any(Function));
|
||||
});
|
||||
|
||||
it('invoking the add method adds an intercept', () => {
|
||||
const nextHandlerFn = jest.fn();
|
||||
|
||||
const sub = interceptApi.get$().subscribe(nextHandlerFn);
|
||||
|
||||
interceptApi.add(intercept);
|
||||
|
||||
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
|
||||
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('invoking the add method multiple times with an intercept matching the same id is idempotent', () => {
|
||||
const nextHandlerFn = jest.fn();
|
||||
|
||||
const sub = interceptApi.get$().subscribe(nextHandlerFn);
|
||||
|
||||
Array.from({ length: 5 }).forEach(() => {
|
||||
interceptApi.add(intercept);
|
||||
});
|
||||
|
||||
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
|
||||
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('invoking the ack method with the id of an existing intercept removes said intercept', () => {
|
||||
const nextHandlerFn = jest.fn();
|
||||
|
||||
const sub = interceptApi.get$().subscribe(nextHandlerFn);
|
||||
|
||||
// add intercept we'd like to perform an ack on
|
||||
interceptApi.add(intercept);
|
||||
|
||||
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
|
||||
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
|
||||
|
||||
interceptApi.ack({ interceptId: intercept.id, ackType: 'dismissed', interactionDuration: 0 });
|
||||
|
||||
expect(nextHandlerFn).toHaveBeenCalledTimes(3); // called because of the initial value, the new intercept, and the ack
|
||||
expect(nextHandlerFn).toHaveBeenLastCalledWith([]);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import type { EuiTourStepProps } from '@elastic/eui';
|
||||
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import * as Rx from 'rxjs';
|
||||
|
||||
import { InterceptTelemetry } from './telemetry';
|
||||
|
||||
interface InterceptSteps extends Pick<EuiTourStepProps, 'title'> {
|
||||
id: string;
|
||||
/**
|
||||
* expects a react component that will be rendered in the dialog, and expects a callback to be called with the value
|
||||
* of the step when the user is done with the step.
|
||||
*/
|
||||
content: FC<{ onValue: (value: unknown) => void }>;
|
||||
}
|
||||
|
||||
interface StartingInterceptStep extends InterceptSteps {
|
||||
id: 'start';
|
||||
}
|
||||
|
||||
interface CompletionInterceptStep extends InterceptSteps {
|
||||
id: 'completion';
|
||||
}
|
||||
|
||||
interface InterceptProgressEvent {
|
||||
runId: Intercept['runId'];
|
||||
stepId: string;
|
||||
stepResponse: unknown;
|
||||
}
|
||||
|
||||
interface InterceptCompletionEvent {
|
||||
runId: Intercept['runId'];
|
||||
response: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface InterceptDismissalEvent {
|
||||
runId: Intercept['runId'];
|
||||
stepId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface Intercept {
|
||||
/**
|
||||
* Unique identifier for the intercept, value provided must match the Id used when registering the trigger condition for said intercept.
|
||||
*/
|
||||
id: string;
|
||||
runId: number;
|
||||
steps: [StartingInterceptStep, ...InterceptSteps[], CompletionInterceptStep];
|
||||
/**
|
||||
* Provides the response of the user interaction with the dialog for a particular step. Progress will not fire for the start or completion steps.
|
||||
*/
|
||||
onProgress?: (evt: InterceptProgressEvent) => void;
|
||||
/**
|
||||
* Provides the response of the users interaction within the dialog as a object with keys corresponding to the id of the steps.
|
||||
*/
|
||||
onFinish: (evt: InterceptCompletionEvent) => void;
|
||||
onDismiss?: (evt: InterceptDismissalEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type InterceptWithoutRunId = Omit<Intercept, 'runId'>;
|
||||
|
||||
interface InterceptDialogApiStartDeps {
|
||||
analytics: AnalyticsServiceStart;
|
||||
}
|
||||
|
||||
interface InterceptDialogApiSetupDeps {
|
||||
analytics: AnalyticsServiceSetup;
|
||||
}
|
||||
|
||||
export class InterceptDialogApi {
|
||||
private readonly telemetry = new InterceptTelemetry();
|
||||
private productIntercepts$ = new Rx.BehaviorSubject<Intercept[]>([]);
|
||||
private eventReporter?: ReturnType<InterceptTelemetry['start']>;
|
||||
|
||||
setup({ analytics }: InterceptDialogApiSetupDeps) {
|
||||
this.telemetry.setup({ analytics });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
start({ analytics }: InterceptDialogApiStartDeps) {
|
||||
this.eventReporter = this.telemetry.start({ analytics });
|
||||
|
||||
return {
|
||||
add: this.add.bind(this),
|
||||
ack: this.ack.bind(this),
|
||||
get$: this.get$.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
private get$() {
|
||||
return this.productIntercepts$.asObservable();
|
||||
}
|
||||
|
||||
private add(productIntercept: Intercept): string {
|
||||
const existingIntercepts = this.productIntercepts$.getValue();
|
||||
|
||||
if (existingIntercepts.some((intercept) => intercept.id === productIntercept.id)) {
|
||||
this.eventReporter?.reportInterceptOverload({ interceptId: productIntercept.id });
|
||||
} else {
|
||||
// order is important so we can operate on a FIFO basis
|
||||
this.productIntercepts$.next([productIntercept, ...existingIntercepts]);
|
||||
|
||||
this.eventReporter?.reportInterceptRegistration({ interceptId: productIntercept.id });
|
||||
}
|
||||
|
||||
return productIntercept.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description expected to be called when a user is determined to have acknowledged the intercept for which the id is provided
|
||||
*/
|
||||
private ack({
|
||||
interactionDuration,
|
||||
interceptId,
|
||||
ackType,
|
||||
}: {
|
||||
ackType: 'dismissed' | 'completed';
|
||||
} & Omit<
|
||||
Parameters<NonNullable<typeof this.eventReporter>['reportInterceptInteraction']>[0],
|
||||
'interactionType'
|
||||
>): void {
|
||||
this.productIntercepts$.next(
|
||||
this.productIntercepts$.getValue().filter((intercept) => intercept.id !== interceptId)
|
||||
);
|
||||
|
||||
this.eventReporter?.reportInterceptInteraction({
|
||||
interactionType: ackType,
|
||||
interactionDuration,
|
||||
interceptId,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks';
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { InterceptDialogService } from './intercept_dialog_service';
|
||||
|
||||
const staticAssetsHelperMock = httpServiceMock.createSetupContract().staticAssets;
|
||||
|
||||
describe('InterceptDialogService', () => {
|
||||
it('exposes a setup and start method', () => {
|
||||
const interceptDialogService = new InterceptDialogService();
|
||||
|
||||
expect(interceptDialogService).toHaveProperty('setup', expect.any(Function));
|
||||
expect(interceptDialogService).toHaveProperty('start', expect.any(Function));
|
||||
expect(interceptDialogService).toHaveProperty('stop', expect.any(Function));
|
||||
});
|
||||
|
||||
describe('#start', () => {
|
||||
let start: ReturnType<InterceptDialogService['start']>;
|
||||
|
||||
const interceptDialogService = new InterceptDialogService();
|
||||
|
||||
beforeAll(() => {
|
||||
interceptDialogService.setup({
|
||||
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
|
||||
notifications: notificationServiceMock.createSetupContract(),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
start = interceptDialogService.start({
|
||||
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
|
||||
rendering: renderingServiceMock.create(),
|
||||
targetDomElement: document.createElement('div'),
|
||||
persistInterceptRunId: jest.fn(),
|
||||
staticAssetsHelper: staticAssetsHelperMock,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
interceptDialogService.stop();
|
||||
});
|
||||
|
||||
it('exposes an expected set of properties', () => {
|
||||
expect(start).toStrictEqual({
|
||||
add: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as Rx from 'rxjs';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { render, unmountComponentAtNode } from 'react-dom';
|
||||
|
||||
import type { HttpStart } from '@kbn/core-http-browser';
|
||||
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import type { RenderingService } from '@kbn/core-rendering-browser';
|
||||
import type { NotificationsSetup } from '@kbn/core-notifications-browser';
|
||||
import { InterceptDialogApi } from './intercept_dialog_api';
|
||||
import { UserInterceptRunPersistenceService } from './user_intercept_run_persistence_service';
|
||||
import { InterceptDisplayManager } from '../component/intercept_display_manager';
|
||||
|
||||
interface InterceptServiceSetupDeps {
|
||||
analytics: AnalyticsServiceSetup;
|
||||
notifications: NotificationsSetup;
|
||||
}
|
||||
|
||||
export interface InterceptServiceStartDeps {
|
||||
analytics: AnalyticsServiceStart;
|
||||
rendering: RenderingService;
|
||||
targetDomElement: HTMLElement;
|
||||
persistInterceptRunId: ReturnType<
|
||||
UserInterceptRunPersistenceService['start']
|
||||
>['updateUserTriggerData'];
|
||||
staticAssetsHelper: HttpStart['staticAssets'];
|
||||
}
|
||||
|
||||
export class InterceptDialogService {
|
||||
private readonly api = new InterceptDialogApi();
|
||||
private targetDomElement?: HTMLElement;
|
||||
private notificationsCoordinator?: ReturnType<NotificationsSetup['coordinator']>;
|
||||
|
||||
setup({ analytics, notifications }: InterceptServiceSetupDeps) {
|
||||
this.api.setup({ analytics });
|
||||
|
||||
this.notificationsCoordinator = notifications.coordinator(InterceptDialogService.name);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start({
|
||||
targetDomElement,
|
||||
rendering,
|
||||
analytics,
|
||||
staticAssetsHelper,
|
||||
persistInterceptRunId: persistInterceptRunInteraction,
|
||||
}: InterceptServiceStartDeps) {
|
||||
const { ack, add, get$ } = this.api.start({ analytics });
|
||||
this.targetDomElement = targetDomElement;
|
||||
|
||||
if (!this.notificationsCoordinator) {
|
||||
throw new Error('Notifications coordinator is not initialized');
|
||||
}
|
||||
|
||||
// leverage the notifications coordinator to ensure that we are not showing
|
||||
// multiple intercepts at the same time, and that we are not showing
|
||||
// intercepts when the user is interacting with other notifications
|
||||
// (e.g. toast notifications)
|
||||
const intercept$ = this.notificationsCoordinator
|
||||
.optInToCoordination(get$(), ({ locked }) => !locked)
|
||||
.pipe(
|
||||
Rx.mergeMap((x) => x),
|
||||
// since the backing store for product intercepts accepts all queued intercepts,
|
||||
// we might receive a list of intercepts that should be attended to,
|
||||
// hence we attempt to present them serially to the user, without interrupting other queued notification types
|
||||
Rx.delayWhen(() =>
|
||||
this.notificationsCoordinator!.lock$.pipe(
|
||||
Rx.filter(({ controller }) => controller === InterceptDialogService.name)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const ackIntercept = (
|
||||
...args: Parameters<ComponentProps<typeof InterceptDisplayManager>['ackIntercept']>
|
||||
) => {
|
||||
const [{ runId, ...ackArgs }] = args;
|
||||
|
||||
ack(ackArgs);
|
||||
|
||||
persistInterceptRunInteraction(ackArgs.interceptId, runId);
|
||||
|
||||
// we release the coordination lock on processing the user's acknowledgement of the product intercept,
|
||||
// so that any other pending notification can be shown
|
||||
this.notificationsCoordinator?.releaseLock();
|
||||
};
|
||||
|
||||
render(
|
||||
rendering.addContext(
|
||||
<InterceptDisplayManager
|
||||
{...{
|
||||
intercept$,
|
||||
ackIntercept,
|
||||
staticAssetsHelper,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
this.targetDomElement
|
||||
);
|
||||
|
||||
return {
|
||||
add,
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.targetDomElement) {
|
||||
unmountComponentAtNode(this.targetDomElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type InterceptDialogServiceStart = ReturnType<InterceptDialogApi['start']>;
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { type RootSchema, type EventTypeOpts } from '@elastic/ebt/client';
|
||||
|
||||
export enum EventMetric {
|
||||
INTERCEPT_TERMINATION_INTERACTION = 'intercept_termination_interaction',
|
||||
INTERCEPT_REGISTRATION = 'intercept_registration',
|
||||
INTERCEPT_OVERLOAD = 'intercept_overload',
|
||||
}
|
||||
|
||||
export enum EventFieldType {
|
||||
INTERACTION_TYPE = 'interaction_type',
|
||||
INTERCEPT_ID = 'intercept_id',
|
||||
INTERACTION_DURATION = 'interaction_duration',
|
||||
}
|
||||
|
||||
const fields: Record<EventFieldType, RootSchema<unknown>> = {
|
||||
[EventFieldType.INTERACTION_TYPE]: {
|
||||
[EventFieldType.INTERACTION_TYPE]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The type of interaction that occurred with the intercept',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.INTERCEPT_ID]: {
|
||||
[EventFieldType.INTERCEPT_ID]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'ID of the intercept',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.INTERACTION_DURATION]: {
|
||||
[EventFieldType.INTERACTION_DURATION]: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'Duration of the interaction in milliseconds',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @description defines all the event types that can be reported by the intercept dialog,
|
||||
* with the mapping that values provided will be ingested as within EBT
|
||||
*/
|
||||
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
|
||||
{
|
||||
eventType: EventMetric.INTERCEPT_TERMINATION_INTERACTION,
|
||||
schema: {
|
||||
...fields[EventFieldType.INTERACTION_TYPE],
|
||||
...fields[EventFieldType.INTERCEPT_ID],
|
||||
...fields[EventFieldType.INTERACTION_DURATION],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: EventMetric.INTERCEPT_REGISTRATION,
|
||||
schema: {
|
||||
...fields[EventFieldType.INTERCEPT_ID],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: EventMetric.INTERCEPT_OVERLOAD,
|
||||
schema: {
|
||||
...fields[EventFieldType.INTERCEPT_ID],
|
||||
},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import { EventMetric, EventFieldType, eventTypes } from './event_definitions';
|
||||
|
||||
export class InterceptTelemetry {
|
||||
private reportEvent?: AnalyticsServiceStart['reportEvent'];
|
||||
|
||||
public setup({ analytics }: { analytics: AnalyticsServiceSetup }) {
|
||||
eventTypes.forEach((eventType) => {
|
||||
analytics.registerEventType(eventType);
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start({ analytics }: { analytics: AnalyticsServiceStart }) {
|
||||
this.reportEvent = analytics.reportEvent;
|
||||
|
||||
return {
|
||||
reportInterceptRegistration: this.reportInterceptRegistration.bind(this),
|
||||
reportInterceptOverload: this.reportInterceptOverload.bind(this),
|
||||
reportInterceptInteraction: this.reportInterceptInteraction.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
private reportInterceptRegistration({ interceptId }: { interceptId: string }) {
|
||||
this.reportEvent?.(EventMetric.INTERCEPT_REGISTRATION, {
|
||||
[EventFieldType.INTERCEPT_ID]: interceptId,
|
||||
});
|
||||
}
|
||||
|
||||
private reportInterceptOverload({ interceptId }: { interceptId: string }) {
|
||||
this.reportEvent?.(EventMetric.INTERCEPT_OVERLOAD, {
|
||||
[EventFieldType.INTERCEPT_ID]: interceptId,
|
||||
});
|
||||
}
|
||||
|
||||
private reportInterceptInteraction({
|
||||
interactionDuration,
|
||||
interactionType,
|
||||
interceptId,
|
||||
}: {
|
||||
interactionDuration: number;
|
||||
interactionType: string;
|
||||
interceptId: string;
|
||||
}) {
|
||||
this.reportEvent?.(EventMetric.INTERCEPT_TERMINATION_INTERACTION, {
|
||||
[EventFieldType.INTERACTION_TYPE]: interactionType,
|
||||
[EventFieldType.INTERCEPT_ID]: interceptId,
|
||||
[EventFieldType.INTERACTION_DURATION]: interactionDuration,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { InterceptTelemetry } from './event_reporter';
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as Rx from 'rxjs';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '../../../common/constants';
|
||||
|
||||
/**
|
||||
* @description provides utils to read and update information about the last interaction with a given intercept trigger.
|
||||
*/
|
||||
export class UserInterceptRunPersistenceService {
|
||||
start(http: CoreStart['http']) {
|
||||
return {
|
||||
getUserTriggerData$: this.userInterceptRunId.bind(this, http),
|
||||
updateUserTriggerData: this.persistInterceptRunInteraction.bind(this, http),
|
||||
};
|
||||
}
|
||||
|
||||
userInterceptRunId(http: CoreStart['http'], triggerId: string) {
|
||||
return Rx.from(
|
||||
http.get<{ lastInteractedInterceptId: number }>(
|
||||
TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', triggerId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
persistInterceptRunInteraction(http: CoreStart['http'], triggerId: string, runId: number) {
|
||||
return http.post(
|
||||
TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', triggerId),
|
||||
{
|
||||
body: JSON.stringify({
|
||||
lastInteractedInterceptId: runId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
16
x-pack/platform/plugins/private/intercepts/server/index.ts
Normal file
16
x-pack/platform/plugins/private/intercepts/server/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PluginInitializerContext } from '@kbn/core/server';
|
||||
export { config } from '../common/config';
|
||||
|
||||
export type { InterceptSetup, InterceptStart } from './plugin';
|
||||
|
||||
export const plugin = async (initContext: PluginInitializerContext) => {
|
||||
const { InterceptsServerPlugin } = await import('./plugin');
|
||||
return new InterceptsServerPlugin(initContext);
|
||||
};
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
RequestHandlerContext,
|
||||
type CoreSetup,
|
||||
type CoreStart,
|
||||
type Logger,
|
||||
type IRouter,
|
||||
type IContextProvider,
|
||||
} from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { parseIntervalAsMillisecond } from '@kbn/task-manager-plugin/server/lib/intervals';
|
||||
import { InterceptTriggerService } from './services/intercept_trigger';
|
||||
import { InterceptUserInteractionService } from './services/intercept_user_interaction';
|
||||
import { TRIGGER_INFO_API_ROUTE } from '../common/constants';
|
||||
import type { TriggerInfo } from '../common/types';
|
||||
|
||||
interface InterceptTriggerRouteContext extends RequestHandlerContext {
|
||||
triggerInfo: Promise<TriggerInfo>;
|
||||
}
|
||||
|
||||
interface InterceptTriggerCoreSetup {
|
||||
kibanaVersion: string;
|
||||
}
|
||||
|
||||
export class InterceptsTriggerOrchestrator {
|
||||
private logger?: Logger;
|
||||
private interceptTriggerService = new InterceptTriggerService();
|
||||
private interceptUserInteractionService = new InterceptUserInteractionService();
|
||||
|
||||
setup(core: CoreSetup, logger: Logger, { kibanaVersion }: InterceptTriggerCoreSetup) {
|
||||
this.logger = logger;
|
||||
|
||||
const { fetchRegisteredTask } = this.interceptTriggerService.setup(core, this.logger, {
|
||||
kibanaVersion,
|
||||
});
|
||||
|
||||
this.interceptUserInteractionService.setup(core, this.logger);
|
||||
|
||||
core.http.registerRouteHandlerContext<InterceptTriggerRouteContext, 'triggerInfo'>(
|
||||
'triggerInfo',
|
||||
this.routerHandlerContext.bind(this, fetchRegisteredTask)
|
||||
);
|
||||
|
||||
const router = core.http.createRouter<InterceptTriggerRouteContext>();
|
||||
|
||||
router.post.apply(router, this.getRouteConfig());
|
||||
}
|
||||
|
||||
start(core: CoreStart) {
|
||||
const { registerTriggerDefinition } = this.interceptTriggerService.start(core);
|
||||
|
||||
this.interceptUserInteractionService.start(core);
|
||||
|
||||
return {
|
||||
registerTriggerDefinition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description this method provides trigger information as context for the route handler
|
||||
*/
|
||||
private async routerHandlerContext(
|
||||
fetchRegisteredTask: ReturnType<InterceptTriggerService['setup']>['fetchRegisteredTask'],
|
||||
...args: Parameters<IContextProvider<InterceptTriggerRouteContext, 'triggerInfo'>>
|
||||
) {
|
||||
const [, request] = args;
|
||||
|
||||
// @ts-expect-error -- the context is not typed
|
||||
const triggerId = request.body?.triggerId;
|
||||
|
||||
if (!triggerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let triggerInfo: InterceptTriggerRouteContext['triggerInfo'] extends Promise<infer T>
|
||||
? T
|
||||
: never = null;
|
||||
|
||||
let registeredTriggerDefinition;
|
||||
|
||||
if ((registeredTriggerDefinition = await fetchRegisteredTask(triggerId))) {
|
||||
triggerInfo = {
|
||||
registeredAt: registeredTriggerDefinition.firstRegisteredAt,
|
||||
triggerIntervalInMs: parseIntervalAsMillisecond(registeredTriggerDefinition.triggerAfter),
|
||||
recurrent: registeredTriggerDefinition.recurrent,
|
||||
};
|
||||
}
|
||||
|
||||
return triggerInfo;
|
||||
}
|
||||
|
||||
private getRouteConfig(): Parameters<IRouter<InterceptTriggerRouteContext>['post']> {
|
||||
return [
|
||||
{
|
||||
path: TRIGGER_INFO_API_ROUTE,
|
||||
validate: {
|
||||
body: schema.object({
|
||||
triggerId: schema.string(),
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
authz: {
|
||||
enabled: false,
|
||||
reason: 'route is public and provides information about the intercept trigger',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const resolvedTriggerInfo = await context.triggerInfo;
|
||||
|
||||
if (!resolvedTriggerInfo) {
|
||||
return response.noContent();
|
||||
}
|
||||
|
||||
// use the trigger interval as the etag
|
||||
const responseETag = crypto
|
||||
.createHash('sha256')
|
||||
.update(Buffer.from(String(resolvedTriggerInfo.triggerIntervalInMs)))
|
||||
.digest('hex');
|
||||
|
||||
if (request.headers['if-none-match'] === responseETag) {
|
||||
return response.notModified({});
|
||||
}
|
||||
|
||||
return response.ok({
|
||||
headers: {
|
||||
etag: responseETag,
|
||||
// cache the response for the duration of the trigger interval
|
||||
'cache-control': `max-age=${resolvedTriggerInfo.triggerIntervalInMs}, must-revalidate`,
|
||||
},
|
||||
body: resolvedTriggerInfo,
|
||||
});
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
54
x-pack/platform/plugins/private/intercepts/server/plugin.ts
Normal file
54
x-pack/platform/plugins/private/intercepts/server/plugin.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
Plugin,
|
||||
type CoreSetup,
|
||||
type CoreStart,
|
||||
type PluginInitializerContext,
|
||||
type Logger,
|
||||
} from '@kbn/core/server';
|
||||
import { InterceptsTriggerOrchestrator } from './orchestrator';
|
||||
import type { ServerConfigSchema } from '../common/config';
|
||||
|
||||
export class InterceptsServerPlugin implements Plugin<object, object, object, never> {
|
||||
private readonly logger: Logger;
|
||||
private readonly config: ServerConfigSchema;
|
||||
private readonly interceptsOrchestrator?: InterceptsTriggerOrchestrator;
|
||||
|
||||
constructor(private initContext: PluginInitializerContext<unknown>) {
|
||||
this.logger = initContext.logger.get();
|
||||
this.config = initContext.config.get<ServerConfigSchema>();
|
||||
|
||||
if (this.config.enabled) {
|
||||
this.interceptsOrchestrator = new InterceptsTriggerOrchestrator();
|
||||
}
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup) {
|
||||
this.interceptsOrchestrator?.setup(core, this.logger, {
|
||||
kibanaVersion: this.initContext.env.packageInfo.version,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
const interceptOrchestratorStart = this.interceptsOrchestrator?.start(core);
|
||||
|
||||
return {
|
||||
registerTriggerDefinition: interceptOrchestratorStart?.registerTriggerDefinition.bind(
|
||||
interceptOrchestratorStart
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
||||
|
||||
export type InterceptSetup = ReturnType<InterceptsServerPlugin['setup']>;
|
||||
export type InterceptStart = ReturnType<InterceptsServerPlugin['start']>;
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export type { InterceptTriggerRecord } from './intercept_trigger_record';
|
||||
export { interceptTriggerRecordSavedObject } from './intercept_trigger_record';
|
||||
export type { InterceptInteractionUserRecordAttributes } from './intercept_user_interaction_record';
|
||||
export { interceptInteractionUserRecordSavedObject } from './intercept_user_interaction_record';
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { SavedObjectsType, SavedObjectsFieldMapping } from '@kbn/core/server';
|
||||
import type { InferObjectSchema } from './types';
|
||||
|
||||
const interceptTriggerV1 = schema.object({
|
||||
firstRegisteredAt: schema.string(),
|
||||
/**
|
||||
* The interval at which the intercept should be displayed to the user.
|
||||
*/
|
||||
triggerAfter: schema.string(),
|
||||
/**
|
||||
* The version of kibana where this intercept trigger was installed.
|
||||
*/
|
||||
installedOn: schema.string(),
|
||||
/**
|
||||
* Flag to denote if the trigger should run in perpetuity or not. set to false for a trigger that should run only once.
|
||||
*/
|
||||
recurrent: schema.boolean(),
|
||||
});
|
||||
|
||||
export type InterceptTriggerRecord = InferObjectSchema<typeof interceptTriggerV1>;
|
||||
|
||||
type InterceptTriggerSavedObjectProperties = Record<
|
||||
keyof InterceptTriggerRecord,
|
||||
SavedObjectsFieldMapping
|
||||
>;
|
||||
|
||||
const interceptTriggerProperties: InterceptTriggerSavedObjectProperties = {
|
||||
firstRegisteredAt: {
|
||||
type: 'date',
|
||||
},
|
||||
triggerAfter: {
|
||||
type: 'text',
|
||||
},
|
||||
installedOn: {
|
||||
type: 'keyword',
|
||||
},
|
||||
recurrent: {
|
||||
type: 'boolean',
|
||||
},
|
||||
};
|
||||
|
||||
export const interceptTriggerRecordSavedObject: SavedObjectsType<InterceptTriggerRecord> = {
|
||||
name: 'intercept_trigger_record',
|
||||
hidden: true,
|
||||
hiddenFromHttpApis: true,
|
||||
namespaceType: 'agnostic',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: interceptTriggerProperties,
|
||||
},
|
||||
modelVersions: {
|
||||
1: {
|
||||
changes: [],
|
||||
schemas: {
|
||||
forwardCompatibility: interceptTriggerV1.extends({}, { unknowns: 'ignore' }),
|
||||
create: interceptTriggerV1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { SavedObjectsType } from '@kbn/core/server';
|
||||
import type { InferObjectSchema } from './types';
|
||||
|
||||
const interceptInteractionV1 = schema.object({
|
||||
userId: schema.string(),
|
||||
triggerId: schema.string(),
|
||||
metadata: schema.object(
|
||||
{
|
||||
lastInteractedInterceptId: schema.number(),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
),
|
||||
});
|
||||
|
||||
export type InterceptInteractionUserRecordAttributes = InferObjectSchema<
|
||||
typeof interceptInteractionV1
|
||||
>;
|
||||
|
||||
export const interceptInteractionUserRecordSavedObject: SavedObjectsType = {
|
||||
name: 'intercept_interaction_record',
|
||||
hidden: true,
|
||||
hiddenFromHttpApis: true,
|
||||
namespaceType: 'agnostic',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: {},
|
||||
},
|
||||
modelVersions: {
|
||||
1: {
|
||||
changes: [],
|
||||
schemas: {
|
||||
forwardCompatibility: interceptInteractionV1.extends({}, { unknowns: 'ignore' }),
|
||||
create: interceptInteractionV1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ObjectType, Type } from '@kbn/config-schema';
|
||||
|
||||
export type InferObjectSchema<S> = {
|
||||
[Property in keyof (S extends ObjectType<infer O> ? O : never)]: (S extends ObjectType<infer O>
|
||||
? O
|
||||
: never)[Property] extends Type<infer T>
|
||||
? T extends ObjectType
|
||||
? InferObjectSchema<T>
|
||||
: T
|
||||
: never;
|
||||
};
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { coreMock } from '@kbn/core/server/mocks';
|
||||
import { InterceptTriggerService } from './intercept_trigger';
|
||||
import { interceptTriggerRecordSavedObject } from '../saved_objects';
|
||||
import { ISavedObjectsRepository } from '@kbn/core/server';
|
||||
|
||||
describe('InterceptTriggerService', () => {
|
||||
describe('#setup', () => {
|
||||
it('invoking setup registers the backing saved object', () => {
|
||||
const interceptTrigger = new InterceptTriggerService();
|
||||
|
||||
const coreSetupMock = coreMock.createSetup();
|
||||
|
||||
interceptTrigger.setup(coreSetupMock, {} as any, {
|
||||
kibanaVersion: '8.0.0',
|
||||
});
|
||||
|
||||
expect(coreSetupMock.savedObjects.registerType).toHaveBeenCalledWith(
|
||||
interceptTriggerRecordSavedObject
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#start', () => {
|
||||
it('should return a specific of properties', () => {
|
||||
const interceptTrigger = new InterceptTriggerService();
|
||||
|
||||
const coreStartMock = coreMock.createStart();
|
||||
|
||||
const interceptTriggerStartContract = interceptTrigger.start(coreStartMock);
|
||||
|
||||
expect(interceptTriggerStartContract).toHaveProperty(
|
||||
'registerTriggerDefinition',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
describe('registerTriggerDefinition', () => {
|
||||
it('would cause a creation invocation when a trigger with the same ID has not been registered', async () => {
|
||||
const interceptTrigger = new InterceptTriggerService();
|
||||
|
||||
const coreSetupMock = coreMock.createSetup();
|
||||
const coreStartMock = coreMock.createStart();
|
||||
|
||||
const createSavedObjectFnMock = jest.fn(() => Promise.resolve());
|
||||
|
||||
coreStartMock.savedObjects.createInternalRepository.mockReturnValue({
|
||||
create: createSavedObjectFnMock,
|
||||
get: jest.fn(() => Promise.resolve({ attributes: null })),
|
||||
} as unknown as ISavedObjectsRepository);
|
||||
|
||||
interceptTrigger.setup(coreSetupMock, {} as any, {
|
||||
kibanaVersion: '8.0.0',
|
||||
});
|
||||
|
||||
const { registerTriggerDefinition } = interceptTrigger.start(coreStartMock);
|
||||
|
||||
const triggerId = 'trigger-id';
|
||||
|
||||
await registerTriggerDefinition(triggerId, () => ({
|
||||
triggerAfter: '30d',
|
||||
}));
|
||||
|
||||
expect(createSavedObjectFnMock).toHaveBeenCalledWith(
|
||||
interceptTriggerRecordSavedObject.name,
|
||||
{
|
||||
firstRegisteredAt: expect.any(String),
|
||||
recurrent: expect.any(Boolean),
|
||||
triggerAfter: expect.any(String),
|
||||
installedOn: expect.any(String),
|
||||
},
|
||||
{ id: triggerId }
|
||||
);
|
||||
});
|
||||
|
||||
it('would cause a update invocation when a trigger with the same ID is already registered', async () => {
|
||||
const interceptTrigger = new InterceptTriggerService();
|
||||
|
||||
const coreSetupMock = coreMock.createSetup();
|
||||
const coreStartMock = coreMock.createStart();
|
||||
|
||||
const updateSavedObjectFnMock = jest.fn(() => Promise.resolve());
|
||||
|
||||
coreStartMock.savedObjects.createInternalRepository.mockReturnValue({
|
||||
update: updateSavedObjectFnMock,
|
||||
get: jest.fn((...args) =>
|
||||
Promise.resolve({
|
||||
attributes: {
|
||||
id: args[1],
|
||||
// simulate an existing trigger with configured value
|
||||
triggerAfter: '30d',
|
||||
},
|
||||
})
|
||||
),
|
||||
} as unknown as ISavedObjectsRepository);
|
||||
|
||||
interceptTrigger.setup(coreSetupMock, {} as any, {
|
||||
kibanaVersion: '8.0.0',
|
||||
});
|
||||
|
||||
const { registerTriggerDefinition } = interceptTrigger.start(coreStartMock);
|
||||
|
||||
const triggerId = 'trigger-id';
|
||||
|
||||
await registerTriggerDefinition(triggerId, () => ({
|
||||
// provide a new value for the triggerAfter
|
||||
triggerAfter: '28d',
|
||||
}));
|
||||
|
||||
expect(updateSavedObjectFnMock).toHaveBeenCalledWith(
|
||||
interceptTriggerRecordSavedObject.name,
|
||||
triggerId,
|
||||
{
|
||||
triggerAfter: expect.any(String),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { type CoreSetup, type CoreStart, type Logger } from '@kbn/core/server';
|
||||
import type { ISavedObjectsRepository } from '@kbn/core-saved-objects-api-server';
|
||||
import { interceptTriggerRecordSavedObject, type InterceptTriggerRecord } from '../saved_objects';
|
||||
|
||||
interface InterceptTriggerServiceSetupDeps {
|
||||
kibanaVersion: string;
|
||||
}
|
||||
|
||||
export interface InterceptRegistrationCallbackArgs {
|
||||
existingTriggerDefinition?: InterceptTriggerRecord | null;
|
||||
}
|
||||
|
||||
export class InterceptTriggerService {
|
||||
private logger?: Logger;
|
||||
private savedObjectsClient?: ISavedObjectsRepository;
|
||||
private kibanaVersion?: string;
|
||||
|
||||
private savedObjectRef = interceptTriggerRecordSavedObject;
|
||||
|
||||
setup(core: CoreSetup, logger: Logger, { kibanaVersion }: InterceptTriggerServiceSetupDeps) {
|
||||
this.logger = logger;
|
||||
this.kibanaVersion = kibanaVersion;
|
||||
|
||||
core.savedObjects.registerType(this.savedObjectRef);
|
||||
|
||||
return {
|
||||
fetchRegisteredTask: this.fetchRegisteredTask.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
start(core: CoreStart) {
|
||||
this.savedObjectsClient = core.savedObjects.createInternalRepository([
|
||||
this.savedObjectRef.name,
|
||||
]);
|
||||
|
||||
return {
|
||||
registerTriggerDefinition: this.registerTriggerDefinition.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchRegisteredTask(triggerId: string) {
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await this.savedObjectsClient?.get<InterceptTriggerRecord>(
|
||||
interceptTriggerRecordSavedObject.name,
|
||||
triggerId
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger?.error(err);
|
||||
}
|
||||
|
||||
return result?.attributes ?? null;
|
||||
}
|
||||
|
||||
private async registerTriggerDefinition(
|
||||
triggerId: string,
|
||||
cb: (args: InterceptRegistrationCallbackArgs) => {
|
||||
triggerAfter: string | null;
|
||||
isRecurrent?: boolean;
|
||||
}
|
||||
) {
|
||||
const existingTriggerDefinition = await this.fetchRegisteredTask(triggerId);
|
||||
|
||||
const { triggerAfter, isRecurrent } = cb({
|
||||
existingTriggerDefinition,
|
||||
});
|
||||
|
||||
if (!triggerAfter) {
|
||||
this.logger?.error('Trigger interval is not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingTriggerDefinition) {
|
||||
await this.savedObjectsClient
|
||||
?.create<InterceptTriggerRecord>(
|
||||
this.savedObjectRef.name,
|
||||
{
|
||||
firstRegisteredAt: new Date().toISOString(),
|
||||
triggerAfter,
|
||||
installedOn: this.kibanaVersion!,
|
||||
recurrent: isRecurrent ?? true,
|
||||
},
|
||||
{ id: triggerId }
|
||||
)
|
||||
.catch((err) => {
|
||||
// TODO: handle error properly
|
||||
this.logger?.error(err.message);
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
// only support updating the trigger interval for existing trigger definitions
|
||||
existingTriggerDefinition &&
|
||||
existingTriggerDefinition.triggerAfter !== triggerAfter
|
||||
) {
|
||||
await this.savedObjectsClient
|
||||
?.update<InterceptTriggerRecord>(this.savedObjectRef.name, triggerId, {
|
||||
triggerAfter,
|
||||
})
|
||||
.catch((err) => {
|
||||
// TODO: handle error properly
|
||||
this.logger?.error(err.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing to do if the trigger interval is the same
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
import {
|
||||
type CoreSetup,
|
||||
type CoreStart,
|
||||
type Logger,
|
||||
RequestHandlerContext,
|
||||
SavedObjectsErrorHelpers,
|
||||
} from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { ISavedObjectsRepository, SavedObject } from '@kbn/core-saved-objects-api-server';
|
||||
import {
|
||||
interceptInteractionUserRecordSavedObject,
|
||||
type InterceptInteractionUserRecordAttributes,
|
||||
} from '../saved_objects';
|
||||
import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '../../common/constants';
|
||||
|
||||
export class InterceptUserInteractionService {
|
||||
private savedObjectsClient?: ISavedObjectsRepository;
|
||||
private savedObjectRef = interceptInteractionUserRecordSavedObject;
|
||||
|
||||
setup(core: CoreSetup, logger: Logger) {
|
||||
core.savedObjects.registerType(this.savedObjectRef);
|
||||
|
||||
const router = core.http.createRouter<RequestHandlerContext>();
|
||||
|
||||
router.get(
|
||||
{
|
||||
path: TRIGGER_USER_INTERACTION_METADATA_API_ROUTE,
|
||||
validate: {
|
||||
params: schema.object({
|
||||
triggerId: schema.string(),
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
authz: {
|
||||
enabled: false,
|
||||
reason:
|
||||
'route is public and provides information about the next product intercept trigger',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (ctx, request, response) => {
|
||||
const coreRequestHandlerCtx = await ctx.core;
|
||||
const userId = coreRequestHandlerCtx.security.authc.getCurrentUser()?.profile_uid;
|
||||
|
||||
if (!userId) {
|
||||
return response.forbidden();
|
||||
}
|
||||
|
||||
const result = await this.getUserInteractionSavedObject(userId, request.params.triggerId);
|
||||
|
||||
return response.ok({
|
||||
body: result?.attributes.metadata,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: TRIGGER_USER_INTERACTION_METADATA_API_ROUTE,
|
||||
validate: {
|
||||
params: schema.object({
|
||||
triggerId: schema.string(),
|
||||
}),
|
||||
body: schema.object({
|
||||
lastInteractedInterceptId: schema.number(),
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
authz: {
|
||||
enabled: false,
|
||||
reason: 'This route delegates authorization to SO client.',
|
||||
},
|
||||
authc: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (ctx, request, response) => {
|
||||
const coreRequestHandlerCtx = await ctx.core;
|
||||
const userId = coreRequestHandlerCtx.security.authc.getCurrentUser()?.profile_uid;
|
||||
|
||||
if (!userId) {
|
||||
return response.forbidden();
|
||||
}
|
||||
|
||||
await this.recordUserInteractionForTrigger(userId, request.params.triggerId, request.body);
|
||||
|
||||
return response.created();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
this.savedObjectsClient = core.savedObjects.createInternalRepository([
|
||||
this.savedObjectRef.name,
|
||||
]);
|
||||
}
|
||||
|
||||
// returns an id scoped to the current user
|
||||
private getSavedObjectId = (triggerId: string, userId: string) => `${triggerId}:${userId}`;
|
||||
|
||||
public async getUserInteractionSavedObject(
|
||||
userId: string,
|
||||
triggerId: string
|
||||
): Promise<SavedObject<InterceptInteractionUserRecordAttributes> | null> {
|
||||
assert.ok(this.savedObjectsClient, 'savedObjectsClient is not initialized');
|
||||
|
||||
try {
|
||||
const userInteractionSavedObject =
|
||||
await this.savedObjectsClient.get<InterceptInteractionUserRecordAttributes>(
|
||||
this.savedObjectRef.name,
|
||||
this.getSavedObjectId(triggerId, userId)
|
||||
);
|
||||
|
||||
return userInteractionSavedObject;
|
||||
} catch (e) {
|
||||
// If the saved object is not found, return null
|
||||
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async recordUserInteractionForTrigger(
|
||||
userId: string,
|
||||
triggerId: string,
|
||||
data: InterceptInteractionUserRecordAttributes['metadata']
|
||||
) {
|
||||
assert.ok(this.savedObjectsClient, 'savedObjectsClient is not initialized');
|
||||
|
||||
let interactionTriggerSavedObject = await this.getUserInteractionSavedObject(userId, triggerId);
|
||||
|
||||
const docId = this.getSavedObjectId(triggerId, userId);
|
||||
|
||||
if (!interactionTriggerSavedObject) {
|
||||
interactionTriggerSavedObject = await this.savedObjectsClient.create(
|
||||
this.savedObjectRef.name,
|
||||
{ userId, triggerId, metadata: data },
|
||||
{ id: docId }
|
||||
);
|
||||
} else {
|
||||
interactionTriggerSavedObject =
|
||||
(await this.savedObjectsClient.update<InterceptInteractionUserRecordAttributes>(
|
||||
this.savedObjectRef.name,
|
||||
docId,
|
||||
{
|
||||
...interactionTriggerSavedObject.attributes,
|
||||
metadata: {
|
||||
...interactionTriggerSavedObject.attributes.metadata,
|
||||
...data,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: interactionTriggerSavedObject.version,
|
||||
}
|
||||
)) as SavedObject<InterceptInteractionUserRecordAttributes>;
|
||||
}
|
||||
|
||||
return interactionTriggerSavedObject?.attributes;
|
||||
}
|
||||
}
|
31
x-pack/platform/plugins/private/intercepts/tsconfig.json
Normal file
31
x-pack/platform/plugins/private/intercepts/tsconfig.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"public/**/*.json",
|
||||
"server/**/*",
|
||||
"test_helpers/**/*",
|
||||
"storybook/**/*",
|
||||
"../../../../../typings/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/core-analytics-browser",
|
||||
"@kbn/core-saved-objects-api-server",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/i18n",
|
||||
"@kbn/task-manager-plugin",
|
||||
"@kbn/core-http-browser-mocks",
|
||||
"@kbn/core-notifications-browser-mocks",
|
||||
"@kbn/core-analytics-browser-mocks",
|
||||
"@kbn/core-rendering-browser-mocks",
|
||||
"@kbn/core-rendering-browser",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/core-http-browser"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
## Product intercept plugin
|
||||
|
||||
This is a standalone plugin that leverages the intercept plugin to display product intercept used to gather information that is turn used to compute CSAT about user's experience of Kibana.
|
||||
|
||||
This plugin exposes no public APIs, but however exposes the following config
|
||||
|
||||
- `xpack.product_intercept.enabled`: Expects a boolean value, determines if the product intercept would be allowed to run given that the intercept plugin is enabled.
|
||||
|
||||
- `xpack.product_intercept.interval`: Expects a limited subset of duration string; (d,m,h,s) , denotes the cadence at which a user would be prompted to provide feedback about kibana
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import type { PluginConfigDescriptor, ExposedToBrowserDescriptor } from '@kbn/core/server';
|
||||
|
||||
/**
|
||||
* Config used by plugin to determine if orchestration is invoked,
|
||||
* and if the product intercept gets displayed on the client.
|
||||
*/
|
||||
export const configSchema = schema.object({
|
||||
/**
|
||||
* Whether the product intercept orchestration is enabled.
|
||||
* It's worth noting that if the intercept plugin is disabled this setting will have no effect.
|
||||
*/
|
||||
enabled: schema.boolean({
|
||||
defaultValue: false,
|
||||
}),
|
||||
interval: schema.string({
|
||||
defaultValue: '30m',
|
||||
validate(value) {
|
||||
if (!/^[0-9]+(d|h|m|s)$/.test(value)) {
|
||||
return 'must be a supported duration string';
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type ServerConfigSchema = TypeOf<typeof configSchema>;
|
||||
|
||||
const browserConfigSchemaDescriptor: ExposedToBrowserDescriptor<ServerConfigSchema> = {
|
||||
interval: false,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export const config: PluginConfigDescriptor<ServerConfigSchema> = {
|
||||
exposeToBrowser: browserConfigSchemaDescriptor,
|
||||
schema: configSchema,
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const TRIGGER_DEF_ID = 'productInterceptTrigger' as const;
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/platform/plugins/private/product_intercept'],
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/product-intercept-plugin",
|
||||
"owner": ["@elastic/appex-sharedux"],
|
||||
"group": "platform",
|
||||
"visibility": "private",
|
||||
"description": "This plugin leverages the intercepts plugin to in soliciting app-wide product feedback from users.",
|
||||
"plugin": {
|
||||
"id": "productIntercept",
|
||||
"browser": true,
|
||||
"server": true,
|
||||
"requiredPlugins": ["intercepts"],
|
||||
"requiredBundles": [],
|
||||
"configPath": ["xpack", "product_intercept"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { NPSScoreInput } from './nps_score_input';
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
EuiButtonGroup,
|
||||
EuiButtonGroupProps,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
} from '@elastic/eui';
|
||||
|
||||
interface NPSScoreInputProps {
|
||||
/**
|
||||
* default value here is 5
|
||||
*/
|
||||
upperBound?: number;
|
||||
lowerBoundHelpText?: string;
|
||||
upperBoundHelpText?: string;
|
||||
onChange?: EuiButtonGroupProps['onChange'];
|
||||
}
|
||||
|
||||
export function NPSScoreInput({
|
||||
onChange,
|
||||
lowerBoundHelpText,
|
||||
upperBoundHelpText,
|
||||
upperBound = 5,
|
||||
}: NPSScoreInputProps) {
|
||||
const options: EuiButtonGroupProps['options'] = Array.from({ length: upperBound }, (_, i) => {
|
||||
const optionValue = i + 1;
|
||||
|
||||
return {
|
||||
id: `nps-${optionValue}`,
|
||||
label: optionValue,
|
||||
value: optionValue,
|
||||
};
|
||||
});
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState('');
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
helpText={
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{lowerBoundHelpText}</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{upperBoundHelpText}</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
<EuiButtonGroup
|
||||
legend="Survey about user satisfaction"
|
||||
type="single"
|
||||
options={options}
|
||||
idSelected={selectedOption}
|
||||
onChange={(id, value) => {
|
||||
setSelectedOption(id);
|
||||
onChange?.(value);
|
||||
}}
|
||||
isFullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ProductInterceptPublicPlugin } from './plugin';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function plugin() {
|
||||
return new ProductInterceptPublicPlugin();
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiLink, EuiText } from '@elastic/eui';
|
||||
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import { InterceptsStart } from '@kbn/intercepts-plugin/public';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { NPSScoreInput } from './components';
|
||||
import { PromptTelemetry } from './telemetry';
|
||||
import { TRIGGER_DEF_ID } from '../common/constants';
|
||||
|
||||
interface ProductInterceptPluginStartDeps {
|
||||
intercepts: InterceptsStart;
|
||||
}
|
||||
|
||||
export class ProductInterceptPublicPlugin implements Plugin {
|
||||
private readonly telemetry = new PromptTelemetry();
|
||||
private interceptSubscription?: Subscription;
|
||||
|
||||
setup(core: CoreSetup) {
|
||||
return this.telemetry.setup({ analytics: core.analytics });
|
||||
}
|
||||
|
||||
start(core: CoreStart, { intercepts }: ProductInterceptPluginStartDeps) {
|
||||
const eventReporter = this.telemetry.start({
|
||||
analytics: core.analytics,
|
||||
});
|
||||
|
||||
this.interceptSubscription = intercepts
|
||||
.registerIntercept?.({
|
||||
id: TRIGGER_DEF_ID,
|
||||
steps: [
|
||||
{
|
||||
id: 'start',
|
||||
title: i18n.translate('productIntercept.prompter.step.start.title', {
|
||||
defaultMessage: 'Help us improve Kibana',
|
||||
}),
|
||||
content: () =>
|
||||
React.createElement(
|
||||
EuiText,
|
||||
{ key: 'productInterceptPrompterStartContent', size: 's' },
|
||||
i18n.translate('productIntercept.prompter.step.start.content', {
|
||||
defaultMessage:
|
||||
'We are always looking for ways to improve Kibana. Please take a moment to share your feedback with us.',
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'satisfaction',
|
||||
title: i18n.translate('productIntercept.prompter.step.satisfaction.title', {
|
||||
defaultMessage: 'Overall, how satisfied or dissatisfied are you with Kibana?',
|
||||
}),
|
||||
content: ({ onValue }) => {
|
||||
return React.createElement(NPSScoreInput, {
|
||||
lowerBoundHelpText: i18n.translate(
|
||||
'productIntercept.prompter.step.satisfaction.lowerBoundDescriptionText',
|
||||
{
|
||||
defaultMessage: 'Very dissatisfied',
|
||||
}
|
||||
),
|
||||
upperBoundHelpText: i18n.translate(
|
||||
'productIntercept.prompter.step.satisfaction.upperBoundDescriptionText',
|
||||
{
|
||||
defaultMessage: 'Very satisfied',
|
||||
}
|
||||
),
|
||||
onChange: onValue,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ease',
|
||||
title: i18n.translate('productIntercept.prompter.step.ease.title', {
|
||||
defaultMessage: 'Overall, how difficult or easy is it to use Kibana?',
|
||||
}),
|
||||
content: ({ onValue }) => {
|
||||
return React.createElement(NPSScoreInput, {
|
||||
lowerBoundHelpText: i18n.translate(
|
||||
'productIntercept.prompter.step.ease.lowerBoundDescriptionText',
|
||||
{
|
||||
defaultMessage: 'Very difficult',
|
||||
}
|
||||
),
|
||||
upperBoundHelpText: i18n.translate(
|
||||
'productIntercept.prompter.step.ease.upperBoundDescriptionText',
|
||||
{
|
||||
defaultMessage: 'Very easy',
|
||||
}
|
||||
),
|
||||
onChange: onValue,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'completion',
|
||||
title: i18n.translate('productIntercept.prompter.step.completion.title', {
|
||||
defaultMessage: 'Thanks for the feedback!',
|
||||
}),
|
||||
content: () => {
|
||||
return React.createElement(
|
||||
EuiText,
|
||||
{ size: 's' },
|
||||
React.createElement(FormattedMessage, {
|
||||
id: 'productIntercept.prompter.step.completion.content',
|
||||
defaultMessage:
|
||||
"If you'd like to participate in future research to help improve kibana, <link>click here</link>.",
|
||||
values: {
|
||||
link: (chunks) =>
|
||||
React.createElement(
|
||||
EuiLink,
|
||||
{
|
||||
external: true,
|
||||
href: 'https://www.elastic.co/feedback',
|
||||
target: '_blank',
|
||||
},
|
||||
chunks
|
||||
),
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
onProgress: ({ stepId, stepResponse, runId }) => {
|
||||
eventReporter.reportInterceptInteractionProgress({
|
||||
interceptRunId: runId,
|
||||
metricId: stepId,
|
||||
value: Number(stepResponse),
|
||||
});
|
||||
},
|
||||
onFinish: ({ response: feedbackResponse, runId }) => {
|
||||
eventReporter.reportInterceptInteraction({
|
||||
interactionType: 'completion',
|
||||
interceptRunId: runId,
|
||||
});
|
||||
},
|
||||
onDismiss: ({ runId }) => {
|
||||
// still update user profile run count, a dismissal is still an interaction
|
||||
eventReporter.reportInterceptInteraction({
|
||||
interactionType: 'dismissal',
|
||||
interceptRunId: runId,
|
||||
});
|
||||
},
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.interceptSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { type RootSchema, type EventTypeOpts } from '@elastic/ebt/client';
|
||||
|
||||
export enum EventMetric {
|
||||
PRODUCT_INTERCEPT_TERMINATION_INTERACTION = 'product_intercept_termination_interaction',
|
||||
PRODUCT_INTERCEPT_PROGRESS_INTERACTION = 'product_intercept_interaction_progress',
|
||||
PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR = 'product_intercept_trigger_fetch_error',
|
||||
}
|
||||
|
||||
export enum EventFieldType {
|
||||
INTERACTION_TYPE = 'interaction_type',
|
||||
INTERCEPT_RUN_ID = 'interaction_run_id',
|
||||
INTERACTION_METRIC = 'interaction_metric',
|
||||
INTERACTION_METRIC_VALUE = 'interaction_metric_value',
|
||||
TRIGGER_FETCH_ERROR_MESSAGE = 'trigger_fetch_error_message',
|
||||
}
|
||||
|
||||
const fields: Record<EventFieldType, RootSchema<unknown>> = {
|
||||
[EventFieldType.INTERCEPT_RUN_ID]: {
|
||||
[EventFieldType.INTERCEPT_RUN_ID]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The id of the product intercept run',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.INTERACTION_TYPE]: {
|
||||
[EventFieldType.INTERACTION_TYPE]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The type of interaction that occurred with the intercept',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.INTERACTION_METRIC]: {
|
||||
[EventFieldType.INTERACTION_METRIC]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'The interaction metric id of of the product intercept',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.INTERACTION_METRIC_VALUE]: {
|
||||
[EventFieldType.INTERACTION_METRIC_VALUE]: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'The value for the interaction metric id of of the product intercept',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: {
|
||||
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: {
|
||||
type: 'text',
|
||||
_meta: {
|
||||
description: 'The error message from the trigger fetch',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @description defines all the event types that can be reported by the product intercept dialog,
|
||||
* with the mapping that values provided will be ingested as within EBT
|
||||
*/
|
||||
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
|
||||
{
|
||||
eventType: EventMetric.PRODUCT_INTERCEPT_TERMINATION_INTERACTION,
|
||||
schema: {
|
||||
...fields[EventFieldType.INTERACTION_TYPE],
|
||||
...fields[EventFieldType.INTERCEPT_RUN_ID],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: EventMetric.PRODUCT_INTERCEPT_PROGRESS_INTERACTION,
|
||||
schema: {
|
||||
...fields[EventFieldType.INTERACTION_METRIC],
|
||||
...fields[EventFieldType.INTERACTION_METRIC_VALUE],
|
||||
...fields[EventFieldType.INTERCEPT_RUN_ID],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: EventMetric.PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR,
|
||||
schema: {
|
||||
...fields[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE],
|
||||
},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import { EventMetric, EventFieldType, eventTypes } from './event_definitions';
|
||||
|
||||
export class PromptTelemetry {
|
||||
private reportEvent?: AnalyticsServiceStart['reportEvent'];
|
||||
|
||||
public setup({ analytics }: { analytics: AnalyticsServiceSetup }) {
|
||||
eventTypes.forEach((eventType) => {
|
||||
analytics.registerEventType(eventType);
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start({ analytics }: { analytics: AnalyticsServiceStart }) {
|
||||
this.reportEvent = analytics.reportEvent;
|
||||
|
||||
return {
|
||||
reportInterceptInteraction: this.reportInterceptTermination,
|
||||
reportInterceptInteractionProgress: this.reportInterceptInteractionProgress,
|
||||
reportTriggerFetchError: this.reportTriggerFetchError,
|
||||
};
|
||||
}
|
||||
|
||||
private reportInterceptTermination({
|
||||
interactionType,
|
||||
interceptRunId,
|
||||
}: {
|
||||
interactionType: 'dismissal' | 'completion';
|
||||
interceptRunId: number;
|
||||
}) {
|
||||
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_TERMINATION_INTERACTION, {
|
||||
[EventFieldType.INTERACTION_TYPE]: interactionType,
|
||||
[EventFieldType.INTERCEPT_RUN_ID]: String(interceptRunId),
|
||||
});
|
||||
}
|
||||
|
||||
private reportInterceptInteractionProgress({
|
||||
interceptRunId,
|
||||
metricId,
|
||||
value,
|
||||
}: {
|
||||
interceptRunId: number;
|
||||
metricId: string;
|
||||
value: number;
|
||||
}) {
|
||||
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_PROGRESS_INTERACTION, {
|
||||
[EventFieldType.INTERACTION_METRIC]: metricId,
|
||||
[EventFieldType.INTERACTION_METRIC_VALUE]: value,
|
||||
[EventFieldType.INTERCEPT_RUN_ID]: String(interceptRunId),
|
||||
});
|
||||
}
|
||||
|
||||
private reportTriggerFetchError({ errorMessage }: { errorMessage: string }) {
|
||||
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR, {
|
||||
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { PromptTelemetry } from './event_reporter';
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { PluginInitializerContext } from '@kbn/core/server';
|
||||
export { config } from '../common/config';
|
||||
|
||||
export const plugin = async (initContext: PluginInitializerContext) => {
|
||||
const { ProductInterceptServerPlugin } = await import('./plugin');
|
||||
return new ProductInterceptServerPlugin(initContext);
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
Plugin,
|
||||
type CoreSetup,
|
||||
type CoreStart,
|
||||
type PluginInitializerContext,
|
||||
type Logger,
|
||||
} from '@kbn/core/server';
|
||||
import type { InterceptSetup, InterceptStart } from '@kbn/intercepts-plugin/server';
|
||||
import { TRIGGER_DEF_ID } from '../common/constants';
|
||||
import { ServerConfigSchema } from '../common/config';
|
||||
|
||||
interface ProductInterceptServerPluginSetup {
|
||||
intercepts: InterceptSetup;
|
||||
}
|
||||
|
||||
interface ProductInterceptServerPluginStart {
|
||||
intercepts: InterceptStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export class ProductInterceptServerPlugin
|
||||
implements Plugin<object, object, ProductInterceptServerPluginSetup, never>
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private readonly config: ServerConfigSchema;
|
||||
|
||||
constructor(initContext: PluginInitializerContext<unknown>) {
|
||||
this.logger = initContext.logger.get();
|
||||
this.config = initContext.config.get<ServerConfigSchema>();
|
||||
}
|
||||
|
||||
setup(core: CoreSetup, {}: ProductInterceptServerPluginSetup) {
|
||||
return {};
|
||||
}
|
||||
|
||||
start(core: CoreStart, { intercepts }: ProductInterceptServerPluginStart) {
|
||||
if (this.config.enabled) {
|
||||
void intercepts.registerTriggerDefinition?.(TRIGGER_DEF_ID, () => {
|
||||
this.logger.debug('Registering kibana product trigger definition');
|
||||
return { triggerAfter: this.config.interval };
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"public/**/*.json",
|
||||
"server/**/*",
|
||||
"test_helpers/**/*",
|
||||
"storybook/**/*",
|
||||
"../../../../../typings/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/core-analytics-browser",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/i18n",
|
||||
"@kbn/intercepts-plugin",
|
||||
"@kbn/i18n-react"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
|
@ -181,6 +181,8 @@ export default async () => {
|
|||
`--xpack.cloud.deployments_url=/deployments`,
|
||||
`--xpack.cloud.organization_url=/account/`,
|
||||
`--xpack.cloud.users_and_roles_url=/account/members/`,
|
||||
// disable product intercept for all ftr tests by default
|
||||
'--xpack.intercepts.enabled=false',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -5904,6 +5904,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/intercepts-plugin@link:x-pack/platform/plugins/private/intercepts":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/interpreter@link:src/platform/packages/shared/kbn-interpreter":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
@ -6564,6 +6568,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/product-intercept-plugin@link:x-pack/platform/plugins/private/product_intercept":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/profiler-cli@link:x-pack/platform/packages/shared/kbn-profiler-cli":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue