mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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   #### The tour on the first appearance  #### (Part of A/B test) Drift in the header on new pages and as floating action button on old pages0386ccbd
-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:
parent
924664fc79
commit
07f1c36df7
67 changed files with 764 additions and 358 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,7 +12,6 @@ pageLoadAssetSize:
|
|||
charts: 55000
|
||||
cloud: 21076
|
||||
cloudChat: 19894
|
||||
cloudChatProvider: 17114
|
||||
cloudDataMigration: 19170
|
||||
cloudDefend: 18697
|
||||
cloudExperiments: 59358
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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' })
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -5,8 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export type ChatVariant = 'header' | 'bubble';
|
||||
|
||||
export interface GetChatUserDataResponseBody {
|
||||
token: string;
|
||||
email: string;
|
||||
id: string;
|
||||
chatVariant: ChatVariant;
|
||||
}
|
||||
|
|
|
@ -13,11 +13,13 @@
|
|||
"chat"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"cloud",
|
||||
"cloudChatProvider"
|
||||
"cloud"
|
||||
],
|
||||
"requiredBundles": [
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"security"
|
||||
"security",
|
||||
"cloudExperiments"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 />;
|
||||
}
|
||||
}
|
|
@ -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,
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -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';
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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
|
|
@ -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}',
|
||||
],
|
||||
};
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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() {}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
".storybook/**/*",
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"server/**/*",
|
||||
"../../../typings/**/*"
|
||||
],
|
||||
"kbn_references": ["@kbn/core"],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,8 +35,7 @@
|
|||
"esUiShared",
|
||||
"fieldFormats",
|
||||
"uiActions",
|
||||
"lens",
|
||||
"cloudChat",
|
||||
"lens"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -34,8 +34,7 @@
|
|||
"usageCollection"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"cloudChat"
|
||||
"kibanaReact"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"cloudChat",
|
||||
"esUiShared",
|
||||
"logsShared",
|
||||
"kibanaUtils",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -78,8 +78,7 @@
|
|||
"usageCollection",
|
||||
"lists",
|
||||
"ml",
|
||||
"unifiedSearch",
|
||||
"cloudChat"
|
||||
"unifiedSearch"
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
"common"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue