[Drift] Enable chat globally + A/B test for pages where the chat was available before (#167069)

## Summary

Close https://github.com/elastic/kibana/issues/159691

[Requirements](https://docs.google.com/document/d/1uXgyDIGuIqkYXmavdMTpBgQEiOP07ObJYb7GNq5GiSE/edit#heading=h.okl11rz12ytg)
[A/B test
description](https://docs.google.com/document/d/1yzfZF8mtlRNH4X6HjosD6Exh24zAhN__LR_zh3DOzKw/edit?usp=sharing)

[Figma](https://www.figma.com/file/WGhmfgyy9FBOltLtycfGPE/Getting-started?type=design&node-id=92-44804&mode=design&t=mwbIexn5Fs754HQz-0)
Testing - see _testing_ section below for more details
(https://dosant-pr-167374-d-2023-06-14-global-drift-with-experiment.kbndev.co/.
elastic/changeme)

This PR enables cloud chat (Drift) globally. This is done by adding a
custom chat button in the Kibana header which manually toggles Drift
widget. We attempt to manually position the widget to the top of the
screen so it pops up close to the chat button that triggered it.

Previously Drift chat was available only on specific pages like
Solutions onboarding pages, integrations, setup guides as a regular chat
widget with the floating chat bubble in the bottom right corner. We
couldn't enable it on all pages, because on a lot of them the floating
chat bottom would have overlapped the application UI.

We also were asked to add [an a/b
test](https://docs.google.com/document/d/1yzfZF8mtlRNH4X6HjosD6Exh24zAhN__LR_zh3DOzKw/edit?usp=sharing):
 - A: The chat button appears in the header for all pages (new)
- B: The chat button appears as floating action button in the
bottom-right corner on pages where Drift was previously available
(solutions onboarding pages, integrations, setup guides)

### Screenshots / Videos

#### Global Chat in the header

![Screenshot 2023-09-25 at 10 30
41](dba3b3da-4e90-4d6c-a5d2-99123aa8c753)
![Screenshot 2023-09-25 at 10 30
45](752d05e4-cc85-458e-8216-f75529c2bac7)

#### The tour on the first appearance 

![Screenshot 2023-09-25 at 10 55
42](c0958095-f724-4b69-a149-04a0aec8e083)

#### (Part of A/B test) Drift in the header on new pages and as floating
action button on old pages


0386ccbd-ab6c-4eb2-a57b-f9324fcf73eb

### Implementation notes

- **We still enable Drift only for trial users + gap window**
- We exposed additional APIs from drift iframe to manually control its
visibility and react to more events
https://github.com/elastic/cloud/pull/118761. This changes are required
for the code in this PR to work. ~The updated frame code wasn't deployed
yet.~ the changes were deployed
- We use [`playbookFired` event
](https://devdocs.drift.com/docs/drift-events#playbook-fired) to know if
Drift chat should be visible for the current user. We show the button in
the header only when it fires.
- To react to the event and to display the button, we have to kick of
Drift iframe initialization first
- This means Drift codes loads before we show the button and before user
interacts with it (Only when Drift is enabled, meaning, only for trial
users + gap window)
- Subsequent launches or opens of the same playbook will not re-trigger
the `playbookFired` event, I used local storage flag to workaround this
and show the live chat button, but it also has it's own edge case. As an
alternative we can always show the chat button and don't rely on the
playbook event, more details here:
https://docs.google.com/document/d/1j313mVOIz19Rkoj8TDFWaLc7Pgk_ByBBKJFNIC-jbyc/edit?usp=sharing.
For now was decided to rely on the `playbookFired` event.
- A/B: **to support for both new and old implementation, I had to
refactor the old one from drop-in chat to global chat that is controlled
by list of hardcoded URLs.** This is not ideal, but this allows two
implementations to co-exist with much tech-debt and we plan to get rid
of this after the a/b test.
- When we navigate between pages with different implementations, Drift
re-initializes itself (just like in old implementation), this
performance debt should go away when we get rid of the a/b test.
- When end-to-end testing the a/b experiment, I found a bug in the a/b
test setup in Kibana https://github.com/elastic/kibana/issues/167240
this needs to be addressed separately for a/b test to work properly.
- When the user receives a message from Drift - the custom "Live Chat"
button doesn't indicate that there is a new message. This needs a follow
up, @Dosant to create an issue


### Testing

Version with the experiment where on old pages Drift appears as a
floating chat bubble -
https://dosant-pr-167374-d-2023-06-14-global-drift-with-experiment.kbndev.co/
elastic/changeme

> [!NOTE]  
> If the live chat button doesn't appear, it is likely because the
playbook was recently activated by someone else. you can workaround this
for testing by creating a different user or by setting
`cloudChatPlaybookFiredOnce : true` to localstorage. This issue and
mitigation is described in details in the "implementation details"
section


#### To test locally: 

```
xpack.cloud.id: 'some-id'
xpack.cloud.trial_end_date: '2023-09-21T00:00:00.000Z'
xpack.cloud_integrations.chat.trialBuffer: 45
xpack.cloud.chat.enabled: true

xpack.cloud.chatIdentitySecret: <pls react out> 
xpack.cloud.chat.chatURL: https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html

xpack.cloud_integrations.experiments.flag_overrides:
  "cloud-chat.chat-variant": "bubble" or "header"

```
This commit is contained in:
Anton Dosov 2023-10-01 16:07:11 +02:00 committed by GitHub
parent 924664fc79
commit 07f1c36df7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 764 additions and 358 deletions

1
.github/CODEOWNERS vendored
View file

@ -69,7 +69,6 @@ packages/kbn-ci-stats-shipper-cli @elastic/kibana-operations
packages/kbn-cli-dev-mode @elastic/kibana-operations
packages/cloud @elastic/kibana-core
x-pack/plugins/cloud_integrations/cloud_chat @elastic/kibana-core
x-pack/plugins/cloud_integrations/cloud_chat_provider @elastic/kibana-core
x-pack/plugins/cloud_integrations/cloud_data_migration @elastic/platform-onboarding
x-pack/plugins/cloud_defend @elastic/kibana-cloud-security-posture
x-pack/plugins/cloud_integrations/cloud_experiments @elastic/kibana-core

View file

@ -478,10 +478,6 @@ The plugin exposes the static DefaultEditorController class to consume.
|Integrates with DriftChat in order to provide live support to our Elastic Cloud users. This plugin should only run on Elastic Cloud.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_chat_provider/README.md[cloudChatProvider]
|This plugin exists as a workaround for using cloudChat plugin in plugins which can't have a direct dependency on security plugin.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_data_migration/README.md[cloudDataMigration]
|Static migration page where self-managed users can see text/copy about migrating to Elastic Cloud

View file

@ -175,7 +175,6 @@
"@kbn/charts-plugin": "link:src/plugins/charts",
"@kbn/cloud": "link:packages/cloud",
"@kbn/cloud-chat-plugin": "link:x-pack/plugins/cloud_integrations/cloud_chat",
"@kbn/cloud-chat-provider-plugin": "link:x-pack/plugins/cloud_integrations/cloud_chat_provider",
"@kbn/cloud-data-migration-plugin": "link:x-pack/plugins/cloud_integrations/cloud_data_migration",
"@kbn/cloud-defend-plugin": "link:x-pack/plugins/cloud_defend",
"@kbn/cloud-experiments-plugin": "link:x-pack/plugins/cloud_integrations/cloud_experiments",

View file

@ -7,6 +7,7 @@
*/
import { capabilitiesServiceMock } from '@kbn/core-capabilities-browser-mocks';
import { Observable } from 'rxjs';
export const MockCapabilitiesService = capabilitiesServiceMock.create();
export const CapabilitiesServiceConstructor = jest
@ -26,7 +27,7 @@ jest.doMock('history', () => ({
}));
export const parseAppUrlMock = jest.fn();
export const getLocationObservableMock = jest.fn();
export const getLocationObservableMock = jest.fn(() => new Observable());
jest.doMock('./utils', () => {
const original = jest.requireActual('./utils');

View file

@ -112,6 +112,7 @@ export class ApplicationService {
private stop$ = new Subject<void>();
private registrationClosed = false;
private history?: History<any>;
private location$?: Observable<string>;
private navigate?: (url: string, state: unknown, replace: boolean) => void;
private openInNewTab?: (url: string) => void;
private redirectTo?: (url: string) => void;
@ -136,10 +137,10 @@ export class ApplicationService {
}),
});
const location$ = getLocationObservable(window.location, this.history);
this.location$ = getLocationObservable(window.location, this.history);
registerAnalyticsContextProvider({
analytics,
location$,
location$: this.location$,
});
this.navigate = (url, state, replace) => {
@ -311,6 +312,7 @@ export class ApplicationService {
shareReplay(1)
),
capabilities,
currentLocation$: this.location$!.pipe(takeUntil(this.stop$)),
currentAppId$: this.currentAppId$.pipe(
filter((appId) => appId !== undefined),
distinctUntilChanged(),

View file

@ -40,10 +40,12 @@ const createInternalSetupContractMock = (): jest.Mocked<InternalApplicationSetup
const createStartContractMock = (): jest.Mocked<ApplicationStart> => {
const currentAppId$ = new Subject<string | undefined>();
const currentLocation$ = new Subject<string>();
return {
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(new Map()),
currentAppId$: currentAppId$.asObservable(),
currentLocation$: currentLocation$.asObservable(),
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
@ -79,11 +81,13 @@ const createInternalStartContractMock = (
const currentAppId$ = currentAppId
? new BehaviorSubject<string | undefined>(currentAppId)
: new Subject<string | undefined>();
const currentLocation$ = new Subject<string>();
return {
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(new Map()),
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
currentAppId$: currentAppId$.asObservable(),
currentLocation$: currentLocation$.asObservable(),
currentActionMenu$: new BehaviorSubject<MountPoint | undefined>(undefined),
getComponent: jest.fn(),
getUrlForApp: jest.fn(),

View file

@ -134,6 +134,11 @@ export interface ApplicationStart {
* An observable that emits the current application id and each subsequent id update.
*/
currentAppId$: Observable<string | undefined>;
/**
* An observable that emits the current path#hash and each subsequent update using the global history instance
*/
currentLocation$: Observable<string>;
}
/**

View file

@ -115,6 +115,7 @@ export function createPluginStartContext<
navigateToApp: deps.application.navigateToApp,
navigateToUrl: deps.application.navigateToUrl,
getUrlForApp: deps.application.getUrlForApp,
currentLocation$: deps.application.currentLocation$,
},
customBranding: deps.customBranding,
docLinks: deps.docLinks,

View file

@ -12,7 +12,6 @@ pageLoadAssetSize:
charts: 55000
cloud: 21076
cloudChat: 19894
cloudChatProvider: 17114
cloudDataMigration: 19170
cloudDefend: 18697
cloudExperiments: 59358

View file

@ -21,10 +21,12 @@ const capabilities = deepFreeze({
export const createStartContractMock = (): jest.Mocked<ApplicationStart> => {
const currentAppId$ = new Subject<string | undefined>();
const currentLocation$ = new Subject<string>();
return {
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(new Map()),
currentAppId$: currentAppId$.asObservable(),
currentLocation$: currentLocation$.asObservable(),
capabilities,
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),

View file

@ -6,20 +6,8 @@
"id": "home",
"server": true,
"browser": true,
"requiredPlugins": [
"dataViews",
"share",
"urlForwarding"
],
"optionalPlugins": [
"usageCollection",
"customIntegrations",
"cloud",
"guidedOnboarding",
"cloudChatProvider"
],
"requiredBundles": [
"kibanaReact"
]
"requiredPlugins": ["dataViews", "share", "urlForwarding"],
"optionalPlugins": ["usageCollection", "customIntegrations", "cloud", "guidedOnboarding"],
"requiredBundles": ["kibanaReact"]
}
}

View file

@ -43,8 +43,7 @@ const skipText = i18n.translate('home.guidedOnboarding.gettingStarted.skip.butto
});
export const GettingStarted = () => {
const { application, trackUiMetric, chrome, guidedOnboardingService, cloud, cloudChat } =
getServices();
const { application, trackUiMetric, chrome, guidedOnboardingService, cloud } = getServices();
const [guidesState, setGuidesState] = useState<GuideState[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
@ -227,7 +226,6 @@ export const GettingStarted = () => {
{skipText}
</EuiLink>
</div>
{cloudChat?.Chat && <cloudChat.Chat />}
</EuiPageTemplate.Section>
</KibanaPageTemplate>
);

View file

@ -22,7 +22,6 @@ import { DataViewsContract } from '@kbn/data-views-plugin/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { GuidedOnboardingApi } from '@kbn/guided-onboarding-plugin/public';
import { CloudSetup } from '@kbn/cloud-plugin/public';
import { CloudChatProviderPluginStart } from '@kbn/cloud-chat-provider-plugin/public';
import { TutorialService } from '../services/tutorials';
import { AddDataService } from '../services/add_data';
import { FeatureCatalogueRegistry } from '../services/feature_catalogue';
@ -54,7 +53,6 @@ export interface HomeKibanaServices {
welcomeService: WelcomeService;
guidedOnboardingService?: GuidedOnboardingApi;
cloud?: CloudSetup;
cloudChat?: CloudChatProviderPluginStart;
}
let services: HomeKibanaServices | null = null;

View file

@ -22,7 +22,6 @@ import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/
import { AppNavLinkStatus } from '@kbn/core/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { CloudChatProviderPluginStart } from '@kbn/cloud-chat-provider-plugin/public';
import { PLUGIN_ID, HOME_APP_BASE_PATH } from '../common/constants';
import { setServices } from './application/kibana_services';
import { ConfigSchema } from '../config';
@ -43,7 +42,6 @@ export interface HomePluginStartDependencies {
dataViews: DataViewsPublicPluginStart;
urlForwarding: UrlForwardingStart;
guidedOnboarding: GuidedOnboardingPluginStart;
cloudChatProvider?: CloudChatProviderPluginStart;
}
export interface HomePluginSetupDependencies {
@ -82,10 +80,8 @@ export class HomePublicPlugin
const trackUiMetric = usageCollection
? usageCollection.reportUiCounter.bind(usageCollection, 'Kibana_home')
: () => {};
const [
coreStart,
{ dataViews, urlForwarding: urlForwardingStart, guidedOnboarding, cloudChatProvider },
] = await core.getStartServices();
const [coreStart, { dataViews, urlForwarding: urlForwardingStart, guidedOnboarding }] =
await core.getStartServices();
setServices({
share,
trackUiMetric,
@ -110,7 +106,6 @@ export class HomePublicPlugin
welcomeService: this.welcomeService,
guidedOnboardingService: guidedOnboarding.guidedOnboardingApi,
cloud,
cloudChat: cloudChatProvider,
});
coreStart.chrome.docTitle.change(
i18n.translate('home.pageTitle', { defaultMessage: 'Home' })

View file

@ -30,7 +30,6 @@
"@kbn/ebt-tools",
"@kbn/core-analytics-server",
"@kbn/storybook",
"@kbn/cloud-chat-provider-plugin",
"@kbn/shared-ux-router",
"@kbn/core-http-common",
],

View file

@ -133,6 +133,16 @@ exports[`SavedObjectsTable should render normally 1`] = `
"thrownError": null,
},
},
"currentLocation$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],

View file

@ -132,8 +132,6 @@
"@kbn/cloud/*": ["packages/cloud/*"],
"@kbn/cloud-chat-plugin": ["x-pack/plugins/cloud_integrations/cloud_chat"],
"@kbn/cloud-chat-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_chat/*"],
"@kbn/cloud-chat-provider-plugin": ["x-pack/plugins/cloud_integrations/cloud_chat_provider"],
"@kbn/cloud-chat-provider-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_chat_provider/*"],
"@kbn/cloud-data-migration-plugin": ["x-pack/plugins/cloud_integrations/cloud_data_migration"],
"@kbn/cloud-data-migration-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_data_migration/*"],
"@kbn/cloud-defend-plugin": ["x-pack/plugins/cloud_defend"],

View file

@ -13,6 +13,7 @@ import { ServicesProvider, CloudChatServices } from '../public/services';
const services: CloudChatServices = {
chat: {
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
chatVariant: 'bubble',
user: {
id: 'user-id',
email: 'test-user@elastic.co',

View file

@ -5,8 +5,11 @@
* 2.0.
*/
export type ChatVariant = 'header' | 'bubble';
export interface GetChatUserDataResponseBody {
token: string;
email: string;
id: string;
chatVariant: ChatVariant;
}

View file

@ -13,11 +13,13 @@
"chat"
],
"requiredPlugins": [
"cloud",
"cloudChatProvider"
"cloud"
],
"requiredBundles": [
],
"optionalPlugins": [
"security"
"security",
"cloudExperiments"
]
}
}

View file

@ -5,79 +5,40 @@
* 2.0.
*/
import React, { useRef, useState } from 'react';
import { css } from '@emotion/react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { useChatConfig } from './use_chat_config';
import { WhenIdle } from './when_idle';
import { useChatConfig, ChatApi } from './use_chat_config';
export type { ChatApi } from './use_chat_config';
export interface Props {
/** Handler invoked when chat is hidden by someone. */
onHide?: () => void;
/** Handler invoked when the chat widget signals it is ready. */
onReady?: () => void;
onReady?: (chatApi: ChatApi) => void;
/** Handler invoked when the chat widget signals to be resized. */
onResize?: () => void;
/** Handler invoked when the playbook is fired. */
onPlaybookFired?: () => void;
/** The offset from the top of the page to the chat widget. */
topOffset?: number;
}
/**
* A component that will display a trigger that will allow the user to chat with a human operator,
* when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = ({ onHide = () => {}, onReady, onResize }: Props) => {
const config = useChatConfig({ onReady, onResize });
const ref = useRef<HTMLDivElement>(null);
const [isClosed, setIsClosed] = useState(false);
export const Chat = ({ onReady, onResize, onPlaybookFired, topOffset = 0 }: Props) => {
const config = useChatConfig({
onReady,
onResize,
onPlaybookFired,
});
if (!config.enabled || isClosed) {
if (!config.enabled) {
return null;
}
const { isReady, isResized, style } = config;
const { right } = style;
const buttonCSS = css`
bottom: ${euiThemeVars.euiSizeXS};
position: fixed;
right: calc(${right} + ${euiThemeVars.euiSizeXS});
visibility: ${isReady && isResized ? 'visible' : 'hidden'};
`;
const button = (
<EuiButtonEmpty
css={buttonCSS}
data-test-subj="cloud-chat-hide"
name="cloudChatHide"
onClick={() => {
onHide();
setIsClosed(true);
}}
size="xs"
>
{i18n.translate('xpack.cloudChat.hideChatButtonLabel', {
defaultMessage: 'Hide chat',
})}
</EuiButtonEmpty>
);
const containerCSS = css`
bottom: ${euiThemeVars.euiSizeXL};
position: fixed;
right: ${euiThemeVars.euiSizeXL};
z-index: ${euiThemeVars.euiZMaskBelowHeader - 1};
&:focus [name='cloudChatHide'],
&:hover [name='cloudChatHide'] {
visibility: visible;
}
`;
return (
<div css={containerCSS} ref={ref} data-test-subj="cloud-chat">
{button}
<WhenIdle>
<iframe
data-test-subj="cloud-chat-frame"
title={i18n.translate('xpack.cloudChat.chatFrameTitle', {
@ -85,8 +46,25 @@ export const Chat = ({ onHide = () => {}, onReady, onResize }: Props) => {
})}
src={config.src}
ref={config.ref}
style={config.style}
style={
config.isReady
? {
position: 'fixed',
...config.style,
// reset default button positioning
bottom: 'auto',
inset: 'initial',
// force position to the top and of the page
top: topOffset,
right: 0,
// TODO: if the page height is smaller than widget height + topOffset,
// the widget will be cut off from the bottom.
// @ts-ignore - fixes white background on iframe in chrome/system dark mode
colorScheme: 'light',
}
: { display: 'none' }
}
/>
</div>
</WhenIdle>
);
};

View file

@ -0,0 +1,28 @@
/*
* 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, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
import type { Props } from './chat';
export type { ChatApi, Props } from './chat';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() => import('./chat').then(({ Chat }) => ({ default: Chat })));
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = (props: Props) => (
<EuiErrorBoundary>
<Suspense fallback={<></>}>
<LazyChat {...props} />
</Suspense>
</EuiErrorBoundary>
);

View file

@ -6,6 +6,7 @@
*/
import React, { useEffect, useRef, useState, CSSProperties } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { useChat } from '../../services';
import { getChatContext } from './get_chat_context';
import { Props as ChatProps } from './chat';
@ -21,12 +22,23 @@ type UseChatType =
isResized: boolean;
};
export interface ChatApi {
show: () => void;
hide: () => void;
toggle: () => void;
}
const MESSAGE_WIDGET_READY = 'driftWidgetReady';
const MESSAGE_IFRAME_READY = 'driftIframeReady';
const MESSAGE_RESIZE = 'driftIframeResize';
const MESSAGE_SET_CONTEXT = 'driftSetContext';
const MESSAGE_CHAT_CLOSED = 'driftChatClosed';
const MESSAGE_PLAYBOOK_FIRED = 'driftPlaybookFired';
type ChatConfigParams = Exclude<ChatProps, 'onHide'>;
type ChatConfigParams = Exclude<ChatProps, 'onHide'> & {
/** if the chat visibility is controlled from the outside */
controlled?: boolean;
};
/**
* Hook which handles positioning and communication with the chat widget.
@ -34,12 +46,19 @@ type ChatConfigParams = Exclude<ChatProps, 'onHide'>;
export const useChatConfig = ({
onReady = () => {},
onResize = () => {},
onPlaybookFired = () => {},
controlled = true,
}: ChatConfigParams): UseChatType => {
const ref = useRef<HTMLIFrameElement>(null);
const chat = useChat();
const [style, setStyle] = useState<CSSProperties>({ height: 0, width: 0 });
const [isReady, setIsReady] = useState(false);
const [isResized, setIsResized] = useState(false);
const isChatOpenRef = useRef<boolean>(false);
const [hasPlaybookFiredOnce, setPlaybookFiredOnce] = useLocalStorage(
'cloudChatPlaybookFiredOnce',
false
);
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
@ -51,9 +70,43 @@ export const useChatConfig = ({
const context = getChatContext();
const { data: message } = event;
const { user: userConfig } = chat;
const { user: userConfig, chatVariant } = chat;
const { id, email, jwt, trialEndDate, kbnVersion, kbnBuildNum } = userConfig;
const chatApi: ChatApi = {
show: () => {
ref.current?.contentWindow?.postMessage(
{
type: `driftShow`,
},
'*'
);
ref.current?.contentWindow?.postMessage(
{
type: `driftOpenChat`,
},
'*'
);
isChatOpenRef.current = true;
},
hide: () => {
ref.current?.contentWindow?.postMessage(
{
type: `driftHide`,
},
'*'
);
isChatOpenRef.current = false;
},
toggle: () => {
if (isChatOpenRef.current) {
chatApi.hide();
} else {
chatApi.show();
}
},
};
switch (message.type) {
// The IFRAME is ready to receive messages.
case MESSAGE_IFRAME_READY: {
@ -64,6 +117,7 @@ export const useChatConfig = ({
trial_end_date: trialEndDate,
kbn_version: kbnVersion,
kbn_build_num: kbnBuildNum,
kbn_chat_variant: chatVariant,
},
jwt,
};
@ -83,7 +137,13 @@ export const useChatConfig = ({
// its interface.
case MESSAGE_RESIZE: {
const styles = message.data.styles || ({} as CSSProperties);
setStyle({ ...style, ...styles });
// camelize to avoid style warnings from react
const camelize = (s: string) => s.replace(/-./g, (x) => x[1].toUpperCase());
const camelStyles = Object.keys(styles).reduce((acc, key) => {
acc[camelize(key)] = styles[key];
return acc;
}, {} as Record<string, string>) as CSSProperties;
setStyle({ ...style, ...camelStyles });
if (!isResized) {
setIsResized(true);
@ -95,8 +155,28 @@ export const useChatConfig = ({
// The chat widget is ready.
case MESSAGE_WIDGET_READY:
if (controlled) chatApi.hide();
setIsReady(true);
onReady();
onReady(chatApi);
if (hasPlaybookFiredOnce) {
// The `MESSAGE_PLAYBOOK_FIRED` event is only fired until the interaction,
// so we need to manually trigger the callback if the event has already fired.
// otherwise, users might have an ongoing conversion, but they can't get back to it
onPlaybookFired();
}
break;
case MESSAGE_CHAT_CLOSED:
if (controlled) chatApi.hide();
break;
case MESSAGE_PLAYBOOK_FIRED:
onPlaybookFired();
setPlaybookFiredOnce(true);
break;
default:
break;
}
@ -105,10 +185,28 @@ export const useChatConfig = ({
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [chat, style, onReady, onResize, isReady, isResized]);
}, [
chat,
style,
onReady,
onResize,
isReady,
isResized,
controlled,
hasPlaybookFiredOnce,
onPlaybookFired,
setPlaybookFiredOnce,
]);
if (chat) {
return { enabled: true, src: chat.chatURL, ref, style, isReady, isResized };
return {
enabled: true,
src: chat.chatURL,
ref,
style,
isReady,
isResized,
};
}
return { enabled: false };

View file

@ -0,0 +1,34 @@
/*
* 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';
export function whenIdle(doWork: () => void) {
const requestIdleCallback = window.requestIdleCallback || window.setTimeout;
if (document.readyState === 'complete') {
requestIdleCallback(() => doWork());
} else {
window.addEventListener('load', () => {
requestIdleCallback(() => doWork());
});
}
}
/**
* Postpone rendering of children until the page is loaded and browser is idle.
*/
export const WhenIdle: React.FC = ({ children }) => {
const [idleFired, setIdleFired] = React.useState(false);
React.useEffect(() => {
whenIdle(() => {
setIdleFired(true);
});
}, []);
return idleFired ? <>{children}</> : null;
};

View file

@ -0,0 +1,40 @@
/*
* 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 { Observable } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import type { ChatVariant } from '../../../common/types';
import { ChatHeaderMenuItem } from '../chat_header_menu_item';
import { Chat as ChatFloatingBubble } from '../chat_floating_bubble';
const BUBBLE_ALLOWED_LOCATIONS = [
'app/home#/getting_started',
'app/enterprise_search/overview',
'app/observability/overview',
'app/security/get_started',
'app/integrations',
];
export function ChatExperimentSwitcher(props: {
location$: Observable<string>;
variant: ChatVariant;
}) {
const location = useObservable(props.location$);
if (!location) return null;
if (props.variant === 'bubble') {
if (BUBBLE_ALLOWED_LOCATIONS.some((loc) => location.includes(loc))) {
return <ChatFloatingBubble />;
} else {
return null;
}
} else {
return <ChatHeaderMenuItem />;
}
}

View file

@ -16,7 +16,7 @@ import {
import { forceReRender } from '@storybook/react';
import { Chat } from './chat';
import { Chat } from './chat_floating_bubble';
import { ServicesProvider } from '../../services';
export default {
@ -80,6 +80,7 @@ export const Component = ({
<ServicesProvider
chat={{
chatURL,
chatVariant: 'bubble',
user: {
jwt,
id,

View file

@ -0,0 +1,101 @@
/*
* 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, { useRef, useState } from 'react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiButtonEmpty } from '@elastic/eui';
import { useChatConfig, ChatApi } from '../chat/use_chat_config';
export type { ChatApi } from '../chat/use_chat_config';
export interface Props {
/** Handler invoked when chat is hidden by someone. */
onHide?: () => void;
/** Handler invoked when the chat widget signals it is ready. */
onReady?: (chatApi: ChatApi) => void;
/** Handler invoked when the chat widget signals to be resized. */
onResize?: () => void;
/** Handler invoked when the playbook is fired. */
onPlaybookFired?: () => void;
}
/**
* This Chat widget implementation uses the default floating chat bubble in the bottom right corner of the screen.
* It automatically appears if the playbook fires and isn't controlled from the outside
*/
export const Chat = ({ onHide = () => {}, onReady, onResize, onPlaybookFired }: Props) => {
const config = useChatConfig({
onReady,
onResize,
onPlaybookFired,
controlled: false /* makes this chat appear automatically */,
});
const ref = useRef<HTMLDivElement>(null);
const [isClosed, setIsClosed] = useState(false);
if (!config.enabled || isClosed) {
return null;
}
const { isReady, isResized, style } = config;
const { right } = style;
const buttonCSS = css`
bottom: ${euiThemeVars.euiSizeXS};
position: fixed;
right: calc(${right} + ${euiThemeVars.euiSizeXS});
visibility: ${isReady && isResized ? 'visible' : 'hidden'};
`;
const button = (
<EuiButtonEmpty
css={buttonCSS}
data-test-subj="cloud-chat-hide"
name="cloudChatHide"
onClick={() => {
onHide();
setIsClosed(true);
}}
size="xs"
>
{i18n.translate('xpack.cloudChat.hideChatButtonLabel', {
defaultMessage: 'Hide chat',
})}
</EuiButtonEmpty>
);
const containerCSS = css`
bottom: ${euiThemeVars.euiSizeXL};
position: fixed;
right: ${euiThemeVars.euiSizeXL};
z-index: ${euiThemeVars.euiZMaskBelowHeader - 1};
&:focus [name='cloudChatHide'],
&:hover [name='cloudChatHide'] {
visibility: visible;
}
`;
return (
<div css={containerCSS} ref={ref} data-test-subj="cloud-chat">
{button}
<iframe
data-test-subj="cloud-chat-frame"
title={i18n.translate('xpack.cloudChat.chatFrameTitle', {
defaultMessage: 'Chat',
})}
src={config.src}
ref={config.ref}
style={{
...config.style,
// @ts-ignore - fixes white background on iframe in chrome/system dark mode
colorScheme: 'light',
}}
/>
</div>
);
};

View 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 React, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
import type { Props } from './chat_floating_bubble';
export type { ChatApi, Props } from './chat_floating_bubble';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() =>
import('./chat_floating_bubble').then(({ Chat }) => ({ default: Chat }))
);
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = (props: Props) => (
<EuiErrorBoundary>
<Suspense fallback={<></>}>
<LazyChat {...props} />
</Suspense>
</EuiErrorBoundary>
);

View file

@ -0,0 +1,109 @@
/*
* 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 {
EuiButtonEmpty,
useEuiTheme,
useIsWithinMinBreakpoint,
EuiTourStep,
EuiText,
EuiIcon,
} from '@elastic/eui';
import ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import chatIconDark from './chat_icon_dark.svg';
import chatIconLight from './chat_icon_light.svg';
import { Chat, ChatApi } from '../chat';
export function ChatHeaderMenuItem() {
const [showChatButton, setChatButtonShow] = React.useState(false);
const [chatApi, setChatApi] = React.useState<ChatApi | null>(null);
const [showTour, setShowTour] = useLocalStorage('cloudChatTour', true);
const { euiTheme, colorMode } = useEuiTheme();
const ref = React.useRef<HTMLButtonElement>(null);
// chat top offset is used to properly position the chat widget
// it can't be static because of an edge case with banners
const [chatTopOffset, setChatTopOffset] = React.useState(0);
React.useEffect(() => {
const chatButton = ref.current;
if (!chatButton) return;
const chatButtonClientRect = chatButton.getBoundingClientRect();
setChatTopOffset(chatButtonClientRect.top + chatButtonClientRect.height - 8);
}, [showChatButton]);
const isLargeScreen = useIsWithinMinBreakpoint('m');
if (!isLargeScreen) return null;
return (
<>
{showChatButton && (
<EuiTourStep
title={
<>
<EuiIcon
type={colorMode === 'DARK' ? chatIconLight : chatIconDark}
size={'l'}
css={{ marginRight: euiTheme.size.s }}
/>
{i18n.translate('xpack.cloudChat.chatTourHeaderText', {
defaultMessage: 'Live Chat Now',
})}
</>
}
content={
<EuiText size={'s'}>
<p>
{i18n.translate('xpack.cloudChat.chatTourText', {
defaultMessage:
'Open chat for assistance with topics such as ingesting data, configuring your instance, and troubleshooting.',
})}
</p>
</EuiText>
}
isStepOpen={showTour}
onFinish={() => setShowTour(false)}
minWidth={300}
maxWidth={360}
step={1}
stepsTotal={1}
anchorPosition="downRight"
>
<EuiButtonEmpty
buttonRef={ref}
css={{ color: euiTheme.colors.ghost, marginRight: euiTheme.size.m }}
size="s"
iconType={chatIconLight}
data-test-subj="cloud-chat"
onClick={() => {
if (showTour) setShowTour(false);
chatApi?.toggle();
}}
>
{i18n.translate('xpack.cloudChat.chatButtonLabel', {
defaultMessage: 'Live Chat',
})}
</EuiButtonEmpty>
</EuiTourStep>
)}
{ReactDOM.createPortal(
<Chat
onReady={(_chatApi) => {
setChatApi(_chatApi);
}}
onPlaybookFired={() => {
setChatButtonShow(true);
}}
topOffset={chatTopOffset}
/>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,5 @@
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.96672 8.8637C5.29876 9.6512 6.05226 10.2009 6.92862 10.2009H10.6021L13.3572 13V10.2009C14.5407 10.2009 15.5001 9.1984 15.5001 7.9617V4.60283C15.5001 3.36613 14.5407 2.36359 13.3572 2.36359H12.2858V6.69704C12.2858 7.89366 11.3903 8.8637 10.2858 8.8637H4.96672Z" fill="black"/>
<path d="M4.90517 6.71774H9.07143C9.66316 6.71774 10.1429 6.21646 10.1429 5.59811V2.23925C10.1429 1.62089 9.66316 1.11962 9.07143 1.11962H2.64286C2.05112 1.11962 1.57143 1.62089 1.57143 2.23925V5.59811C1.57143 6.21646 2.05112 6.71774 2.64286 6.71774H3.71429V8.1696L4.90517 6.71774ZM5.39796 7.83736L2.64286 10.6364V7.83736C1.45939 7.83736 0.5 6.83481 0.5 5.59811V2.23925C0.5 1.00254 1.45939 0 2.64286 0H9.07143C10.2549 0 11.2143 1.00254 11.2143 2.23925V5.59811C11.2143 6.83481 10.2549 7.83736 9.07143 7.83736H5.39796Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.96672 8.8637C5.29876 9.6512 6.05226 10.2009 6.92862 10.2009H10.6021L13.3572 13V10.2009C14.5407 10.2009 15.5001 9.1984 15.5001 7.9617V4.60283C15.5001 3.36613 14.5407 2.36359 13.3572 2.36359H12.2858V6.69704C12.2858 7.89366 11.3903 8.8637 10.2858 8.8637H4.96672Z" fill="white"/>
<path d="M4.90517 6.71774H9.07143C9.66316 6.71774 10.1429 6.21646 10.1429 5.59811V2.23925C10.1429 1.62089 9.66316 1.11962 9.07143 1.11962H2.64286C2.05112 1.11962 1.57143 1.62089 1.57143 2.23925V5.59811C1.57143 6.21646 2.05112 6.71774 2.64286 6.71774H3.71429V8.1696L4.90517 6.71774ZM5.39796 7.83736L2.64286 10.6364V7.83736C1.45939 7.83736 0.5 6.83481 0.5 5.59811V2.23925C0.5 1.00254 1.45939 0 2.64286 0H9.07143C10.2549 0 11.2143 1.00254 11.2143 2.23925V5.59811C11.2143 6.83481 10.2549 7.83736 9.07143 7.83736H5.39796Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

View file

@ -5,8 +5,4 @@
* 2.0.
*/
/**
* A component that will display a trigger that will allow the user to chat with a human operator,
* when the service is enabled; otherwise, it renders nothing.
*/
export { Chat } from './chat';
export { ChatHeaderMenuItem } from './chat_header_menu_items';

View file

@ -5,22 +5,5 @@
* 2.0.
*/
import React, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() => import('./chat').then(({ Chat }) => ({ default: Chat })));
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = () => (
<EuiErrorBoundary>
<Suspense fallback={<div />}>
<LazyChat />
</Suspense>
</EuiErrorBoundary>
);
export { Chat, type ChatApi, type Props as ChatProps } from './chat';
export { ChatHeaderMenuItem } from './chat_header_menu_item';

View file

@ -11,6 +11,3 @@ import { CloudChatPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudChatPlugin(initializerContext);
}
export { Chat } from './components';
export type { CloudChatPluginStart } from './plugin';

View file

@ -64,17 +64,12 @@ describe('Cloud Chat Plugin', () => {
const cloud = cloudMock.createSetup();
const cloudChatProvider = {
registerChatProvider: jest.fn(),
};
plugin.setup(coreSetup, {
cloud: { ...cloud, isCloudEnabled, trialEndDate },
...(securityEnabled ? { security: securitySetup } : {}),
cloudChatProvider,
});
return { initContext, plugin, coreSetup, cloudChatProvider };
return { initContext, plugin, coreSetup };
};
it('chatConfig is not retrieved if cloud is not enabled', async () => {
@ -119,11 +114,6 @@ describe('Cloud Chat Plugin', () => {
});
expect(coreSetup.http.get).toHaveBeenCalled();
});
it('Chat component is registered with chatProvider plugin', async () => {
const { cloudChatProvider } = await setupPlugin({});
expect(cloudChatProvider.registerChatProvider).toBeCalled();
});
});
});
});

View file

@ -6,23 +6,23 @@
*/
import React, { type FC } from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { SecurityPluginSetup } from '@kbn/security-plugin/public';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import { CloudChatProviderPluginSetup } from '@kbn/cloud-chat-provider-plugin/public';
import { ReplaySubject } from 'rxjs';
import type { GetChatUserDataResponseBody } from '../common/types';
import { ReplaySubject, first } from 'rxjs';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { ChatVariant, GetChatUserDataResponseBody } from '../common/types';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../common/constants';
import { ChatConfig, ServicesProvider } from './services';
import { isTodayInDateWindow } from '../common/util';
import { Chat } from './components';
import { ChatExperimentSwitcher } from './components/chat_experiment_switcher';
interface CloudChatSetupDeps {
cloud: CloudSetup;
security?: SecurityPluginSetup;
cloudChatProvider: CloudChatProviderPluginSetup;
}
interface CloudChatStartDeps {
@ -38,16 +38,9 @@ interface CloudChatConfig {
trialBuffer: number;
}
export interface CloudChatPluginStart {
Chat: React.ComponentType;
}
export class CloudChatPlugin
implements Plugin<void, CloudChatPluginStart, CloudChatSetupDeps, CloudChatStartDeps>
{
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, CloudChatStartDeps> {
private readonly config: CloudChatConfig;
private chatConfig$ = new ReplaySubject<ChatConfig>(1);
private Chat: React.ComponentType | undefined;
private kbnVersion: string;
private kbnBuildNum: number;
@ -57,31 +50,44 @@ export class CloudChatPlugin
this.config = initializerContext.config.get();
}
public setup(core: CoreSetup, { cloud, security, cloudChatProvider }: CloudChatSetupDeps) {
this.setupChat({ http: core.http, cloud, security, cloudChatProvider }).catch((e) =>
public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) {
this.setupChat({ http: core.http, cloud, security }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up Chat: ${e.toString()}`)
);
}
public start(core: CoreStart, { cloud }: CloudChatStartDeps) {
const CloudChatContextProvider: FC = ({ children }) => {
// There's a risk that the request for chat config will take too much time to complete, and the provider
// will maintain a stale value. To avoid this, we'll use an Observable.
const chatConfig = useObservable(this.chatConfig$, undefined);
return <ServicesProvider chat={chatConfig}>{children}</ServicesProvider>;
};
cloud.registerCloudService(CloudChatContextProvider);
cloudChatProvider.registerChatProvider(() => this.Chat);
}
function ConnectedChat(props: { chatVariant: ChatVariant }) {
return (
<CloudChatContextProvider>
<KibanaRenderContextProvider theme={core.theme} i18n={core.i18n}>
<ChatExperimentSwitcher
location$={core.application.currentLocation$}
variant={props.chatVariant}
/>
</KibanaRenderContextProvider>
</CloudChatContextProvider>
);
}
public start(core: CoreStart, { cloud }: CloudChatStartDeps) {
const CloudContextProvider = cloud.CloudContextProvider;
this.Chat = () => (
<CloudContextProvider>
<Chat />
</CloudContextProvider>
);
return {
Chat: this.Chat,
};
this.chatConfig$.pipe(first((config) => config != null)).subscribe((config) => {
core.chrome.navControls.registerExtension({
order: 50,
mount: (e) => {
ReactDOM.render(<ConnectedChat chatVariant={config.chatVariant} />, e);
return () => {
ReactDOM.unmountComponentAtNode(e);
};
},
});
});
}
public stop() {}
@ -105,6 +111,7 @@ export class CloudChatPlugin
email,
id,
token: jwt,
chatVariant,
} = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);
if (!email || !id || !jwt) {
@ -113,6 +120,7 @@ export class CloudChatPlugin
this.chatConfig$.next({
chatURL,
chatVariant,
user: {
email,
id,

View file

@ -6,9 +6,11 @@
*/
import React, { FC, createContext, useContext } from 'react';
import type { ChatVariant } from '../../common/types';
export interface ChatConfig {
chatURL: string;
chatVariant: ChatVariant;
user: {
jwt: string;
id: string;

View file

@ -9,15 +9,21 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import { registerChatRoute } from './routes';
import type { CloudChatConfigType } from './config';
import type { ChatVariant } from '../common/types';
interface CloudChatSetupDeps {
cloud: CloudSetup;
security?: SecurityPluginSetup;
}
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps> {
interface CloudChatStartDeps {
cloudExperiments?: CloudExperimentsPluginStart;
}
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, CloudChatStartDeps> {
private readonly config: CloudChatConfigType;
private readonly isDev: boolean;
@ -26,7 +32,7 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps> {
this.isDev = initializerContext.env.mode.dev;
}
public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) {
public setup(core: CoreSetup<CloudChatStartDeps>, { cloud, security }: CloudChatSetupDeps) {
const { chatIdentitySecret, trialBuffer } = this.config;
const { isCloudEnabled, trialEndDate } = cloud;
@ -38,6 +44,27 @@ export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps> {
trialBuffer,
security,
isDev: this.isDev,
getChatVariant: () =>
core.getStartServices().then(([_, { cloudExperiments }]) => {
if (!cloudExperiments) {
return 'header';
} else {
return cloudExperiments
.getVariation<ChatVariant>('cloud-chat.chat-variant', 'header')
.catch(() => 'header');
}
}),
getChatDisabledThroughExperiments: () =>
core.getStartServices().then(([_, { cloudExperiments }]) => {
if (!cloudExperiments) {
return false;
} else {
return cloudExperiments
.getVariation<boolean>('cloud-chat.enabled', true)
.then((enabled) => !enabled)
.catch(() => false);
}
}),
});
}
}

View file

@ -15,11 +15,22 @@ import { httpServiceMock, httpServerMock } from '@kbn/core/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { kibanaResponseFactory } from '@kbn/core/server';
import { registerChatRoute } from './chat';
import { ChatVariant } from '../../common/types';
describe('chat route', () => {
const getChatVariant = async (): Promise<ChatVariant> => 'header';
const getChatDisabledThroughExperiments = async (): Promise<boolean> => false;
test('do not add the route if security is not enabled', async () => {
const router = httpServiceMock.createRouter();
registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60 });
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
getChatVariant,
getChatDisabledThroughExperiments,
});
expect(router.get.mock.calls).toEqual([]);
});
@ -35,6 +46,8 @@ describe('chat route', () => {
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
getChatVariant,
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
@ -70,6 +83,8 @@ describe('chat route', () => {
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 2,
getChatVariant,
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
@ -108,6 +123,8 @@ describe('chat route', () => {
chatIdentitySecret: 'secret',
trialBuffer: 2,
trialEndDate,
getChatVariant,
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
@ -124,6 +141,42 @@ describe('chat route', () => {
`);
});
test('error if disabled in experiments', async () => {
const security = securityMock.createSetup();
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce({
username,
metadata: {
saml_email: [email],
},
});
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
security,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
getChatVariant,
getChatDisabledThroughExperiments: async () => true,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "Chat is disabled through experiments",
},
"payload": "Chat is disabled through experiments",
"status": 400,
}
`);
});
test('returns user information taken from saml metadata and a token', async () => {
const security = securityMock.createSetup();
const username = 'user.name';
@ -144,6 +197,8 @@ describe('chat route', () => {
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
getChatVariant,
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
@ -151,12 +206,14 @@ describe('chat route', () => {
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
@ -181,6 +238,8 @@ describe('chat route', () => {
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
getChatVariant,
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
@ -188,12 +247,60 @@ describe('chat route', () => {
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
test('returns chat variant', async () => {
const security = securityMock.createSetup();
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce({
username,
metadata: {
saml_email: [email],
},
});
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
security,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
getChatVariant: async () => 'bubble',
getChatDisabledThroughExperiments,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "bubble",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "bubble",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",

View file

@ -8,7 +8,7 @@
import { IRouter } from '@kbn/core/server';
import type { SecurityPluginSetup, AuthenticatedUser } from '@kbn/security-plugin/server';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants';
import type { GetChatUserDataResponseBody } from '../../common/types';
import type { GetChatUserDataResponseBody, ChatVariant } from '../../common/types';
import { generateSignedJwt } from '../util/generate_jwt';
import { isTodayInDateWindow } from '../../common/util';
@ -26,6 +26,8 @@ export const registerChatRoute = ({
trialBuffer,
security,
isDev,
getChatVariant,
getChatDisabledThroughExperiments,
}: {
router: IRouter;
chatIdentitySecret: string;
@ -33,6 +35,12 @@ export const registerChatRoute = ({
trialBuffer: number;
security?: SecurityPluginSetup;
isDev: boolean;
getChatVariant: () => Promise<ChatVariant>;
/**
* Returns true if chat is disabled in LaunchDarkly
* Meant to be used as a runtime kill switch
*/
getChatDisabledThroughExperiments: () => Promise<boolean>;
}) => {
if (!security) {
return;
@ -78,11 +86,18 @@ export const registerChatRoute = ({
});
}
if (await getChatDisabledThroughExperiments()) {
return response.badRequest({
body: 'Chat is disabled through experiments',
});
}
const token = generateSignedJwt(userId, chatIdentitySecret);
const body: GetChatUserDataResponseBody = {
token,
email: userEmail,
id: userId,
chatVariant: await getChatVariant(),
};
return response.ok({ body });
}

View file

@ -17,9 +17,10 @@
"@kbn/storybook",
"@kbn/core-http-browser",
"@kbn/i18n",
"@kbn/ui-theme",
"@kbn/config-schema",
"@kbn/cloud-chat-provider-plugin",
"@kbn/ui-theme",
"@kbn/cloud-experiments-plugin",
"@kbn/react-kibana-context-render",
],
"exclude": [
"target/**/*",

View file

@ -1,5 +0,0 @@
# Cloud Chat Provider
This plugin exists as a workaround for using `cloudChat` plugin in plugins which can't have a direct dependency on security plugin.
Ideally we'd remove this plugin and used `cloudChat` directly https://github.com/elastic/kibana/issues/159008

View file

@ -1,18 +0,0 @@
/*
* 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/plugins/cloud_integrations/cloud_chat_provider'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_chat_provider',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_chat_provider/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -1,11 +0,0 @@
{
"type": "plugin",
"id": "@kbn/cloud-chat-provider-plugin",
"owner": "@elastic/kibana-core",
"description": "This plugin exists as a workaround for using `cloudChat` plugin in plugins which can't have a direct dependency on security plugin.",
"plugin": {
"id": "cloudChatProvider",
"server": false,
"browser": true
}
}

View file

@ -1,15 +0,0 @@
/*
* 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 { CloudChatProviderPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudChatProviderPlugin(initializerContext);
}
export type { CloudChatProviderPluginSetup, CloudChatProviderPluginStart } from './plugin';

View file

@ -1,49 +0,0 @@
/*
* 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 type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { PluginInitializerContext } from '@kbn/core/public';
export interface CloudChatProviderPluginSetup {
registerChatProvider: (getChat: () => React.ComponentType | undefined) => void;
}
export interface CloudChatProviderPluginStart {
Chat?: React.ComponentType;
}
export class CloudChatProviderPlugin
implements Plugin<CloudChatProviderPluginSetup, CloudChatProviderPluginStart>
{
private getChat: (() => React.ComponentType | undefined) | undefined;
constructor(initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup) {
return {
registerChatProvider: (getChat: () => React.ComponentType | undefined) => {
if (this.getChat) {
throw new Error('Chat component has already been provided');
}
this.getChat = getChat;
},
};
}
public start(core: CoreStart) {
return {
Chat: () => {
const Chat = this.getChat?.();
return Chat ? <Chat /> : <></>;
},
};
}
public stop() {}
}

View file

@ -1,15 +0,0 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"kbn_references": ["@kbn/core"],
"exclude": ["target/**/*"]
}

View file

@ -27,6 +27,17 @@ export enum FEATURE_FLAG_NAMES {
* defined by type { GuideConfig } from '@kbn/guided-onboarding';
*/
'security-solutions.guided-onboarding-content' = 'security-solutions.guided-onboarding-content',
/**
* Used in cloud chat plugin to enable/disable the chat.
* The expectation that the chat is enabled by default and the flag is used as a runtime kill switch.
*/
'cloud-chat.enabled' = 'cloud-chat.enabled',
/**
* Used in cloud chat plugin to switch between the chat variants.
* Options are: 'header' (the chat button appears as part of the kibana header) and 'bubble' (floating chat button at the bottom of the screen).
*/
'cloud-chat.chat-variant' = 'cloud-chat.chat-variant',
}
/**

View file

@ -35,8 +35,7 @@
"esUiShared",
"fieldFormats",
"uiActions",
"lens",
"cloudChat",
"lens"
]
}
}

View file

@ -29,8 +29,6 @@ import {
processResults,
} from '../../../common/components/utils';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { MODE } from './constants';
export class FileDataVisualizerView extends Component {
@ -353,7 +351,6 @@ export class FileDataVisualizerView extends Component {
/>
</>
)}
<Chat />
</div>
);
}

View file

@ -16,7 +16,6 @@
"@kbn/aiops-components",
"@kbn/aiops-utils",
"@kbn/charts-plugin",
"@kbn/cloud-chat-plugin",
"@kbn/cloud-plugin",
"@kbn/core-execution-context-common",
"@kbn/core",

View file

@ -34,8 +34,7 @@
"usageCollection"
],
"requiredBundles": [
"kibanaReact",
"cloudChat"
"kibanaReact"
]
}
}

View file

@ -17,7 +17,6 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { i18n } from '@kbn/i18n';
import { WelcomeBanner } from '@kbn/search-api-panels';
@ -148,7 +147,6 @@ export const ProductSelector: React.FC = () => {
</EuiFlexItem>
)}
</EuiFlexGroup>
<Chat />
</EuiPageTemplate.Section>
</EnterpriseSearchOverviewPageTemplate>
</>

View file

@ -20,7 +20,6 @@
"@kbn/kibana-react-plugin",
"@kbn/usage-collection-plugin",
"@kbn/cloud-plugin",
"@kbn/cloud-chat-plugin",
"@kbn/features-plugin",
"@kbn/lens-plugin",
"@kbn/licensing-plugin",
@ -58,9 +57,9 @@
"@kbn/core-elasticsearch-server-mocks",
"@kbn/shared-ux-link-redirect-app",
"@kbn/global-search-plugin",
"@kbn/logs-shared-plugin",
"@kbn/share-plugin",
"@kbn/search-api-panels",
"@kbn/search-connectors"
"@kbn/search-connectors",
"@kbn/logs-shared-plugin"
]
}

View file

@ -15,6 +15,7 @@ const applications = new Map();
export const getApplication = () => {
const application: ApplicationStart = {
currentAppId$: of('fleet'),
currentLocation$: of(),
navigateToUrl: async (url: string) => {
action(`Navigate to: ${url}`);
},

View file

@ -39,7 +39,6 @@
],
"requiredBundles": [
"kibanaReact",
"cloudChat",
"esUiShared",
"logsShared",
"kibanaUtils",

View file

@ -17,7 +17,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { KibanaContextProvider, RedirectAppLinks } from '@kbn/kibana-react-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
@ -104,7 +103,6 @@ export const IntegrationsAppContext: React.FC<{
<FlyoutContextProvider>
<IntegrationsHeader {...{ setHeaderActionMenu, theme$ }} />
{children}
<Chat />
</FlyoutContextProvider>
</PackageInstallProvider>
</AgentPolicyContextProvider>

View file

@ -39,7 +39,6 @@
"@kbn/home-plugin",
// requiredBundles from ./kibana.json
"@kbn/cloud-chat-plugin",
"@kbn/kibana-react-plugin",
"@kbn/es-ui-shared-plugin",
"@kbn/logs-shared-plugin",

View file

@ -34,7 +34,7 @@
"dashboard",
],
"optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"],
"requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "stackAlerts", "spaces"],
"requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "stackAlerts", "spaces"],
"extraPublicDirs": ["common"]
}
}

View file

@ -6,7 +6,6 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { BoolQuery } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useBreadcrumbs, useFetcher } from '@kbn/observability-shared-plugin/public';
@ -22,7 +21,6 @@ import { usePluginContext } from '../../hooks/use_plugin_context';
import { useTimeBuckets } from '../../hooks/use_time_buckets';
import { getAlertSummaryTimeRange } from '../../utils/alert_summary_widget';
import { buildEsQuery } from '../../utils/build_es_query';
import { useKibana } from '../../utils/kibana_react';
import { DataAssistantFlyout } from './components/data_assistant_flyout';
import { DataSections } from './components/data_sections';
import { HeaderActions } from './components/header_actions/header_actions';
@ -34,6 +32,7 @@ import { Resources } from './components/resources';
import { EmptySections } from './components/sections/empty/empty_sections';
import { SectionContainer } from './components/sections/section_container';
import { calculateBucketSize } from './helpers/calculate_bucket_size';
import { useKibana } from '../../utils/kibana_react';
const ALERTS_PER_PAGE = 10;
const ALERTS_TABLE_ID = 'xpack.observability.overview.alert.table';
@ -227,8 +226,6 @@ export function OverviewPage() {
{isDataAssistantFlyoutVisible ? (
<DataAssistantFlyout onClose={() => setIsDataAssistantFlyoutVisible(false)} />
) : null}
<Chat />
</ObservabilityPageTemplate>
);
}

View file

@ -76,7 +76,6 @@
"@kbn/field-types",
"@kbn/safer-lodash-set",
"@kbn/core-http-server",
"@kbn/cloud-chat-plugin",
"@kbn/cloud-plugin",
"@kbn/stack-alerts-plugin",
"@kbn/data-view-editor-plugin",

View file

@ -78,8 +78,7 @@
"usageCollection",
"lists",
"ml",
"unifiedSearch",
"cloudChat"
"unifiedSearch"
],
"extraPublicDirs": [
"common"

View file

@ -6,7 +6,6 @@
*/
import React, { memo } from 'react';
import { Chat } from '@kbn/cloud-chat-plugin/public';
import { css } from '@emotion/react';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { SecurityPageName } from '../../../common/constants';
@ -24,7 +23,6 @@ export const LandingPage = memo(() => {
>
<SecuritySolutionPageWrapper noPadding>
<LandingPageComponent />
<Chat />
<SpyRoute pageName={SecurityPageName.landing} />
</SecuritySolutionPageWrapper>
</div>

View file

@ -158,7 +158,6 @@
"@kbn/field-formats-plugin",
"@kbn/expressions-plugin",
"@kbn/dev-proc-runner",
"@kbn/cloud-chat-plugin",
"@kbn/alerts-ui-shared",
"@kbn/text-based-editor",
"@kbn/security-solution-navigation",

View file

@ -16,6 +16,7 @@ export const getDefaultServicesApplication = (
const applications = new Map();
return {
currentLocation$: of(),
currentAppId$: of('fleet'),
navigateToUrl: async (url: string) => {
action(`Navigate to: ${url}`);

View file

@ -11,45 +11,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
// TODO: the whole suite doesn't run on ci https://github.com/elastic/kibana/issues/159401
describe('Cloud Chat integration', function () {
before(async () => {
// Create role mapping so user gets superuser access
await getService('esSupertest')
.post('/_security/role_mapping/saml1')
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
.expect(200);
});
it('chat widget is present in header', async () => {
await PageObjects.common.navigateToUrl('home');
it('chat widget is present on integrations page', async () => {
await PageObjects.common.navigateToUrl('integrations', 'browse', {
useActualUrl: true,
shouldUseHashForSubUrl: false,
});
// button is visible
await testSubjects.existOrFail('cloud-chat');
});
it('chat widget is present on home getting_started page', async () => {
await PageObjects.common.navigateToUrl('home', '/getting_started', {
useActualUrl: true,
shouldUseHashForSubUrl: true,
// the chat widget is not visible (but in DOM)
await testSubjects.missingOrFail('cloud-chat-frame', {
allowHidden: true,
});
await testSubjects.existOrFail('cloud-chat');
});
it('chat widget is present on observability/overview page', async () => {
await PageObjects.common.navigateToUrl('observability', '/overview', {
useActualUrl: true,
shouldUseHashForSubUrl: true,
});
await testSubjects.existOrFail('cloud-chat');
});
it('chat widget is present on security/get_started page', async () => {
await PageObjects.common.navigateToUrl('security', '/get_started', {
useActualUrl: true,
shouldUseHashForSubUrl: true,
});
await testSubjects.existOrFail('cloud-chat');
await testSubjects.click('cloud-chat');
await testSubjects.existOrFail('cloud-chat-frame');
});
});
}

View file

@ -3203,10 +3203,6 @@
version "0.0.0"
uid ""
"@kbn/cloud-chat-provider-plugin@link:x-pack/plugins/cloud_integrations/cloud_chat_provider":
version "0.0.0"
uid ""
"@kbn/cloud-data-migration-plugin@link:x-pack/plugins/cloud_integrations/cloud_data_migration":
version "0.0.0"
uid ""