From 575e80bcccf0b74af17f3cb635736a1968015b1a Mon Sep 17 00:00:00 2001
From: Rodney Norris
Date: Wed, 18 Jun 2025 14:58:57 -0500
Subject: [PATCH] [Search][Playground] View Saved Playground (#223062)
## Summary
This PR implements the frontend for opening a Saved Playground. As a
part of that there are several refactors to the current playground that
warrant regression testing.
### Testing
To test the saved playground view the search mode feature flag should be
enabled, either with a config override or via console:
```
POST kbn:/internal/kibana/settings/searchPlayground:searchModeEnabled
{"value": true}
```
Then you will need to manually save a playground:
```
curl -X "PUT" "http://localhost:5601/internal/search_playground/playgrounds" \
-H 'elastic-api-version: 1' \
-H 'kbn-xsrf: dev' \
-H 'x-elastic-internal-origin: Kibana' \
-H 'Content-Type: application/json; charset=utf-8' \
-u 'elastic_serverless:' \
-d $'{
"elasticsearchQueryJSON": "{\\"retriever\\":{\\"standard\\":{\\"query\\":{\\"semantic\\":{\\"field\\":\\"text\\",\\"query\\":\\"{query}\\"}}}},\\"highlight\\":{\\"fields\\":{\\"text\\":{\\"type\\":\\"semantic\\",\\"number_of_fragments\\":2,\\"order\\":\\"score\\"}}}}",
"indices": [
"search-test"
],
"name": "Test playground",
"queryFields": {
"search-test": [
"text"
]
}
}'
```
*Note this creates a saved playground in the Default space, and
playgrounds are space aware so it will only be available in the default
space. If you want to create a playground in another space you will need
to update this URL to include the space.
This assumes you have a `search-test` index created using the
semantic_text onboarding workflow mapping.
Then you can open the saved playground page at:
`/app/search_playground/p/`
## Screenshots
Chat

Chat - Query

Search - Query

### Checklist
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.buildkite/ftr_search_stateful_configs.yml | 2 +-
.../public/navigation_tree.ts | 1 +
.../plugins/search_playground/common/index.ts | 3 +
.../search_playground/common/prompt.ts | 2 +
.../search_playground/public/application.tsx | 5 +-
.../public/components/chat.tsx | 23 +-
.../public/components/field_error_callout.tsx | 39 +++
.../public/components/header.test.tsx | 27 +-
.../public/components/header.tsx | 47 +++-
.../public/components/not_found.tsx | 2 +-
.../public/components/playgorund.tsx | 8 +-
.../components/query_mode/chat_prompt.tsx | 6 +-
.../query_mode/query_side_panel.tsx | 9 +-
.../components/query_mode/query_viewer.tsx | 66 ++---
.../components/query_mode/search_query.tsx | 8 +-
.../query_mode/search_query_mode.tsx | 9 +-
.../saved_playground/saved_playground.tsx | 167 +++++++++++
.../saved_playground_fetch_error.tsx | 46 +++
.../summarization_panel.tsx | 2 -
.../view_code/examples/dev_tools.tsx | 8 +-
.../examples/py_lang_client.test.tsx | 5 +-
.../view_code/examples/py_lang_client.tsx | 11 +-
.../examples/py_langchain_python.test.tsx | 12 +-
.../examples/py_langchain_python.tsx | 9 +-
.../components/view_code/view_code_flyout.tsx | 15 +-
.../public/hooks/use_elasticsearch_query.ts | 8 +-
.../public/hooks/use_llms_models.test.ts | 150 ----------
.../public/hooks/use_llms_models.test.tsx | 181 ++++++++++++
.../public/hooks/use_llms_models.ts | 111 ++++----
.../public/hooks/use_load_connectors.ts | 171 ++----------
.../hooks/use_playground_breadcrumbs.ts | 47 ++--
.../hooks/use_saved_playground_parameters.ts | 18 ++
.../public/hooks/use_search_preview.ts | 7 +-
...se_page_mode.ts => use_show_setup_page.ts} | 20 +-
.../hooks/use_user_query_validations.ts | 35 ---
.../use_validate_playground_view_modes.ts | 28 ++
.../public/layout/page_template.tsx | 39 +++
.../public/playground_overview.tsx | 26 +-
.../public/playground_router.tsx | 5 +
.../providers/saved_playground_provider.tsx | 86 ++++++
.../providers/unsaved_form_provider.test.tsx | 4 -
.../providers/unsaved_form_provider.tsx | 35 +--
.../search_playground/public/routes.ts | 6 +
.../public/saved_playground.tsx | 24 ++
.../plugins/search_playground/public/types.ts | 26 +-
.../utils/playground_connectors.test.ts | 225 +++++++++++++++
.../public/utils/playground_connectors.ts | 146 ++++++++++
.../utils/playground_form_resolver.test.ts | 264 ++++++++++++++++++
.../public/utils/playground_form_resolver.ts | 64 +++++
.../public/utils/saved_playgrounds.ts | 89 ++++++
.../public/utils/user_query.test.ts | 126 +++------
.../public/utils/user_query.ts | 93 +++---
.../playground_saved_object/schema/v1/v1.ts | 2 +-
.../plugins/search_playground/tsconfig.json | 1 -
.../public/navigation_tree.ts | 1 +
.../search_playground/saved_playgrounds.ts | 123 ++++++++
.../utils/create_playground.ts | 71 +++++
.../apps/shared/solution_tour.ts | 29 ++
.../config/config.feature_flags.ts | 38 +++
.../ftr_provider_context.d.ts | 13 +
.../functional_search/index.feature_flags.ts | 18 ++
x-pack/solutions/search/test/tsconfig.json | 3 +
.../page_objects/search_playground_page.ts | 53 +++-
.../test_suites/search/index.feature_flags.ts | 1 +
.../search_playground/saved_playgrounds.ts | 136 +++++++++
.../utils/create_playground.ts | 67 +++++
66 files changed, 2385 insertions(+), 737 deletions(-)
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/field_error_callout.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx
delete mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts
rename x-pack/solutions/search/plugins/search_playground/public/hooks/{use_page_mode.ts => use_show_setup_page.ts} (62%)
delete mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts
create mode 100644 x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts
create mode 100644 x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts
create mode 100644 x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts
create mode 100644 x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts
create mode 100644 x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts
create mode 100644 x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts
create mode 100644 x-pack/solutions/search/test/functional_search/index.feature_flags.ts
create mode 100644 x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts
create mode 100644 x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts
diff --git a/.buildkite/ftr_search_stateful_configs.yml b/.buildkite/ftr_search_stateful_configs.yml
index 84036c73c896..a5647a88c4e5 100644
--- a/.buildkite/ftr_search_stateful_configs.yml
+++ b/.buildkite/ftr_search_stateful_configs.yml
@@ -6,6 +6,6 @@ defaultQueue: 'n2-4-spot'
enabled:
- x-pack/test/functional_search/config.ts
- x-pack/test/functional/apps/search_playground/config.ts
+ - x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts
- x-pack/solutions/search/test/api_integration/apis/search_playground/config.ts
- x-pack/solutions/search/test/api_integration/apis/guided_onboarding/config.ts
-
diff --git a/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts b/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts
index c090202aa8af..80e81dee7945 100644
--- a/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts
+++ b/x-pack/solutions/search/plugins/enterprise_search/public/navigation_tree.ts
@@ -142,6 +142,7 @@ export const getNavigationTreeDefinition = ({
}),
},
{
+ breadcrumbStatus: 'hidden',
link: 'searchPlayground',
},
{
diff --git a/x-pack/solutions/search/plugins/search_playground/common/index.ts b/x-pack/solutions/search/plugins/search_playground/common/index.ts
index b765f4b151cc..3fd14848e9f8 100644
--- a/x-pack/solutions/search/plugins/search_playground/common/index.ts
+++ b/x-pack/solutions/search/plugins/search_playground/common/index.ts
@@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { Pagination } from './types';
+export { APIRoutes, type PlaygroundSavedObject } from './types';
export const PLUGIN_ID = 'searchPlayground';
export const PLUGIN_NAME = i18n.translate('xpack.searchPlayground.plugin.name', {
@@ -34,3 +35,5 @@ export enum ROUTE_VERSIONS {
}
export const PLAYGROUND_SAVED_OBJECT_TYPE = 'search_playground';
+
+export const DEFAULT_CONTEXT_DOCUMENTS = 3;
diff --git a/x-pack/solutions/search/plugins/search_playground/common/prompt.ts b/x-pack/solutions/search/plugins/search_playground/common/prompt.ts
index 3df5b63b6f36..17af9bef59f4 100644
--- a/x-pack/solutions/search/plugins/search_playground/common/prompt.ts
+++ b/x-pack/solutions/search/plugins/search_playground/common/prompt.ts
@@ -102,3 +102,5 @@ export const QuestionRewritePrompt = (options: QuestionRewritePromptOptions): st
gemini: GeminiPrompt,
}[options.type || 'openai'](systemInstructions, true);
};
+
+export const DEFAULT_LLM_PROMPT = 'You are an assistant for question-answering tasks.';
diff --git a/x-pack/solutions/search/plugins/search_playground/public/application.tsx b/x-pack/solutions/search/plugins/search_playground/public/application.tsx
index 1e356cb9539e..f20d0228f796 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/application.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/application.tsx
@@ -9,7 +9,6 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { CoreStart } from '@kbn/core/public';
-import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { Router } from '@kbn/shared-ux-router';
@@ -24,7 +23,7 @@ export const renderApp = async (
const { PlaygroundRouter } = await import('./playground_router');
ReactDOM.render(
-
+ core.rendering.addContext(
@@ -34,7 +33,7 @@ export const renderApp = async (
- ,
+ ),
element
);
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx
index 093e3eaee547..cb7add21deec 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/chat.tsx
@@ -6,7 +6,7 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
-import { Controller, useFormContext } from 'react-hook-form';
+import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
import {
EuiButtonEmpty,
EuiButtonIcon,
@@ -38,17 +38,20 @@ import { useUsageTracker } from '../hooks/use_usage_tracker';
import { PlaygroundBodySection } from './playground_body_section';
import { elasticsearchQueryString } from '../utils/user_query';
-const buildFormData = (formData: PlaygroundForm): ChatRequestData => ({
- connector_id: formData[PlaygroundFormFields.summarizationModel].connectorId!,
+const buildFormData = (
+ formData: PlaygroundForm,
+ formErrors: FieldErrors
+): ChatRequestData => ({
+ connector_id: formData[PlaygroundFormFields.summarizationModel]!.connectorId!,
prompt: formData[PlaygroundFormFields.prompt],
indices: formData[PlaygroundFormFields.indices].join(),
citations: formData[PlaygroundFormFields.citations],
elasticsearch_query: elasticsearchQueryString(
formData[PlaygroundFormFields.elasticsearchQuery],
formData[PlaygroundFormFields.userElasticsearchQuery],
- formData[PlaygroundFormFields.userElasticsearchQueryValidations]
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
),
- summarization_model: formData[PlaygroundFormFields.summarizationModel].value,
+ summarization_model: formData[PlaygroundFormFields.summarizationModel]!.value,
source_fields: JSON.stringify(formData[PlaygroundFormFields.sourceFields]),
doc_size: formData[PlaygroundFormFields.docSize],
});
@@ -57,7 +60,7 @@ export const Chat = () => {
const { euiTheme } = useEuiTheme();
const {
control,
- formState: { isValid, isSubmitting },
+ formState: { isValid, isSubmitting, errors: formErrors },
resetField,
handleSubmit,
getValues,
@@ -70,7 +73,7 @@ export const Chat = () => {
await append(
{ content: data.question, role: MessageRole.user, createdAt: new Date() },
{
- data: buildFormData(data),
+ data: buildFormData(data, formErrors),
}
);
usageTracker?.click(AnalyticsEvents.chatQuestionSent);
@@ -102,7 +105,7 @@ export const Chat = () => {
setIsRegenerating(true);
const formData = getValues();
await reload({
- data: buildFormData(formData),
+ data: buildFormData(formData, formErrors),
});
setIsRegenerating(false);
@@ -193,10 +196,6 @@ export const Chat = () => {
name={PlaygroundFormFields.question}
control={control}
defaultValue=""
- rules={{
- required: true,
- validate: (rule) => !!rule?.trim(),
- }}
render={({ field }) => (
{
+ return (
+
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx
index c548ec707298..d59bd9184621 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/header.test.tsx
@@ -16,14 +16,9 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { EuiForm } from '@elastic/eui';
import { FormProvider, useForm } from 'react-hook-form';
-const mockUsePlaygroundParameters = jest.fn();
-
jest.mock('../hooks/use_source_indices_field', () => ({
useSourceIndicesFields: () => ({}),
}));
-jest.mock('../hooks/use_playground_parameters', () => ({
- usePlaygroundParameters: () => mockUsePlaygroundParameters(),
-}));
const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
const methods = useForm({
@@ -68,14 +63,15 @@ const MockPlaygroundForm = ({
describe('Header', () => {
it('renders correctly', () => {
- mockUsePlaygroundParameters.mockReturnValue({
- pageMode: PlaygroundPageMode.chat,
- viewMode: PlaygroundViewMode.preview,
- });
render(
{}}>
- {}} onSelectPageModeChange={() => {}} />
+ {}}
+ onSelectPageModeChange={() => {}}
+ />
);
@@ -85,14 +81,15 @@ describe('Header', () => {
});
it('renders correctly with preview mode', () => {
- mockUsePlaygroundParameters.mockReturnValue({
- pageMode: PlaygroundPageMode.search,
- viewMode: PlaygroundViewMode.preview,
- });
render(
{}}>
- {}} onSelectPageModeChange={() => {}} />
+ {}}
+ onSelectPageModeChange={() => {}}
+ />
);
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx
index 7faae9ecc8f3..d230993c64e4 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/header.tsx
@@ -7,6 +7,7 @@
import React from 'react';
import {
+ EuiBadge,
EuiButtonGroup,
EuiFlexGroup,
EuiPageHeaderSection,
@@ -23,22 +24,28 @@ import { PlaygroundHeaderDocs } from './playground_header_docs';
import { Toolbar } from './toolbar';
import { PlaygroundPageMode, PlaygroundViewMode } from '../types';
import { useSearchPlaygroundFeatureFlag } from '../hooks/use_search_playground_feature_flag';
-import { usePlaygroundParameters } from '../hooks/use_playground_parameters';
interface HeaderProps {
+ pageMode: PlaygroundPageMode;
+ viewMode: PlaygroundViewMode;
showDocs?: boolean;
onModeChange: (mode: PlaygroundViewMode) => void;
onSelectPageModeChange: (mode: PlaygroundPageMode) => void;
isActionsDisabled?: boolean;
+ playgroundName?: string;
+ hasChanges?: boolean;
}
export const Header: React.FC = ({
+ pageMode,
+ viewMode,
onModeChange,
showDocs = false,
isActionsDisabled = false,
onSelectPageModeChange,
+ playgroundName,
+ hasChanges,
}) => {
- const { pageMode, viewMode } = usePlaygroundParameters();
const isSearchModeEnabled = useSearchPlaygroundFeatureFlag();
const { euiTheme } = useEuiTheme();
const options: Array = [
@@ -74,15 +81,25 @@ export const Header: React.FC = ({
>
-
-
-
-
-
+ {playgroundName === undefined ? (
+
+
+
+
+
+ ) : (
+
+ {playgroundName}
+
+ )}
+
{isSearchModeEnabled && (
= ({
onChange={(e) => onSelectPageModeChange(e.target.value as PlaygroundPageMode)}
/>
)}
+ {isSearchModeEnabled && playgroundName !== undefined && hasChanges ? (
+
+
+
+ ) : null}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx
index 278ac0807b6c..ad03d987d1c9 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/not_found.tsx
@@ -52,7 +52,7 @@ export const PlaygroundRouteNotFound = () => {
}
actions={
-
+
{i18n.translate('xpack.searchPlayground.notFound.action1', {
defaultMessage: 'Back to Playground',
})}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx
index 0e4723381e58..579edd4788fa 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/playgorund.tsx
@@ -24,7 +24,8 @@ import {
import { Chat } from './chat';
import { SearchMode } from './search_mode/search_mode';
import { SearchPlaygroundSetupPage } from './setup_page/search_playground_setup_page';
-import { usePageMode } from '../hooks/use_page_mode';
+import { useShowSetupPage } from '../hooks/use_show_setup_page';
+import { useValidatePlaygroundViewModes } from '../hooks/use_validate_playground_view_modes';
import { useKibana } from '../hooks/use_kibana';
import { usePlaygroundParameters } from '../hooks/use_playground_parameters';
import { useSearchPlaygroundFeatureFlag } from '../hooks/use_search_playground_feature_flag';
@@ -40,6 +41,7 @@ export interface AppProps {
}
export const Playground: React.FC = ({ showDocs = false }) => {
+ useValidatePlaygroundViewModes();
const isSearchModeEnabled = useSearchPlaygroundFeatureFlag();
const location = useLocation();
const { pageMode, viewMode } = usePlaygroundParameters();
@@ -66,7 +68,7 @@ export const Playground: React.FC = ({ showDocs = false }) => {
navigateToView(pageMode, id, location.search);
const handlePageModeChange = (mode: PlaygroundPageMode) =>
navigateToView(mode, viewMode, location.search);
- const { showSetupPage } = usePageMode({
+ const { showSetupPage } = useShowSetupPage({
hasSelectedIndices,
hasConnectors: Boolean(connectors?.length),
});
@@ -74,6 +76,8 @@ export const Playground: React.FC = ({ showDocs = false }) => {
return (
<>
{
({
name: PlaygroundFormFields.userElasticsearchQuery,
});
- const {
- field: { value: userElasticsearchQueryValidations },
- } = useController({
- name: PlaygroundFormFields.userElasticsearchQueryValidations,
- });
const handleSearch = useCallback(
(e: React.FormEvent) => {
@@ -134,7 +129,7 @@ export const QuerySidePanel = ({
indexFields={group}
updateFields={updateFields}
queryFields={queryFields}
- customizedQuery={userElasticsearchQueryValidations?.isUserCustomized ?? false}
+ customizedQuery={userElasticsearchQuery !== null}
/>
))}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx
index 41932ed2d6ed..63e3dc9e49d2 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/query_viewer.tsx
@@ -10,7 +10,6 @@ import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
- EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
@@ -22,12 +21,13 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { CodeEditor } from '@kbn/code-editor';
import { monaco as monacoEditor } from '@kbn/monaco';
-import { Controller, useController, useFormContext } from 'react-hook-form';
+import { useController, useFormContext } from 'react-hook-form';
import { AnalyticsEvents } from '../../analytics/constants';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { PlaygroundForm, PlaygroundFormFields } from '../../types';
import { FullHeight, QueryViewTitlePanel, PanelFillContainer } from './styles';
import { formatElasticsearchQueryString } from '../../utils/user_query';
+import { FieldErrorCallout } from '../field_error_callout';
export const ElasticsearchQueryViewer = ({
executeQuery,
@@ -41,7 +41,7 @@ export const ElasticsearchQueryViewer = ({
const { euiTheme } = useEuiTheme();
const usageTracker = useUsageTracker();
const [esQueryFirstEdit, setEsQueryFirtEdit] = useState(false);
- const { control } = useFormContext();
+ const { trigger } = useFormContext();
const {
field: { value: elasticsearchQuery },
} = useController({
@@ -49,14 +49,10 @@ export const ElasticsearchQueryViewer = ({
});
const {
field: { value: userElasticsearchQuery, onChange: onChangeUserQuery },
+ fieldState: { error: userElasticsearchQueryError },
} = useController({
name: PlaygroundFormFields.userElasticsearchQuery,
});
- const {
- field: { value: userElasticsearchQueryValidations },
- } = useController({
- name: PlaygroundFormFields.userElasticsearchQueryValidations,
- });
const generatedEsQuery = useMemo(
() => formatElasticsearchQueryString(elasticsearchQuery),
[elasticsearchQuery]
@@ -76,10 +72,12 @@ export const ElasticsearchQueryViewer = ({
setEsQueryFirtEdit(true);
usageTracker?.count(AnalyticsEvents.editElasticsearchQuery);
}
- onChangeUserQuery(value);
+ onChangeUserQuery(value === generatedEsQuery ? null : value);
+ trigger(PlaygroundFormFields.userElasticsearchQuery);
},
- [esQueryFirstEdit, generatedEsQuery, usageTracker, onChangeUserQuery]
+ [esQueryFirstEdit, generatedEsQuery, usageTracker, onChangeUserQuery, trigger]
);
+ const userElasticsearchQueryIsCustomized = userElasticsearchQuery !== null;
return (
@@ -95,7 +93,7 @@ export const ElasticsearchQueryViewer = ({
/>
- {userElasticsearchQueryValidations?.isUserCustomized ? (
+ {userElasticsearchQueryIsCustomized ? (
- {userElasticsearchQueryValidations?.isUserCustomized ? (
+ {userElasticsearchQuery !== null ? (
- {userElasticsearchQueryValidations?.isUserCustomized &&
- (userElasticsearchQueryValidations?.userQueryErrors?.length ?? 0) > 0 ? (
+ {userElasticsearchQueryIsCustomized && userElasticsearchQueryError !== undefined ? (
- {userElasticsearchQueryValidations.userQueryErrors!.map((error, errorIndex) => (
-
- ))}
+
) : null}
- (
-
- )}
+
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx
index a7ce1fd2d1d3..dd26f4dec566 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/query_mode/search_query.tsx
@@ -15,7 +15,6 @@ import { PlaygroundForm, PlaygroundFormFields } from '../../types';
export const SearchQuery = ({ isLoading }: { isLoading: boolean }) => {
const { control } = useFormContext();
const {
- field: { value: searchBarValue },
formState: { isSubmitting },
} = useController({
name: PlaygroundFormFields.searchQuery,
@@ -29,8 +28,11 @@ export const SearchQuery = ({ isLoading }: { isLoading: boolean }) => {
({
- name: PlaygroundFormFields.userElasticsearchQueryValidations,
+ field: { value: userElasticsearchQuery },
+ fieldState: { invalid: userElasticsearchQueryInvalid },
+ } = useController({
+ name: PlaygroundFormFields.userElasticsearchQuery,
});
const executeQueryDisabled = disableExecuteQuery(
- userElasticsearchQueryValidations,
+ userElasticsearchQuery === null || !userElasticsearchQueryInvalid,
pageMode === PlaygroundPageMode.chat ? question : searchQuery
);
const isLoading = fetchStatus !== 'idle';
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx
new file mode 100644
index 000000000000..609e2de3b1d2
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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, { useCallback, useEffect } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui';
+import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
+import { Route, Routes } from '@kbn/shared-ux-router';
+
+import { PLUGIN_ID } from '../../../common';
+import { useKibana } from '../../hooks/use_kibana';
+import { useLLMsModels } from '../../hooks/use_llms_models';
+import { useLoadConnectors } from '../../hooks/use_load_connectors';
+import { usePlaygroundBreadcrumbs } from '../../hooks/use_playground_breadcrumbs';
+import { useSavedPlaygroundParameters } from '../../hooks/use_saved_playground_parameters';
+import { useShowSetupPage } from '../../hooks/use_show_setup_page';
+import {
+ PlaygroundPageMode,
+ PlaygroundViewMode,
+ SavedPlaygroundForm,
+ SavedPlaygroundFormFields,
+} from '../../types';
+import { Header } from '../header';
+import {
+ SAVED_PLAYGROUND_CHAT_PATH,
+ SAVED_PLAYGROUND_CHAT_QUERY_PATH,
+ SAVED_PLAYGROUND_SEARCH_PATH,
+ SAVED_PLAYGROUND_SEARCH_QUERY_PATH,
+} from '../../routes';
+import { Chat } from '../chat';
+import { ChatSetupPage } from '../setup_page/chat_setup_page';
+import { SearchMode } from '../search_mode/search_mode';
+import { SearchQueryMode } from '../query_mode/search_query_mode';
+
+import { SavedPlaygroundFetchError } from './saved_playground_fetch_error';
+
+export const SavedPlayground = () => {
+ const models = useLLMsModels();
+ const { playgroundId, pageMode, viewMode } = useSavedPlaygroundParameters();
+ const { application } = useKibana().services;
+ const { data: connectors } = useLoadConnectors();
+ // TODO: need to handle errors from Form (loading errors, indices no longer exist etc.)
+ const { formState, watch, setValue } = useFormContext();
+ const playgroundName = watch(SavedPlaygroundFormFields.name);
+ const playgroundIndices = watch(SavedPlaygroundFormFields.indices);
+ const summarizationModel = watch(SavedPlaygroundFormFields.summarizationModel);
+ usePlaygroundBreadcrumbs(playgroundName);
+ const { showSetupPage } = useShowSetupPage({
+ hasSelectedIndices: true, // Saved Playgrounds always have indices ? (at least to be saved)
+ hasConnectors: Boolean(connectors?.length),
+ });
+ const navigateToView = useCallback(
+ (page: PlaygroundPageMode, view?: PlaygroundViewMode, searchParams?: string) => {
+ let path = `/p/${playgroundId}/${page}`;
+ if (view && view !== PlaygroundViewMode.preview) {
+ path += `/${view}`;
+ }
+ if (searchParams) {
+ path += searchParams;
+ }
+ application.navigateToApp(PLUGIN_ID, {
+ path,
+ });
+ },
+ [application, playgroundId]
+ );
+ useEffect(() => {
+ if (formState.isLoading) return;
+ if (pageMode === undefined) {
+ // If there is not a pageMode set we redirect based on if there is a model set in the
+ // saved playground as a best guess for default mode. until we save mode with the playground
+ navigateToView(
+ summarizationModel !== undefined ? PlaygroundPageMode.Chat : PlaygroundPageMode.Search,
+ PlaygroundViewMode.preview
+ );
+ return;
+ }
+ // Handle Unknown modes
+ if (!Object.values(PlaygroundPageMode).includes(pageMode)) {
+ navigateToView(
+ summarizationModel !== undefined ? PlaygroundPageMode.Chat : PlaygroundPageMode.Search,
+ PlaygroundViewMode.preview
+ );
+ return;
+ }
+ if (!Object.values(PlaygroundViewMode).includes(viewMode)) {
+ navigateToView(pageMode, PlaygroundViewMode.preview);
+ }
+ }, [pageMode, viewMode, summarizationModel, formState.isLoading, navigateToView]);
+ useEffect(() => {
+ // When opening chat mode without a model selected try to select a default model
+ // if one is available.
+ if (formState.isLoading) return;
+ if (
+ pageMode === PlaygroundPageMode.Chat &&
+ summarizationModel === undefined &&
+ models.length > 0
+ ) {
+ const defaultModel = models.find((model) => !model.disabled);
+ if (defaultModel) {
+ setValue(SavedPlaygroundFormFields.summarizationModel, defaultModel);
+ }
+ }
+ }, [formState.isLoading, pageMode, summarizationModel, models, setValue]);
+
+ const handleModeChange = (id: PlaygroundViewMode) =>
+ navigateToView(pageMode ?? PlaygroundPageMode.Search, id, location.search);
+ const handlePageModeChange = (mode: PlaygroundPageMode) =>
+ navigateToView(mode, viewMode, location.search);
+
+ const { isLoading } = formState;
+ if (isLoading || pageMode === undefined) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (playgroundName.length === 0 && playgroundIndices.length === 0) {
+ return ;
+ }
+ return (
+ <>
+
+
+ {showSetupPage ? (
+ <>
+
+ {/* {isSearchModeEnabled && (
+ // TODO: This should be impossible
+ )} */}
+ >
+ ) : (
+ <>
+
+ }
+ />
+
+ }
+ />
+ >
+ )}
+
+ >
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx
new file mode 100644
index 000000000000..52b6f13596c7
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/saved_playground/saved_playground_fetch_error.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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, { useCallback, useMemo } from 'react';
+import { EuiButton } from '@elastic/eui';
+import { useFormContext } from 'react-hook-form';
+import { i18n } from '@kbn/i18n';
+import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
+import { PLUGIN_ID } from '../../../common';
+import { useKibana } from '../../hooks/use_kibana';
+import { SavedPlaygroundFormFetchError } from '../../types';
+
+export const SavedPlaygroundFetchError = () => {
+ const {
+ services: { application },
+ } = useKibana();
+ const { getValues } = useFormContext();
+ const formData = useMemo(() => getValues(), [getValues]);
+ const goToPlayground = useCallback(() => {
+ application.navigateToApp(PLUGIN_ID);
+ }, [application]);
+ return (
+
+ {i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.title', {
+ defaultMessage: 'Error loading playground',
+ })}
+
+ }
+ body={{formData.error.message}
}
+ actions={
+
+ {i18n.translate('xpack.searchPlayground.savedPlayground.fetchError.action1', {
+ defaultMessage: 'Back to Playgrounds',
+ })}
+
+ }
+ />
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx
index bc9aec0fe1d1..9e6fc19db4ef 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/summarization_panel/summarization_panel.tsx
@@ -23,7 +23,6 @@ export const SummarizationPanel: React.FC = () => {
(
{
}
/>
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx
index 39dff1e97fc3..7bcabed3a8a6 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/dev_tools.tsx
@@ -13,18 +13,20 @@ import { PlaygroundForm, PlaygroundFormFields } from '../../../types';
import { elasticsearchQueryObject } from '../../../utils/user_query';
export const DevToolsCode: React.FC = () => {
- const { getValues } = useFormContext();
+ const {
+ getValues,
+ formState: { errors: formErrors },
+ } = useFormContext();
const {
[PlaygroundFormFields.indices]: indices,
[PlaygroundFormFields.elasticsearchQuery]: esQuery,
[PlaygroundFormFields.searchQuery]: searchQuery,
[PlaygroundFormFields.userElasticsearchQuery]: userElasticsearchQuery,
- [PlaygroundFormFields.userElasticsearchQueryValidations]: userElasticsearchQueryValidations,
} = getValues();
const query = elasticsearchQueryObject(
esQuery,
userElasticsearchQuery,
- userElasticsearchQueryValidations
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
);
const replacedQuery =
searchQuery ?? ''
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx
index 3bfc6c676763..e08cccb439c7 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.test.tsx
@@ -11,6 +11,7 @@ import { ES_CLIENT_DETAILS } from '../view_code_flyout';
import { PlaygroundForm } from '../../../types';
describe('PY_LANG_CLIENT function', () => {
+ const formErrors = {};
test('renders with correct content', () => {
// Mocking necessary values for your function
const formValues = {
@@ -25,7 +26,7 @@ describe('PY_LANG_CLIENT function', () => {
const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance');
- const { container } = render(PY_LANG_CLIENT(formValues, clientDetails));
+ const { container } = render(PY_LANG_CLIENT(formValues, formErrors, clientDetails));
expect(container.firstChild?.textContent).toMatchSnapshot();
});
@@ -43,7 +44,7 @@ describe('PY_LANG_CLIENT function', () => {
const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance');
- const { container } = render(PY_LANG_CLIENT(formValues, clientDetails));
+ const { container } = render(PY_LANG_CLIENT(formValues, formErrors, clientDetails));
expect(container.firstChild?.textContent).toMatchSnapshot();
});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx
index 65488cd176b1..578ff50aef2b 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_lang_client.tsx
@@ -7,12 +7,17 @@
import { EuiCodeBlock } from '@elastic/eui';
import React from 'react';
-import { PlaygroundForm } from '../../../types';
+import { FieldErrors } from 'react-hook-form';
+import { PlaygroundForm, PlaygroundFormFields } from '../../../types';
import { Prompt } from '../../../../common/prompt';
import { elasticsearchQueryObject } from '../../../utils/user_query';
import { getESQuery } from './utils';
-export const PY_LANG_CLIENT = (formValues: PlaygroundForm, clientDetails: string) => (
+export const PY_LANG_CLIENT = (
+ formValues: PlaygroundForm,
+ formErrors: FieldErrors,
+ clientDetails: string
+) => (
{`## Install the required packages
## pip install -qU elasticsearch openai
@@ -34,7 +39,7 @@ def get_elasticsearch_results(query):
...elasticsearchQueryObject(
formValues.elasticsearch_query,
formValues.user_elasticsearch_query,
- formValues.user_elasticsearch_query_validations
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
),
size: formValues.doc_size,
})}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx
index 7c43e01cee6c..9642f8fbb13b 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.test.tsx
@@ -26,7 +26,11 @@ describe('LangchainPythonExmaple component', () => {
const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance');
const { container } = render(
-
+
);
expect(container.firstChild?.textContent).toMatchSnapshot();
@@ -47,7 +51,11 @@ describe('LangchainPythonExmaple component', () => {
const clientDetails = ES_CLIENT_DETAILS('http://my-local-cloud-instance');
const { container } = render(
-
+
);
expect(container.firstChild?.textContent).toMatchSnapshot();
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx
index 1ffda975c0e9..8e0c0234a8ed 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/examples/py_langchain_python.tsx
@@ -7,7 +7,8 @@
import { EuiCodeBlock } from '@elastic/eui';
import React, { useMemo } from 'react';
-import { PlaygroundForm } from '../../../types';
+import { FieldErrors } from 'react-hook-form';
+import { PlaygroundForm, PlaygroundFormFields } from '../../../types';
import { Prompt } from '../../../../common/prompt';
import { elasticsearchQueryObject } from '../../../utils/user_query';
import { getESQuery } from './utils';
@@ -31,9 +32,11 @@ export const getSourceFields = (sourceFields: PlaygroundForm['source_fields']) =
export const LangchainPythonExmaple = ({
formValues,
+ formErrors,
clientDetails,
}: {
formValues: PlaygroundForm;
+ formErrors: FieldErrors;
clientDetails: string;
}) => {
const { esQuery, hasContentFieldsArray, indices, prompt, sourceFields } = useMemo(() => {
@@ -43,7 +46,7 @@ export const LangchainPythonExmaple = ({
elasticsearchQueryObject(
formValues.elasticsearch_query,
formValues.user_elasticsearch_query,
- formValues.user_elasticsearch_query_validations
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
)
),
indices: formValues.indices.join(','),
@@ -54,7 +57,7 @@ export const LangchainPythonExmaple = ({
}),
...fields,
};
- }, [formValues]);
+ }, [formValues, formErrors]);
return (
{`## Install the required packages
diff --git a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx
index c28977456bb3..a84450a54658 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/components/view_code/view_code_flyout.tsx
@@ -46,7 +46,10 @@ es_client = Elasticsearch(
export const ViewCodeFlyout: React.FC = ({ onClose, selectedPageMode }) => {
const usageTracker = useUsageTracker();
const [selectedLanguage, setSelectedLanguage] = useState('py-es-client');
- const { getValues } = useFormContext();
+ const {
+ getValues,
+ formState: { errors: formErrors },
+ } = useFormContext();
const formValues = getValues();
const {
services: { cloud, http },
@@ -60,8 +63,14 @@ export const ViewCodeFlyout: React.FC = ({ onClose, selecte
const CLIENT_STEP = ES_CLIENT_DETAILS(elasticsearchUrl);
const steps: Record = {
- 'lc-py': ,
- 'py-es-client': PY_LANG_CLIENT(formValues, CLIENT_STEP),
+ 'lc-py': (
+
+ ),
+ 'py-es-client': PY_LANG_CLIENT(formValues, formErrors, CLIENT_STEP),
};
const handleLanguageChange = (e: React.ChangeEvent) => {
setSelectedLanguage(e.target.value);
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts
index 3384d8985e10..cdb67420a39c 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_elasticsearch_query.ts
@@ -19,13 +19,16 @@ import { elasticsearchQueryString } from '../utils/user_query';
export const useElasticsearchQuery = (pageMode: PlaygroundPageMode) => {
const { http } = useKibana().services;
- const { getValues } = useFormContext();
+ const {
+ getValues,
+ formState: { errors: formErrors },
+ } = useFormContext();
const executeEsQuery = () => {
const formValues = getValues();
const esQuery = elasticsearchQueryString(
formValues[PlaygroundFormFields.elasticsearchQuery],
formValues[PlaygroundFormFields.userElasticsearchQuery],
- formValues[PlaygroundFormFields.userElasticsearchQueryValidations]
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
);
const body =
pageMode === PlaygroundPageMode.chat
@@ -50,6 +53,7 @@ export const useElasticsearchQuery = (pageMode: PlaygroundPageMode) => {
};
const { refetch: executeQuery, ...rest } = useQuery({
+ queryKey: ['searchPlayground', 'queryTest'],
queryFn: executeEsQuery,
enabled: false,
retry: false,
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts
deleted file mode 100644
index d416791df38f..000000000000
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.ts
+++ /dev/null
@@ -1,150 +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 { renderHook } from '@testing-library/react';
-import { useLoadConnectors } from './use_load_connectors';
-import { useLLMsModels } from './use_llms_models';
-import { LLMs } from '../types';
-
-jest.mock('./use_load_connectors', () => ({
- useLoadConnectors: jest.fn(),
-}));
-
-const mockConnectors = [
- { id: 'connectorId1', name: 'OpenAI Connector', type: LLMs.openai },
- { id: 'connectorId2', name: 'OpenAI Azure Connector', type: LLMs.openai_azure },
- { id: 'connectorId2', name: 'Bedrock Connector', type: LLMs.bedrock },
- { id: 'connectorId3', name: 'OpenAI OSS Model Connector', type: LLMs.openai_other },
- {
- id: 'connectorId4',
- name: 'EIS Connector',
- type: LLMs.inference,
- config: { provider: 'openai' },
- },
-];
-const mockUseLoadConnectors = (data: any) => {
- (useLoadConnectors as jest.Mock).mockReturnValue({ data });
-};
-
-describe('useLLMsModels Hook', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('returns LLMModels with connectors available', () => {
- mockUseLoadConnectors(mockConnectors);
-
- const { result } = renderHook(() => useLLMsModels());
-
- expect(result.current).toEqual([
- {
- connectorId: 'connectorId1',
- connectorName: 'OpenAI Connector',
- connectorType: LLMs.openai,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId1OpenAI GPT-4o ',
- name: 'OpenAI GPT-4o ',
- showConnectorName: false,
- value: 'gpt-4o',
- promptTokenLimit: 128000,
- },
- {
- connectorId: 'connectorId1',
- connectorName: 'OpenAI Connector',
- connectorType: LLMs.openai,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId1OpenAI GPT-4 Turbo ',
- name: 'OpenAI GPT-4 Turbo ',
- showConnectorName: false,
- value: 'gpt-4-turbo',
- promptTokenLimit: 128000,
- },
- {
- connectorId: 'connectorId1',
- connectorName: 'OpenAI Connector',
- connectorType: LLMs.openai,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId1OpenAI GPT-3.5 Turbo ',
- name: 'OpenAI GPT-3.5 Turbo ',
- showConnectorName: false,
- value: 'gpt-3.5-turbo',
- promptTokenLimit: 16385,
- },
- {
- connectorId: 'connectorId2',
- connectorName: 'OpenAI Azure Connector',
- connectorType: LLMs.openai_azure,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId2OpenAI Azure Connector (Azure OpenAI)',
- name: 'OpenAI Azure Connector (Azure OpenAI)',
- showConnectorName: false,
- value: undefined,
- promptTokenLimit: undefined,
- },
- {
- connectorId: 'connectorId2',
- connectorName: 'Bedrock Connector',
- connectorType: LLMs.bedrock,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId2Anthropic Claude 3 Haiku',
- name: 'Anthropic Claude 3 Haiku',
- showConnectorName: false,
- value: 'anthropic.claude-3-haiku-20240307-v1:0',
- promptTokenLimit: 200000,
- },
- {
- connectorId: 'connectorId2',
- connectorName: 'Bedrock Connector',
- connectorType: LLMs.bedrock,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId2Anthropic Claude 3.5 Sonnet',
- name: 'Anthropic Claude 3.5 Sonnet',
- showConnectorName: false,
- value: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
- promptTokenLimit: 200000,
- },
- {
- connectorId: 'connectorId3',
- connectorName: 'OpenAI OSS Model Connector',
- connectorType: LLMs.openai_other,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId3OpenAI OSS Model Connector (OpenAI Compatible Service)',
- name: 'OpenAI OSS Model Connector (OpenAI Compatible Service)',
- showConnectorName: false,
- value: undefined,
- promptTokenLimit: undefined,
- },
- {
- connectorId: 'connectorId4',
- connectorName: 'EIS Connector',
- connectorType: LLMs.inference,
- disabled: false,
- icon: expect.any(String),
- id: 'connectorId4EIS Connector',
- name: 'EIS Connector',
- showConnectorName: false,
- value: undefined,
- promptTokenLimit: undefined,
- },
- ]);
- });
-
- it('returns emptyd when connectors not available', () => {
- mockUseLoadConnectors([]);
-
- const { result } = renderHook(() => useLLMsModels());
-
- expect(result.current).toEqual([]);
- });
-});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx
new file mode 100644
index 000000000000..8f22c752b03c
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.test.tsx
@@ -0,0 +1,181 @@
+/*
+ * 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react';
+
+import { LoadConnectorsQuery } from './use_load_connectors';
+import { useLLMsModels } from './use_llms_models';
+import { LLMs } from '../types';
+
+jest.mock('./use_load_connectors', () => ({
+ useLoadConnectors: jest.fn(),
+ LOAD_CONNECTORS_QUERY_KEY: jest.requireActual('./use_load_connectors').LOAD_CONNECTORS_QUERY_KEY,
+ LoadConnectorsQuery: jest.fn(),
+}));
+
+jest.mock('./use_kibana', () => ({
+ useKibana: jest.fn().mockReturnValue({
+ services: {
+ http: {},
+ notifications: {
+ toasts: {
+ addError: jest.fn(),
+ },
+ },
+ },
+ }),
+}));
+
+const mockedLoadConnectorsQuery = jest.fn();
+
+const mockConnectors = [
+ { id: 'connectorId1', name: 'OpenAI Connector', type: LLMs.openai },
+ { id: 'connectorId2', name: 'OpenAI Azure Connector', type: LLMs.openai_azure },
+ { id: 'connectorId2', name: 'Bedrock Connector', type: LLMs.bedrock },
+ { id: 'connectorId3', name: 'OpenAI OSS Model Connector', type: LLMs.openai_other },
+ {
+ id: 'connectorId4',
+ name: 'EIS Connector',
+ type: LLMs.inference,
+ config: { provider: 'openai' },
+ },
+];
+const mockLoadConnectorsQuery = (data: any) => {
+ (LoadConnectorsQuery as jest.Mock).mockReturnValue(mockedLoadConnectorsQuery);
+ mockedLoadConnectorsQuery.mockResolvedValue(data);
+};
+
+describe('useLLMsModels Query Hook', () => {
+ const queryClient = new QueryClient();
+ const wrapper = ({ children }: React.PropsWithChildren<{}>) => (
+ {children}
+ );
+ beforeEach(() => {
+ jest.clearAllMocks();
+ queryClient.clear();
+ });
+
+ it('returns LLMModels with connectors available', async () => {
+ mockLoadConnectorsQuery(mockConnectors);
+
+ const { result } = renderHook(() => useLLMsModels(), { wrapper });
+ await waitFor(() => expect(mockedLoadConnectorsQuery).toHaveBeenCalledTimes(1));
+
+ await waitFor(() =>
+ expect(result.current).toStrictEqual([
+ {
+ connectorId: 'connectorId1',
+ connectorName: 'OpenAI Connector',
+ connectorType: LLMs.openai,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId1OpenAI GPT-4o ',
+ name: 'OpenAI GPT-4o ',
+ showConnectorName: false,
+ value: 'gpt-4o',
+ promptTokenLimit: 128000,
+ },
+ {
+ connectorId: 'connectorId1',
+ connectorName: 'OpenAI Connector',
+ connectorType: LLMs.openai,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId1OpenAI GPT-4 Turbo ',
+ name: 'OpenAI GPT-4 Turbo ',
+ showConnectorName: false,
+ value: 'gpt-4-turbo',
+ promptTokenLimit: 128000,
+ },
+ {
+ connectorId: 'connectorId1',
+ connectorName: 'OpenAI Connector',
+ connectorType: LLMs.openai,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId1OpenAI GPT-3.5 Turbo ',
+ name: 'OpenAI GPT-3.5 Turbo ',
+ showConnectorName: false,
+ value: 'gpt-3.5-turbo',
+ promptTokenLimit: 16385,
+ },
+ {
+ connectorId: 'connectorId2',
+ connectorName: 'OpenAI Azure Connector',
+ connectorType: LLMs.openai_azure,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId2OpenAI Azure Connector (Azure OpenAI)',
+ name: 'OpenAI Azure Connector (Azure OpenAI)',
+ showConnectorName: false,
+ value: undefined,
+ promptTokenLimit: undefined,
+ },
+ {
+ connectorId: 'connectorId2',
+ connectorName: 'Bedrock Connector',
+ connectorType: LLMs.bedrock,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId2Anthropic Claude 3 Haiku',
+ name: 'Anthropic Claude 3 Haiku',
+ showConnectorName: false,
+ value: 'anthropic.claude-3-haiku-20240307-v1:0',
+ promptTokenLimit: 200000,
+ },
+ {
+ connectorId: 'connectorId2',
+ connectorName: 'Bedrock Connector',
+ connectorType: LLMs.bedrock,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId2Anthropic Claude 3.5 Sonnet',
+ name: 'Anthropic Claude 3.5 Sonnet',
+ showConnectorName: false,
+ value: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
+ promptTokenLimit: 200000,
+ },
+ {
+ connectorId: 'connectorId3',
+ connectorName: 'OpenAI OSS Model Connector',
+ connectorType: LLMs.openai_other,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId3OpenAI OSS Model Connector (OpenAI Compatible Service)',
+ name: 'OpenAI OSS Model Connector (OpenAI Compatible Service)',
+ showConnectorName: false,
+ value: undefined,
+ promptTokenLimit: undefined,
+ },
+ {
+ connectorId: 'connectorId4',
+ connectorName: 'EIS Connector',
+ connectorType: LLMs.inference,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId4EIS Connector',
+ name: 'EIS Connector',
+ showConnectorName: false,
+ value: undefined,
+ promptTokenLimit: undefined,
+ },
+ ])
+ );
+ });
+
+ it('returns emptyd when connectors not available', async () => {
+ mockLoadConnectorsQuery([]);
+
+ const { result } = renderHook(() => useLLMsModels(), { wrapper });
+
+ await waitFor(() => expect(mockedLoadConnectorsQuery).toHaveBeenCalledTimes(1));
+
+ expect(result.current).toEqual([]);
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts
index abf1344e3efa..df3e77d2e330 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_llms_models.ts
@@ -5,14 +5,17 @@
* 2.0.
*/
+import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query';
+import { HttpSetup } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
-import { useMemo } from 'react';
import { SERVICE_PROVIDERS } from '@kbn/inference-endpoint-ui-common';
+
import type { PlaygroundConnector, InferenceActionConnector, ActionConnector } from '../types';
import { LLMs } from '../../common/types';
import { LLMModel } from '../types';
-import { useLoadConnectors } from './use_load_connectors';
import { MODELS } from '../../common/models';
+import { useKibana } from './use_kibana';
+import { LOAD_CONNECTORS_QUERY_KEY, LoadConnectorsQuery } from './use_load_connectors';
const isInferenceActionConnector = (
connector: ActionConnector
@@ -96,52 +99,66 @@ const mapLlmToModels: Record<
},
};
+export const LLMS_QUERY_KEY = ['search-playground', 'llms-models'];
+
+export const LLMsQuery =
+ (http: HttpSetup, client: QueryClient) => async (): Promise => {
+ const connectors = await client.fetchQuery({
+ queryKey: LOAD_CONNECTORS_QUERY_KEY,
+ queryFn: LoadConnectorsQuery(http),
+ retry: false,
+ });
+
+ const mapConnectorTypeToCount = connectors.reduce>>(
+ (result, connector) => {
+ result[connector.type] = (result[connector.type] || 0) + 1;
+ return result;
+ },
+ {}
+ );
+
+ const models = connectors.reduce((result, connector) => {
+ const connectorType = connector.type as LLMs;
+ const llmParams = mapLlmToModels[connectorType];
+
+ if (!llmParams) {
+ return result;
+ }
+
+ const showConnectorName = Number(mapConnectorTypeToCount?.[connectorType]) > 1;
+
+ llmParams
+ .getModels(connector.name, false)
+ .map(({ label, value, promptTokenLimit }) => ({
+ id: connector?.id + label,
+ name: label,
+ value,
+ connectorType: connector.type,
+ connectorName: connector.name,
+ showConnectorName,
+ icon: typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon,
+ disabled: !connector,
+ connectorId: connector.id,
+ promptTokenLimit,
+ }))
+ .forEach((model) => result.push(model));
+
+ return result;
+ }, []);
+
+ return models;
+ };
+
export const useLLMsModels = (): LLMModel[] => {
- const { data: connectors } = useLoadConnectors();
+ const client = useQueryClient();
+ const {
+ services: { http },
+ } = useKibana();
- const mapConnectorTypeToCount = useMemo(
- () =>
- connectors?.reduce>>(
- (result, connector) => ({
- ...result,
- [connector.type]: (result[connector.type as LLMs] || 0) + 1,
- }),
- {}
- ),
- [connectors]
- );
+ const { data } = useQuery(LLMS_QUERY_KEY, LLMsQuery(http, client), {
+ keepPreviousData: true,
+ retry: false,
+ });
- return useMemo(
- () =>
- connectors?.reduce((result, connector) => {
- const connectorType = connector.type as LLMs;
- const llmParams = mapLlmToModels[connectorType];
-
- if (!llmParams) {
- return result;
- }
-
- const showConnectorName = Number(mapConnectorTypeToCount?.[connectorType]) > 1;
-
- return [
- ...result,
- ...llmParams
- .getModels(connector.name, false)
- .map(({ label, value, promptTokenLimit }) => ({
- id: connector?.id + label,
- name: label,
- value,
- connectorType: connector.type,
- connectorName: connector.name,
- showConnectorName,
- icon:
- typeof llmParams.icon === 'function' ? llmParams.icon(connector) : llmParams.icon,
- disabled: !connector,
- connectorId: connector.id,
- promptTokenLimit,
- })),
- ];
- }, []) || [],
- [connectors, mapConnectorTypeToCount]
- );
+ return data || [];
};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts
index e86ccd499dd9..8984f4ef4bd5 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_load_connectors.ts
@@ -8,162 +8,39 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
-import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
+import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import { i18n } from '@kbn/i18n';
-import {
- OPENAI_CONNECTOR_ID,
- OpenAiProviderType,
- BEDROCK_CONNECTOR_ID,
- GEMINI_CONNECTOR_ID,
- INFERENCE_CONNECTOR_ID,
-} from '@kbn/stack-connectors-plugin/public/common';
-import { isSupportedConnector } from '@kbn/inference-common';
-import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common';
import { useKibana } from './use_kibana';
-import {
- LLMs,
- type ActionConnector,
- type UserConfiguredActionConnector,
- type PlaygroundConnector,
- InferenceActionConnector,
-} from '../types';
+import { type PlaygroundConnector } from '../types';
+import { parsePlaygroundConnectors } from '../utils/playground_connectors';
-const QUERY_KEY = ['search-playground, load-connectors'];
+export const LOAD_CONNECTORS_QUERY_KEY = ['search-playground, load-connectors'];
-type OpenAIConnector = UserConfiguredActionConnector<
- { apiProvider: OpenAiProviderType },
- Record
->;
-
-const connectorTypeToLLM: Array<{
- actionId: string;
- actionProvider?: string;
- match: (connector: ActionConnector) => boolean;
- transform: (connector: ActionConnector) => PlaygroundConnector;
-}> = [
- {
- actionId: OPENAI_CONNECTOR_ID,
- actionProvider: OpenAiProviderType.AzureAi,
- match: (connector) =>
- connector.actionTypeId === OPENAI_CONNECTOR_ID &&
- (connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.AzureAi,
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', {
- defaultMessage: 'OpenAI Azure',
- }),
- type: LLMs.openai_azure,
- }),
- },
- {
- actionId: OPENAI_CONNECTOR_ID,
- match: (connector) =>
- connector.actionTypeId === OPENAI_CONNECTOR_ID &&
- ((connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.OpenAi ||
- !!connector.isPreconfigured),
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', {
- defaultMessage: 'OpenAI',
- }),
- type: LLMs.openai,
- }),
- },
- {
- actionId: OPENAI_CONNECTOR_ID,
- actionProvider: OpenAiProviderType.Other,
- match: (connector) =>
- connector.actionTypeId === OPENAI_CONNECTOR_ID &&
- (connector as OpenAIConnector)?.config?.apiProvider === OpenAiProviderType.Other,
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.openAIOtherConnectorTitle', {
- defaultMessage: 'OpenAI Other',
- }),
- type: LLMs.openai_other,
- }),
- },
- {
- actionId: BEDROCK_CONNECTOR_ID,
- match: (connector) => connector.actionTypeId === BEDROCK_CONNECTOR_ID,
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.bedrockConnectorTitle', {
- defaultMessage: 'Bedrock',
- }),
- type: LLMs.bedrock,
- }),
- },
- {
- actionId: GEMINI_CONNECTOR_ID,
- match: (connector) => connector.actionTypeId === GEMINI_CONNECTOR_ID,
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.geminiConnectorTitle', {
- defaultMessage: 'Gemini',
- }),
- type: LLMs.gemini,
- }),
- },
- {
- actionId: INFERENCE_CONNECTOR_ID,
- match: (connector) =>
- connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector),
- transform: (connector) => ({
- ...connector,
- title: i18n.translate('xpack.searchPlayground.aiConnectorTitle', {
- defaultMessage: 'AI Connector',
- }),
- type: LLMs.inference,
- }),
- },
-];
+export const LoadConnectorsQuery = (http: HttpSetup) => async () => {
+ const queryResult = await loadConnectors({ http });
+ return parsePlaygroundConnectors(queryResult, http);
+};
export const useLoadConnectors = (): UseQueryResult => {
const {
services: { http, notifications },
} = useKibana();
- return useQuery(
- QUERY_KEY,
- async () => {
- const queryResult = await loadConnectors({ http });
-
- return queryResult.reduce>(async (result, connector) => {
- const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {};
-
- if (
- !connector.isMissingSecrets &&
- !!transform &&
- (connector.actionTypeId !== '.inference' ||
- (connector.actionTypeId === '.inference' &&
- (await isInferenceEndpointExists(
- http,
- (connector as InferenceActionConnector)?.config?.inferenceId
- ))))
- ) {
- return [...(await result), transform(connector)];
- }
-
- return result;
- }, Promise.resolve([]));
+ return useQuery(LOAD_CONNECTORS_QUERY_KEY, LoadConnectorsQuery(http), {
+ retry: false,
+ keepPreviousData: true,
+ onError: (error: IHttpFetchError) => {
+ if (error.name !== 'AbortError') {
+ notifications?.toasts?.addError(
+ error.body && error.body.message ? new Error(error.body.message) : error,
+ {
+ title: i18n.translate('xpack.searchPlayground.loadConnectorsError', {
+ defaultMessage:
+ 'Error loading connectors. Please check your configuration and try again.',
+ }),
+ }
+ );
+ }
},
- {
- retry: false,
- keepPreviousData: true,
- onError: (error: IHttpFetchError) => {
- if (error.name !== 'AbortError') {
- notifications?.toasts?.addError(
- error.body && error.body.message ? new Error(error.body.message) : error,
- {
- title: i18n.translate('xpack.searchPlayground.loadConnectorsError', {
- defaultMessage:
- 'Error loading connectors. Please check your configuration and try again.',
- }),
- }
- );
- }
- },
- }
- );
+ });
};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts
index 3fd64c3a7c2a..d4874ac859ca 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_playground_breadcrumbs.ts
@@ -9,31 +9,42 @@ import { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
+import { PLUGIN_PATH } from '../../common';
import { useKibana } from './use_kibana';
-export const usePlaygroundBreadcrumbs = () => {
- const { searchNavigation } = useKibana().services;
+export const usePlaygroundBreadcrumbs = (playgroundName?: string) => {
+ const { cloud, http, searchNavigation } = useKibana().services;
+ const isServerless = cloud?.isServerlessEnabled ?? false;
useEffect(() => {
- searchNavigation?.breadcrumbs.setSearchBreadCrumbs(
- [
- {
- text: i18n.translate('xpack.searchPlayground.breadcrumbs.build', {
- defaultMessage: 'Build',
- }),
- },
- {
- text: i18n.translate('xpack.searchPlayground.breadcrumbs.playground', {
- defaultMessage: 'Playground',
- }),
- },
- ],
- { forClassicChromeStyle: true }
- );
+ searchNavigation?.breadcrumbs.setSearchBreadCrumbs([
+ ...(isServerless
+ ? [] // Serverless is setting Build breadcrumb automatically
+ : [
+ {
+ text: i18n.translate('xpack.searchPlayground.breadcrumbs.build', {
+ defaultMessage: 'Build',
+ }),
+ },
+ ]),
+ {
+ text: i18n.translate('xpack.searchPlayground.breadcrumbs.playground', {
+ defaultMessage: 'Playground',
+ }),
+ href: playgroundName !== undefined ? http.basePath.prepend(PLUGIN_PATH) : undefined,
+ },
+ ...(playgroundName !== undefined
+ ? [
+ {
+ text: playgroundName,
+ },
+ ]
+ : []),
+ ]);
return () => {
// Clear breadcrumbs on unmount;
searchNavigation?.breadcrumbs.clearBreadcrumbs();
};
- }, [searchNavigation]);
+ }, [http, searchNavigation, isServerless, playgroundName]);
};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts
new file mode 100644
index 000000000000..6600b97ace52
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_saved_playground_parameters.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useParams } from 'react-router-dom';
+import { SavedPlaygroundRouterParameters, PlaygroundViewMode } from '../types';
+
+export const useSavedPlaygroundParameters = () => {
+ const { playgroundId, pageMode, viewMode } = useParams();
+ return {
+ playgroundId,
+ pageMode,
+ viewMode: viewMode ?? PlaygroundViewMode.preview,
+ };
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts
index 448fbdc25bb0..8cc124087481 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_search_preview.ts
@@ -71,7 +71,10 @@ export const useSearchPreview = ({
const {
services: { http },
} = useKibana();
- const { getValues } = useFormContext();
+ const {
+ getValues,
+ formState: { errors: formErrors },
+ } = useFormContext();
const indices = getValues(PlaygroundFormFields.indices);
const elasticsearchQuery = getValues(PlaygroundFormFields.elasticsearchQuery);
const queryFn = () => {
@@ -79,7 +82,7 @@ export const useSearchPreview = ({
const elasticsearchQueryBody = elasticsearchQueryObject(
formData[PlaygroundFormFields.elasticsearchQuery],
formData[PlaygroundFormFields.userElasticsearchQuery],
- formData[PlaygroundFormFields.userElasticsearchQueryValidations]
+ formErrors[PlaygroundFormFields.userElasticsearchQuery]
);
return fetchSearchResults({
query,
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts
similarity index 62%
rename from x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts
rename to x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts
index 36c86a32679d..5bd6339c14f1 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_page_mode.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_show_setup_page.ts
@@ -7,12 +7,10 @@
import { useEffect, useState } from 'react';
-import { PLUGIN_ID } from '../../common';
-import { PlaygroundPageMode, PlaygroundViewMode } from '../types';
-import { useKibana } from './use_kibana';
+import { PlaygroundPageMode } from '../types';
import { usePlaygroundParameters } from './use_playground_parameters';
-export const usePageMode = ({
+export const useShowSetupPage = ({
hasSelectedIndices,
hasConnectors,
}: {
@@ -20,8 +18,7 @@ export const usePageMode = ({
hasConnectors: boolean;
}) => {
const [showSetupPage, setShowSetupPage] = useState(true);
- const { pageMode, viewMode } = usePlaygroundParameters();
- const { application } = useKibana().services;
+ const { pageMode } = usePlaygroundParameters();
useEffect(() => {
if (pageMode === PlaygroundPageMode.chat) {
@@ -38,19 +35,8 @@ export const usePageMode = ({
}
}
}, [hasSelectedIndices, showSetupPage, pageMode, hasConnectors]);
- useEffect(() => {
- // Handle Unknown modes
- if (!Object.values(PlaygroundPageMode).includes(pageMode)) {
- application.navigateToApp(PLUGIN_ID, { path: '/not_found' });
- return;
- }
- if (!Object.values(PlaygroundViewMode).includes(viewMode)) {
- application.navigateToApp(PLUGIN_ID, { path: `/${pageMode}` });
- }
- }, [application, pageMode, viewMode]);
return {
showSetupPage,
- pageMode,
};
};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts
deleted file mode 100644
index bfa025e9fed6..000000000000
--- a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_user_query_validations.ts
+++ /dev/null
@@ -1,35 +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 { useEffect } from 'react';
-import { UseFormReturn } from 'react-hook-form/dist/types';
-import { useDebounceFn } from '@kbn/react-hooks';
-
-import { PlaygroundForm, PlaygroundFormFields } from '../types';
-import { validateUserElasticSearchQuery } from '../utils/user_query';
-
-const DEBOUNCE_OPTIONS = { wait: 500 };
-export const useUserQueryValidations = ({
- watch,
- setValue,
- getValues,
-}: Pick, 'watch' | 'getValues' | 'setValue'>) => {
- const userElasticsearchQuery = watch(PlaygroundFormFields.userElasticsearchQuery);
- const elasticsearchQuery = watch(PlaygroundFormFields.elasticsearchQuery);
-
- const userQueryValidation = useDebounceFn(() => {
- const [esQuery, userInputQuery] = getValues([
- PlaygroundFormFields.elasticsearchQuery,
- PlaygroundFormFields.userElasticsearchQuery,
- ]);
- const validations = validateUserElasticSearchQuery(userInputQuery, esQuery);
- setValue(PlaygroundFormFields.userElasticsearchQueryValidations, validations);
- }, DEBOUNCE_OPTIONS);
- useEffect(() => {
- userQueryValidation.run();
- }, [elasticsearchQuery, userElasticsearchQuery, userQueryValidation]);
-};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts
new file mode 100644
index 000000000000..ea9a6ea1c955
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/hooks/use_validate_playground_view_modes.ts
@@ -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 { useEffect } from 'react';
+
+import { PLUGIN_ID } from '../../common';
+import { PlaygroundPageMode, PlaygroundViewMode } from '../types';
+import { useKibana } from './use_kibana';
+import { usePlaygroundParameters } from './use_playground_parameters';
+
+export const useValidatePlaygroundViewModes = () => {
+ const { pageMode, viewMode } = usePlaygroundParameters();
+ const { application } = useKibana().services;
+ useEffect(() => {
+ // Handle Unknown modes
+ if (!Object.values(PlaygroundPageMode).includes(pageMode)) {
+ application.navigateToApp(PLUGIN_ID, { path: '/not_found' });
+ return;
+ }
+ if (!Object.values(PlaygroundViewMode).includes(viewMode)) {
+ application.navigateToApp(PLUGIN_ID, { path: `/${pageMode}` });
+ }
+ }, [application, pageMode, viewMode]);
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx b/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx
new file mode 100644
index 000000000000..60389c1042cc
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/layout/page_template.tsx
@@ -0,0 +1,39 @@
+/*
+ * 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, { useMemo } from 'react';
+import { KibanaPageTemplate, KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
+
+import { useKibana } from '../hooks/use_kibana';
+
+export const SearchPlaygroundPageTemplate = ({
+ children,
+ ...props
+}: Partial) => {
+ const {
+ services: { history, console: consolePlugin, searchNavigation },
+ } = useKibana();
+ const embeddableConsole = useMemo(
+ () => (consolePlugin?.EmbeddableConsole ? : null),
+ [consolePlugin]
+ );
+
+ const allProps: KibanaPageTemplateProps = {
+ offset: 0,
+ restrictWidth: false,
+ grow: false,
+ panelled: false,
+ ...props,
+ };
+
+ return (
+
+ {children}
+ {embeddableConsole}
+
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx b/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx
index f7175d7b2c9a..a81b8d4d7f6a 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/playground_overview.tsx
@@ -5,38 +5,22 @@
* 2.0.
*/
-import React, { useMemo } from 'react';
-import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
+import React from 'react';
import { UnsavedFormProvider } from './providers/unsaved_form_provider';
-import { useKibana } from './hooks/use_kibana';
import { Playground } from './components/playgorund';
+
import { usePlaygroundBreadcrumbs } from './hooks/use_playground_breadcrumbs';
+import { SearchPlaygroundPageTemplate } from './layout/page_template';
export const PlaygroundOverview = () => {
- const {
- services: { history, console: consolePlugin, searchNavigation },
- } = useKibana();
usePlaygroundBreadcrumbs();
- const embeddableConsole = useMemo(
- () => (consolePlugin?.EmbeddableConsole ? : null),
- [consolePlugin]
- );
-
return (
-
+
- {embeddableConsole}
-
+
);
};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx b/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx
index c08b92cbe5db..9bdad1999443 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/playground_router.tsx
@@ -9,9 +9,11 @@ import { Route, Routes } from '@kbn/shared-ux-router';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { PlaygroundOverview } from './playground_overview';
+import { SavedPlaygroundPage } from './saved_playground';
import {
ROOT_PATH,
+ SAVED_PLAYGROUND_PATH,
SEARCH_PLAYGROUND_CHAT_PATH,
SEARCH_PLAYGROUND_NOT_FOUND,
SEARCH_PLAYGROUND_SEARCH_PATH,
@@ -28,6 +30,9 @@ export const PlaygroundRouter: React.FC = () => {
{!isSearchModeEnabled && (
)}
+ {isSearchModeEnabled && (
+
+ )}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx
new file mode 100644
index 000000000000..a9d56ed41424
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/providers/saved_playground_provider.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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, { useEffect } from 'react';
+import { type QueryClient, useQueryClient } from '@tanstack/react-query';
+import type { HttpSetup } from '@kbn/core-http-browser';
+import { FormProvider as ReactHookFormProvider, UseFormReturn, useForm } from 'react-hook-form';
+
+import { ROUTE_VERSIONS } from '../../common';
+import { useKibana } from '../hooks/use_kibana';
+import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices';
+
+import { LLMS_QUERY_KEY, LLMsQuery } from '../hooks/use_llms_models';
+import {
+ APIRoutes,
+ SavedPlaygroundForm,
+ PlaygroundResponse,
+ PlaygroundForm,
+ LLMModel,
+} from '../types';
+import { savedPlaygroundFormResolver } from '../utils/playground_form_resolver';
+import { fetchSavedPlaygroundError, parseSavedPlayground } from '../utils/saved_playgrounds';
+
+export interface SavedPlaygroundFormProviderProps {
+ playgroundId: string;
+}
+
+interface FetchSavedPlaygroundOptions {
+ client: QueryClient;
+ http: HttpSetup;
+}
+
+const fetchSavedPlayground =
+ (playgroundId: string, { http, client }: FetchSavedPlaygroundOptions) =>
+ async (): Promise => {
+ let playgroundResp: PlaygroundResponse;
+ try {
+ playgroundResp = await http.get(
+ APIRoutes.GET_PLAYGROUND.replace('{id}', playgroundId),
+ {
+ version: ROUTE_VERSIONS.v1,
+ }
+ );
+ } catch (e) {
+ return fetchSavedPlaygroundError(e);
+ }
+ let models: LLMModel[];
+ try {
+ models = await client.fetchQuery(LLMS_QUERY_KEY, LLMsQuery(http, client));
+ } catch (e) {
+ models = [];
+ }
+
+ const result = parseSavedPlayground(playgroundResp, models);
+ return result;
+ };
+
+export const SavedPlaygroundFormProvider = ({
+ children,
+ playgroundId,
+}: React.PropsWithChildren) => {
+ const client = useQueryClient();
+ const { http } = useKibana().services;
+ const form = useForm({
+ defaultValues: fetchSavedPlayground(playgroundId, { http, client }),
+ resolver: savedPlaygroundFormResolver,
+ reValidateMode: 'onChange',
+ context: { http },
+ });
+ useLoadFieldsByIndices({
+ watch: form.watch,
+ setValue: form.setValue,
+ getValues: form.getValues,
+ // casting form handlers here because TS isn't happy with UseFormReturn even though SavedPlaygroundForm extends PlaygroundForm
+ } as unknown as Pick, 'watch' | 'getValues' | 'setValue'>);
+ useEffect(() => {
+ if (form.formState.isLoading) return;
+ // Trigger validation of existing values after initial loading.
+ form.trigger();
+ }, [form, form.formState.isLoading]);
+ return {children};
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx
index d7a790d0a1ca..5dea667bae88 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.test.tsx
@@ -54,10 +54,6 @@ const DEFAULT_FORM_STATE: Partial = {
indices: [],
summarization_model: undefined,
user_elasticsearch_query: null,
- user_elasticsearch_query_validations: {
- isUserCustomized: false,
- isValid: false,
- },
};
describe('UnsavedFormProvider', () => {
diff --git a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx
index 661631bc3ca3..fc5eb0aaafe0 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx
+++ b/x-pack/solutions/search/plugins/search_playground/public/providers/unsaved_form_provider.tsx
@@ -4,28 +4,29 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import { FormProvider as ReactHookFormProvider, useForm } from 'react-hook-form';
+import { FormProvider as ReactHookFormProvider, useForm, UseFormGetValues } from 'react-hook-form';
import React, { useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useDebounceFn } from '@kbn/react-hooks';
+import { DEFAULT_CONTEXT_DOCUMENTS } from '../../common';
+import { DEFAULT_LLM_PROMPT } from '../../common/prompt';
import { useIndicesValidation } from '../hooks/use_indices_validation';
import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices';
-import { useUserQueryValidations } from '../hooks/use_user_query_validations';
import { PlaygroundForm, PlaygroundFormFields } from '../types';
import { useLLMsModels } from '../hooks/use_llms_models';
+import { playgroundFormResolver } from '../utils/playground_form_resolver';
type PartialPlaygroundForm = Partial;
export const LOCAL_STORAGE_KEY = 'search_playground_session';
-export const LOCAL_STORAGE_DEBOUNCE_OPTIONS = { wait: 100 };
+export const LOCAL_STORAGE_DEBOUNCE_OPTIONS = { wait: 100, maxWait: 500 };
const DEFAULT_FORM_VALUES: PartialPlaygroundForm = {
- prompt: 'You are an assistant for question-answering tasks.',
- doc_size: 3,
+ prompt: DEFAULT_LLM_PROMPT,
+ doc_size: DEFAULT_CONTEXT_DOCUMENTS,
source_fields: {},
indices: [],
summarization_model: undefined,
[PlaygroundFormFields.userElasticsearchQuery]: null,
- [PlaygroundFormFields.userElasticsearchQueryValidations]: undefined,
};
const getLocalSession = (storage: Storage): PartialPlaygroundForm => {
@@ -42,14 +43,10 @@ const getLocalSession = (storage: Storage): PartialPlaygroundForm => {
}
};
-const setLocalSession = (formState: PartialPlaygroundForm, storage: Storage) => {
+const setLocalSession = (getValues: UseFormGetValues, storage: Storage) => {
+ const formState = getValues();
// omit question and search_query from the session state
- const {
- question,
- search_query: _searchQuery,
- [PlaygroundFormFields.userElasticsearchQueryValidations]: _queryValidations,
- ...state
- } = formState;
+ const { question, search_query: _searchQuery, ...state } = formState;
storage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
};
@@ -76,6 +73,8 @@ export const UnsavedFormProvider: React.FC {
- const subscription = form.watch((values) =>
- setLocalSessionDebounce.run(values as PartialPlaygroundForm, storage)
+ const subscription = form.watch((_values) =>
+ setLocalSessionDebounce.run(form.getValues, storage)
);
return () => subscription.unsubscribe();
}, [form, storage, setLocalSessionDebounce]);
@@ -111,6 +105,7 @@ export const UnsavedFormProvider: React.FC {
if (isValidatedIndices) {
form.setValue(PlaygroundFormFields.indices, validIndices);
+ form.trigger();
}
}, [form, isValidatedIndices, validIndices]);
diff --git a/x-pack/solutions/search/plugins/search_playground/public/routes.ts b/x-pack/solutions/search/plugins/search_playground/public/routes.ts
index 890a2b220ba8..8e79f41e1c30 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/routes.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/routes.ts
@@ -13,3 +13,9 @@ export const SEARCH_PLAYGROUND_CHAT_PATH = `${ROOT_PATH}chat`;
export const PLAYGROUND_CHAT_QUERY_PATH = `${SEARCH_PLAYGROUND_CHAT_PATH}/query`;
export const SEARCH_PLAYGROUND_SEARCH_PATH = `${ROOT_PATH}search`;
export const PLAYGROUND_SEARCH_QUERY_PATH = `${SEARCH_PLAYGROUND_SEARCH_PATH}/query`;
+export const SAVED_PLAYGROUND_BASE_PATH = `${ROOT_PATH}p/:playgroundId`;
+export const SAVED_PLAYGROUND_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/:pageMode?/:viewMode?`;
+export const SAVED_PLAYGROUND_CHAT_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/chat`;
+export const SAVED_PLAYGROUND_CHAT_QUERY_PATH = `${SAVED_PLAYGROUND_CHAT_PATH}/query`;
+export const SAVED_PLAYGROUND_SEARCH_PATH = `${SAVED_PLAYGROUND_BASE_PATH}/search`;
+export const SAVED_PLAYGROUND_SEARCH_QUERY_PATH = `${SAVED_PLAYGROUND_SEARCH_PATH}/query`;
diff --git a/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx b/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx
new file mode 100644
index 000000000000..e0642e261836
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/saved_playground.tsx
@@ -0,0 +1,24 @@
+/*
+ * 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 { SearchPlaygroundPageTemplate } from './layout/page_template';
+import { SavedPlayground } from './components/saved_playground/saved_playground';
+import { SavedPlaygroundFormProvider } from './providers/saved_playground_provider';
+import { useSavedPlaygroundParameters } from './hooks/use_saved_playground_parameters';
+
+export const SavedPlaygroundPage = () => {
+ const { playgroundId } = useSavedPlaygroundParameters();
+ return (
+
+
+
+
+
+ );
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/types.ts b/x-pack/solutions/search/plugins/search_playground/public/types.ts
index ea41f03af49c..020a28f4e623 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/types.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/types.ts
@@ -78,7 +78,6 @@ export enum PlaygroundFormFields {
indices = 'indices',
elasticsearchQuery = 'elasticsearch_query',
userElasticsearchQuery = 'user_elasticsearch_query',
- userElasticsearchQueryValidations = 'user_elasticsearch_query_validations',
summarizationModel = 'summarization_model',
sourceFields = 'source_fields',
docSize = 'doc_size',
@@ -91,22 +90,29 @@ export interface PlaygroundForm {
[PlaygroundFormFields.prompt]: string;
[PlaygroundFormFields.citations]: boolean;
[PlaygroundFormFields.indices]: string[];
- [PlaygroundFormFields.summarizationModel]: LLMModel;
+ [PlaygroundFormFields.summarizationModel]: LLMModel | undefined;
[PlaygroundFormFields.elasticsearchQuery]: { retriever: any }; // RetrieverContainer leads to "Type instantiation is excessively deep and possibly infinite" error
[PlaygroundFormFields.sourceFields]: { [index: string]: string[] };
[PlaygroundFormFields.docSize]: number;
[PlaygroundFormFields.queryFields]: { [index: string]: string[] };
[PlaygroundFormFields.searchQuery]: string;
[PlaygroundFormFields.userElasticsearchQuery]: string | null | undefined;
- [PlaygroundFormFields.userElasticsearchQueryValidations]: UserQueryValidations | undefined;
}
-export interface UserQueryValidations {
- isValid: boolean;
- isUserCustomized: boolean;
- userQueryErrors?: string[];
+enum SavedPlaygroundFields {
+ name = 'name',
}
+export type SavedPlaygroundFormFields = PlaygroundFormFields | SavedPlaygroundFields;
+export const SavedPlaygroundFormFields = { ...PlaygroundFormFields, ...SavedPlaygroundFields };
+export interface SavedPlaygroundForm extends PlaygroundForm {
+ [SavedPlaygroundFields.name]: string;
+}
+
+export type SavedPlaygroundFormFetchError = SavedPlaygroundForm & {
+ error: Error;
+};
+
export interface Message {
id: string;
content: string | ReactNode;
@@ -258,3 +264,9 @@ export interface PlaygroundRouterParameters {
pageMode: PlaygroundPageMode;
viewMode?: PlaygroundViewMode;
}
+
+export interface SavedPlaygroundRouterParameters {
+ playgroundId: string;
+ pageMode?: PlaygroundPageMode;
+ viewMode?: PlaygroundViewMode;
+}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts
new file mode 100644
index 000000000000..06a220f55771
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.test.ts
@@ -0,0 +1,225 @@
+/*
+ * 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.
+ */
+
+const mockIsInferenceEndpointExists = jest.fn();
+jest.mock('@kbn/inference-endpoint-ui-common', () => ({
+ isInferenceEndpointExists: (http: HttpSetup, inferenceId: string) =>
+ mockIsInferenceEndpointExists(http, inferenceId),
+}));
+
+import {
+ OPENAI_CONNECTOR_ID,
+ OpenAiProviderType,
+ BEDROCK_CONNECTOR_ID,
+ GEMINI_CONNECTOR_ID,
+ INFERENCE_CONNECTOR_ID,
+} from '@kbn/stack-connectors-plugin/public/common';
+import { type ActionConnector } from '../types';
+
+import { parsePlaygroundConnectors } from './playground_connectors';
+import { HttpSetup } from '@kbn/core/public';
+
+describe('PlaygroundConnector utilities', () => {
+ const mockHttp = {} as unknown as HttpSetup;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsInferenceEndpointExists.mockResolvedValue(true);
+ });
+ describe('parsePlaygroundConnectors', () => {
+ it('should parse open ai connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '1',
+ actionTypeId: OPENAI_CONNECTOR_ID,
+ isMissingSecrets: false,
+ config: { apiProvider: OpenAiProviderType.OpenAi },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.gen-ai',
+ config: {
+ apiProvider: 'OpenAI',
+ },
+ id: '1',
+ isMissingSecrets: false,
+ title: 'OpenAI',
+ type: 'openai',
+ },
+ ]);
+ });
+ it('should parse preconfigured open ai connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '1',
+ actionTypeId: OPENAI_CONNECTOR_ID,
+ isMissingSecrets: false,
+ isPreconfigured: true,
+ name: 'OpenAI',
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.gen-ai',
+ id: '1',
+ isMissingSecrets: false,
+ isPreconfigured: true,
+ name: 'OpenAI',
+ title: 'OpenAI',
+ type: 'openai',
+ },
+ ]);
+ });
+ it('should parse azure open ai connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '3',
+ actionTypeId: OPENAI_CONNECTOR_ID,
+ isMissingSecrets: false,
+ config: { apiProvider: OpenAiProviderType.AzureAi },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.gen-ai',
+ config: {
+ apiProvider: 'Azure OpenAI',
+ },
+ id: '3',
+ isMissingSecrets: false,
+ title: 'OpenAI Azure',
+ type: 'openai_azure',
+ },
+ ]);
+ });
+ it('should parse other open ai connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '5',
+ actionTypeId: OPENAI_CONNECTOR_ID,
+ isMissingSecrets: false,
+ config: { apiProvider: OpenAiProviderType.Other },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.gen-ai',
+ config: {
+ apiProvider: 'Other',
+ },
+ id: '5',
+ isMissingSecrets: false,
+ title: 'OpenAI Other',
+ type: 'openai_other',
+ },
+ ]);
+ });
+ it('should parse bedrock connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '4',
+ actionTypeId: BEDROCK_CONNECTOR_ID,
+ isMissingSecrets: false,
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.bedrock',
+ id: '4',
+ isMissingSecrets: false,
+ title: 'Bedrock',
+ type: 'bedrock',
+ },
+ ]);
+ });
+ it('should parse gemini connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '7',
+ actionTypeId: GEMINI_CONNECTOR_ID,
+ isMissingSecrets: false,
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.gemini',
+ id: '7',
+ isMissingSecrets: false,
+ title: 'Gemini',
+ type: 'gemini',
+ },
+ ]);
+ });
+ it('should parse inference api connectors', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '6',
+ actionTypeId: INFERENCE_CONNECTOR_ID,
+ isMissingSecrets: false,
+ config: { inferenceId: 'unit-test', provider: 'openai', taskType: 'chat_completion' },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([
+ {
+ actionTypeId: '.inference',
+ config: {
+ inferenceId: 'unit-test',
+ provider: 'openai',
+ taskType: 'chat_completion',
+ },
+ id: '6',
+ isMissingSecrets: false,
+ title: 'AI Connector',
+ type: 'inference',
+ },
+ ]);
+ });
+ it('should not include inference api connectors with out endpoints', async () => {
+ mockIsInferenceEndpointExists.mockResolvedValue(false);
+ const connectors: ActionConnector[] = [
+ {
+ id: '6',
+ actionTypeId: INFERENCE_CONNECTOR_ID,
+ isMissingSecrets: false,
+ config: { inferenceId: 'unit-test', provider: 'openai', taskType: 'chat_completion' },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]);
+ });
+ it('should not include connectors with missing secrets', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '3',
+ actionTypeId: OPENAI_CONNECTOR_ID,
+ isMissingSecrets: true,
+ config: { apiProvider: OpenAiProviderType.AzureAi },
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]);
+ });
+ it('should not include connectors with out a transform defined', async () => {
+ const connectors: ActionConnector[] = [
+ {
+ id: '2',
+ actionTypeId: 'slack',
+ isMissingSecrets: false,
+ } as unknown as ActionConnector,
+ ];
+
+ expect(parsePlaygroundConnectors(connectors, mockHttp)).resolves.toStrictEqual([]);
+ });
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts
new file mode 100644
index 000000000000..97750c3fb3ad
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_connectors.ts
@@ -0,0 +1,146 @@
+/*
+ * 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 {
+ OPENAI_CONNECTOR_ID,
+ OpenAiProviderType,
+ BEDROCK_CONNECTOR_ID,
+ GEMINI_CONNECTOR_ID,
+ INFERENCE_CONNECTOR_ID,
+} from '@kbn/stack-connectors-plugin/public/common';
+import type { HttpSetup } from '@kbn/core/public';
+import { i18n } from '@kbn/i18n';
+import { isSupportedConnector } from '@kbn/inference-common';
+import { isInferenceEndpointExists } from '@kbn/inference-endpoint-ui-common';
+import {
+ LLMs,
+ type ActionConnector,
+ type UserConfiguredActionConnector,
+ type PlaygroundConnector,
+ InferenceActionConnector,
+} from '../types';
+
+type OpenAIConnector = UserConfiguredActionConnector<
+ { apiProvider: OpenAiProviderType },
+ Record
+>;
+
+function isOpenAIConnector(connector: ActionConnector): connector is OpenAIConnector {
+ return connector.actionTypeId === OPENAI_CONNECTOR_ID;
+}
+
+const connectorTypeToLLM: Array<{
+ actionId: string;
+ actionProvider?: string;
+ match: (connector: ActionConnector) => boolean;
+ transform: (connector: ActionConnector) => PlaygroundConnector;
+}> = [
+ {
+ actionId: OPENAI_CONNECTOR_ID,
+ actionProvider: OpenAiProviderType.AzureAi,
+ match: (connector) =>
+ isOpenAIConnector(connector) && connector?.config?.apiProvider === OpenAiProviderType.AzureAi,
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', {
+ defaultMessage: 'OpenAI Azure',
+ }),
+ type: LLMs.openai_azure,
+ }),
+ },
+ {
+ actionId: OPENAI_CONNECTOR_ID,
+ match: (connector) =>
+ isOpenAIConnector(connector) &&
+ (connector?.config?.apiProvider === OpenAiProviderType.OpenAi ||
+ Boolean(connector.isPreconfigured)),
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', {
+ defaultMessage: 'OpenAI',
+ }),
+ type: LLMs.openai,
+ }),
+ },
+ {
+ actionId: OPENAI_CONNECTOR_ID,
+ actionProvider: OpenAiProviderType.Other,
+ match: (connector) =>
+ isOpenAIConnector(connector) && connector?.config?.apiProvider === OpenAiProviderType.Other,
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.openAIOtherConnectorTitle', {
+ defaultMessage: 'OpenAI Other',
+ }),
+ type: LLMs.openai_other,
+ }),
+ },
+ {
+ actionId: BEDROCK_CONNECTOR_ID,
+ match: (connector) => connector.actionTypeId === BEDROCK_CONNECTOR_ID,
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.bedrockConnectorTitle', {
+ defaultMessage: 'Bedrock',
+ }),
+ type: LLMs.bedrock,
+ }),
+ },
+ {
+ actionId: GEMINI_CONNECTOR_ID,
+ match: (connector) => connector.actionTypeId === GEMINI_CONNECTOR_ID,
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.geminiConnectorTitle', {
+ defaultMessage: 'Gemini',
+ }),
+ type: LLMs.gemini,
+ }),
+ },
+ {
+ actionId: INFERENCE_CONNECTOR_ID,
+ match: (connector) =>
+ connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector),
+ transform: (connector) => ({
+ ...connector,
+ title: i18n.translate('xpack.searchPlayground.aiConnectorTitle', {
+ defaultMessage: 'AI Connector',
+ }),
+ type: LLMs.inference,
+ }),
+ },
+];
+
+function isInferenceActionConnector(
+ connector: ActionConnector
+): connector is InferenceActionConnector {
+ return connector.actionTypeId === INFERENCE_CONNECTOR_ID && isSupportedConnector(connector);
+}
+
+export async function parsePlaygroundConnectors(
+ connectors: ActionConnector[],
+ http: HttpSetup
+): Promise {
+ const playgroundConnectors: PlaygroundConnector[] = [];
+ for (const connector of connectors) {
+ const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {};
+ if (transform === undefined) continue;
+ if (connector.isMissingSecrets) continue;
+ if (!isInferenceActionConnector(connector)) {
+ playgroundConnectors.push(transform(connector));
+ } else {
+ const connectorInferenceEndpointExists = await isInferenceEndpointExists(
+ http,
+ connector.config.inferenceId
+ );
+ if (connectorInferenceEndpointExists) {
+ playgroundConnectors.push(transform(connector));
+ }
+ }
+ }
+ return playgroundConnectors;
+}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts
new file mode 100644
index 000000000000..80b2d3d85ef1
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.test.ts
@@ -0,0 +1,264 @@
+/*
+ * 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 { ResolverOptions } from 'react-hook-form';
+import {
+ LLMs,
+ PlaygroundForm,
+ PlaygroundFormFields,
+ SavedPlaygroundForm,
+ SavedPlaygroundFormFields,
+} from '../types';
+import { playgroundFormResolver, savedPlaygroundFormResolver } from './playground_form_resolver';
+
+describe('Form Resolvers', () => {
+ const resolverContext = { http: {} };
+ const mockLLM = {
+ connectorId: 'connectorId1',
+ connectorName: 'OpenAI Connector',
+ connectorType: LLMs.openai,
+ disabled: false,
+ icon: expect.any(String),
+ id: 'connectorId1OpenAI GPT-4o ',
+ name: 'OpenAI GPT-4o ',
+ showConnectorName: false,
+ value: 'gpt-4o',
+ promptTokenLimit: 128000,
+ };
+ const validPlaygroundForm: PlaygroundForm = {
+ [PlaygroundFormFields.indices]: ['unitTest'],
+ [PlaygroundFormFields.queryFields]: {
+ unitTest: ['field1', 'field2'],
+ },
+ [PlaygroundFormFields.sourceFields]: {
+ unitTest: ['field1', 'field2'],
+ },
+ [PlaygroundFormFields.elasticsearchQuery]: { retriever: {} },
+ [PlaygroundFormFields.userElasticsearchQuery]: null,
+ [PlaygroundFormFields.prompt]: 'This is a prompt',
+ [PlaygroundFormFields.citations]: false,
+ [PlaygroundFormFields.docSize]: 3,
+ [PlaygroundFormFields.summarizationModel]: { ...mockLLM },
+ [PlaygroundFormFields.question]: 'Some question',
+ [PlaygroundFormFields.searchQuery]: 'search',
+ };
+
+ describe('playgroundFormResolver', () => {
+ const resolverOptions: ResolverOptions = {
+ criteriaMode: 'all',
+ fields: {},
+ shouldUseNativeValidation: false,
+ };
+ it('returns values when there are no errors', async () => {
+ expect(
+ playgroundFormResolver(validPlaygroundForm, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: validPlaygroundForm,
+ errors: {},
+ });
+ });
+ it('validates user Elasticsearch query', async () => {
+ const values = {
+ ...validPlaygroundForm,
+ [PlaygroundFormFields.userElasticsearchQuery]: 'invalid query',
+ };
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.userElasticsearchQuery]: {
+ type: 'value',
+ message: expect.any(String),
+ },
+ },
+ });
+ });
+ it('validates summarizationModel', async () => {
+ const values = {
+ ...validPlaygroundForm,
+ [PlaygroundFormFields.summarizationModel]: undefined,
+ };
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.summarizationModel]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ it('validates prompt', async () => {
+ const values = {
+ ...validPlaygroundForm,
+ [PlaygroundFormFields.prompt]: '',
+ };
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.prompt]: {
+ type: 'required',
+ },
+ },
+ });
+
+ values[PlaygroundFormFields.prompt] = ' ';
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.prompt]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ it('validates question', async () => {
+ const values = {
+ ...validPlaygroundForm,
+ [PlaygroundFormFields.question]: '',
+ };
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.question]: {
+ type: 'required',
+ },
+ },
+ });
+
+ values[PlaygroundFormFields.question] = ' ';
+ expect(
+ playgroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.question]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ });
+ describe('savedPlaygroundFormResolver', () => {
+ const resolverOptions: ResolverOptions = {
+ criteriaMode: 'all',
+ fields: {},
+ shouldUseNativeValidation: false,
+ };
+ const validSavedPlaygroundForm: SavedPlaygroundForm = {
+ ...validPlaygroundForm,
+ [SavedPlaygroundFormFields.name]: 'my saved playground',
+ };
+
+ it('returns values when there are no errors', async () => {
+ expect(
+ savedPlaygroundFormResolver(validSavedPlaygroundForm, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: validSavedPlaygroundForm,
+ errors: {},
+ });
+ });
+ it('validates user Elasticsearch query', async () => {
+ const values = {
+ ...validSavedPlaygroundForm,
+ [PlaygroundFormFields.userElasticsearchQuery]: 'invalid query',
+ };
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.userElasticsearchQuery]: {
+ type: 'value',
+ message: expect.any(String),
+ },
+ },
+ });
+ });
+
+ it('validates summarizationModel', async () => {
+ const values = {
+ ...validSavedPlaygroundForm,
+ [PlaygroundFormFields.summarizationModel]: undefined,
+ };
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.summarizationModel]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ it('validates prompt', async () => {
+ const values = {
+ ...validSavedPlaygroundForm,
+ [PlaygroundFormFields.prompt]: '',
+ };
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.prompt]: {
+ type: 'required',
+ },
+ },
+ });
+
+ values[PlaygroundFormFields.prompt] = ' ';
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.prompt]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ it('validates question', async () => {
+ const values = {
+ ...validSavedPlaygroundForm,
+ [PlaygroundFormFields.question]: '',
+ };
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.question]: {
+ type: 'required',
+ },
+ },
+ });
+
+ values[PlaygroundFormFields.question] = ' ';
+ expect(
+ savedPlaygroundFormResolver(values, resolverContext, resolverOptions)
+ ).resolves.toStrictEqual({
+ values: {},
+ errors: {
+ [PlaygroundFormFields.question]: {
+ type: 'required',
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts
new file mode 100644
index 000000000000..5bd5d77a7e57
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/playground_form_resolver.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { FieldErrors, Resolver, ResolverOptions } from 'react-hook-form';
+import { type PlaygroundForm, type SavedPlaygroundForm, PlaygroundFormFields } from '../types';
+import { validateUserElasticsearchQuery } from './user_query';
+
+const REQUIRED_ERROR = { type: 'required' };
+
+const hasErrors = (errors: FieldErrors): boolean => Object.keys(errors).length > 0;
+
+export const playgroundFormResolver: Resolver = async (values) => {
+ const errors: FieldErrors = {};
+
+ if (!values[PlaygroundFormFields.summarizationModel]) {
+ errors[PlaygroundFormFields.summarizationModel] = REQUIRED_ERROR;
+ }
+ if (!values[PlaygroundFormFields.prompt]) {
+ errors[PlaygroundFormFields.prompt] = REQUIRED_ERROR;
+ } else if (!values[PlaygroundFormFields.prompt].trim()) {
+ errors[PlaygroundFormFields.prompt] = REQUIRED_ERROR;
+ }
+ if (!values[PlaygroundFormFields.question]) {
+ errors[PlaygroundFormFields.question] = REQUIRED_ERROR;
+ } else if (!values[PlaygroundFormFields.question].trim()) {
+ errors[PlaygroundFormFields.question] = REQUIRED_ERROR;
+ }
+ const userQueryError = validateUserElasticsearchQuery(
+ values[PlaygroundFormFields.userElasticsearchQuery]
+ );
+ if (userQueryError) {
+ errors[PlaygroundFormFields.userElasticsearchQuery] = userQueryError;
+ }
+
+ if (hasErrors(errors)) {
+ return {
+ values: {},
+ errors,
+ };
+ }
+
+ return {
+ values,
+ errors: {},
+ };
+};
+
+export const savedPlaygroundFormResolver: Resolver = async (
+ values,
+ context,
+ options
+) => {
+ const baseResult = await playgroundFormResolver(
+ values,
+ context,
+ options as unknown as ResolverOptions
+ );
+
+ return baseResult;
+};
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts
new file mode 100644
index 000000000000..db652563552e
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/saved_playgrounds.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 { DEFAULT_CONTEXT_DOCUMENTS } from '../../common';
+import { DEFAULT_LLM_PROMPT } from '../../common/prompt';
+
+import {
+ LLMModel,
+ PlaygroundResponse,
+ PlaygroundSavedObject,
+ SavedPlaygroundForm,
+ SavedPlaygroundFormFetchError,
+ SavedPlaygroundFormFields,
+} from '../types';
+
+function parseSummarizationModel(
+ model: PlaygroundSavedObject['summarizationModel'],
+ models: LLMModel[]
+): LLMModel | undefined {
+ if (!model) {
+ return undefined;
+ }
+
+ if (model.modelId) {
+ const exactMatch = models.find(
+ (llm) => llm.connectorId === model.connectorId && llm.name === model.modelId
+ );
+ if (exactMatch) {
+ return exactMatch;
+ }
+ }
+ return models.find((llm) => llm.connectorId === model.connectorId);
+}
+
+export function parseSavedPlayground(
+ playground: PlaygroundResponse,
+ models: LLMModel[]
+): SavedPlaygroundForm {
+ return {
+ [SavedPlaygroundFormFields.name]: playground.data.name,
+ [SavedPlaygroundFormFields.indices]: playground.data.indices,
+ [SavedPlaygroundFormFields.queryFields]: playground.data.queryFields as {
+ [index: string]: string[];
+ },
+ [SavedPlaygroundFormFields.elasticsearchQuery]: JSON.parse(
+ playground.data.elasticsearchQueryJSON
+ ) as { retriever: any }, // TODO: replace with function
+ [SavedPlaygroundFormFields.userElasticsearchQuery]:
+ playground.data.userElasticsearchQueryJSON ?? null,
+ [SavedPlaygroundFormFields.prompt]: playground.data.prompt ?? DEFAULT_LLM_PROMPT,
+ [SavedPlaygroundFormFields.citations]: playground.data.citations ?? false,
+ [SavedPlaygroundFormFields.sourceFields]:
+ (playground.data.context?.sourceFields as
+ | {
+ [index: string]: string[];
+ }
+ | undefined) ?? {},
+ [SavedPlaygroundFormFields.docSize]:
+ playground.data.context?.docSize ?? DEFAULT_CONTEXT_DOCUMENTS,
+ [SavedPlaygroundFormFields.summarizationModel]: parseSummarizationModel(
+ playground.data.summarizationModel,
+ models
+ ),
+ [SavedPlaygroundFormFields.question]: '',
+ [SavedPlaygroundFormFields.searchQuery]: '',
+ };
+}
+
+export function fetchSavedPlaygroundError(e: unknown): SavedPlaygroundFormFetchError {
+ return {
+ [SavedPlaygroundFormFields.name]: '',
+ [SavedPlaygroundFormFields.indices]: [],
+ [SavedPlaygroundFormFields.queryFields]: {},
+ [SavedPlaygroundFormFields.elasticsearchQuery]: { retriever: {} },
+ [SavedPlaygroundFormFields.userElasticsearchQuery]: null,
+ [SavedPlaygroundFormFields.prompt]: '',
+ [SavedPlaygroundFormFields.citations]: false,
+ [SavedPlaygroundFormFields.sourceFields]: {},
+ [SavedPlaygroundFormFields.docSize]: 0,
+ [SavedPlaygroundFormFields.summarizationModel]: undefined,
+ [SavedPlaygroundFormFields.question]: '',
+ [SavedPlaygroundFormFields.searchQuery]: '',
+ error: e instanceof Error ? e : new Error(String(e)),
+ };
+}
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts
index 2215f48039f9..9d8d5b73093a 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.test.ts
@@ -5,27 +5,12 @@
* 2.0.
*/
-import { validateUserElasticSearchQuery, disableExecuteQuery } from './user_query';
+import { validateUserElasticsearchQuery, disableExecuteQuery } from './user_query';
describe('User Query utilities', () => {
- describe('validateUserElasticSearchQuery', () => {
- const sampleGeneratedElasticsearchQuery = {
- retriever: {
- standard: {
- query: {
- multi_match: {
- query: '{query}',
- fields: ['foo', 'bar', 'baz'],
- },
- },
- },
- },
- };
- it('should return false if userQuery is null', () => {
- expect(validateUserElasticSearchQuery(null, sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: false,
- });
+ describe('validateUserElasticsearchQuery', () => {
+ it('should return no error if userQuery is null', () => {
+ expect(validateUserElasticsearchQuery(null)).toEqual(undefined);
});
it('should return valid false if userQuery is not a valid JSON', () => {
const userQuery = `{
@@ -40,50 +25,27 @@ describe('User Query utilities', () => {
}
}
}`;
- expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: true,
+ expect(validateUserElasticsearchQuery(userQuery)).toEqual({
+ type: 'validate',
+ message: expect.any(String),
});
});
it('should return valid false if userQuery is empty', () => {
- expect(validateUserElasticSearchQuery('', sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: true,
- userQueryErrors: [expect.any(String)],
+ expect(validateUserElasticsearchQuery('')).toEqual({
+ type: 'required',
+ message: expect.any(String),
+ types: {
+ value: expect.any(String),
+ required: expect.any(String),
+ },
});
- expect(validateUserElasticSearchQuery(' ', sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: true,
- userQueryErrors: [expect.any(String)],
- });
- });
- it('should return customized false if queries are equal', () => {
- expect(
- validateUserElasticSearchQuery(
- JSON.stringify(sampleGeneratedElasticsearchQuery, null, 4),
- sampleGeneratedElasticsearchQuery
- )
- ).toEqual({
- isValid: true,
- isUserCustomized: false,
- });
- });
- it('should return customized false if queries are functionally equal', () => {
- const userQuery = `{
- "retriever": {
- "standard": {
- "query": {
- "multi_match": {
- "fields": ["foo", "bar", "baz"],
- "query": "{query}"
- }
- }
- }
- }
-}`;
- expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: true,
- isUserCustomized: false,
+ expect(validateUserElasticsearchQuery(' ')).toEqual({
+ type: 'required',
+ message: expect.any(String),
+ types: {
+ value: expect.any(String),
+ required: expect.any(String),
+ },
});
});
it('should return valid false if user query removes {query} placeholder', () => {
@@ -99,59 +61,41 @@ describe('User Query utilities', () => {
}
}
}`;
- expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: true,
- userQueryErrors: ['User query must contain "{query}"'],
+ expect(validateUserElasticsearchQuery(userQuery)).toEqual({
+ type: 'value',
+ message: expect.any(String),
});
});
it('should include {query} placeholder error even when query is not valid JSON', () => {
const userQuery = `invalid`;
- expect(validateUserElasticSearchQuery(userQuery, sampleGeneratedElasticsearchQuery)).toEqual({
- isValid: false,
- isUserCustomized: true,
- userQueryErrors: [expect.any(String)],
+ expect(validateUserElasticsearchQuery(userQuery)).toEqual({
+ type: 'value',
+ message: expect.any(String),
});
});
});
describe('disableExecuteQuery', () => {
it('should return true if query is null', () => {
- expect(disableExecuteQuery(undefined, null)).toEqual(true);
+ expect(disableExecuteQuery(false, null)).toEqual(true);
});
it('should return true if query is empty', () => {
- expect(disableExecuteQuery(undefined, '')).toEqual(true);
- expect(disableExecuteQuery(undefined, ' ')).toEqual(true);
+ expect(disableExecuteQuery(false, '')).toEqual(true);
+ expect(disableExecuteQuery(false, ' ')).toEqual(true);
});
it('should return true if query is undefined', () => {
- expect(disableExecuteQuery(undefined, undefined)).toEqual(true);
+ expect(disableExecuteQuery(false, undefined)).toEqual(true);
});
it('should return true if query is invalid', () => {
- const validations = {
- isValid: false,
- isUserCustomized: true,
- };
- expect(disableExecuteQuery(validations, 'test')).toEqual(true);
+ expect(disableExecuteQuery(false, 'test')).toEqual(true);
});
it('should return false if query is valid', () => {
- const validations = {
- isValid: true,
- isUserCustomized: true,
- };
- expect(disableExecuteQuery(validations, 'test')).toEqual(false);
+ expect(disableExecuteQuery(true, 'test')).toEqual(false);
});
it('should return false if query is valid and userCustomized is false', () => {
- const validations = {
- isValid: true,
- isUserCustomized: false,
- };
- expect(disableExecuteQuery(validations, 'test')).toEqual(false);
+ expect(disableExecuteQuery(true, 'test')).toEqual(false);
});
it('should return false if query is valid and userCustomized is true', () => {
- const validations = {
- isValid: true,
- isUserCustomized: true,
- };
- expect(disableExecuteQuery(validations, 'test')).toEqual(false);
+ expect(disableExecuteQuery(true, 'test')).toEqual(false);
});
});
});
diff --git a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts
index 7d97224f213e..a6bd36ad156c 100644
--- a/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts
+++ b/x-pack/solutions/search/plugins/search_playground/public/utils/user_query.ts
@@ -5,68 +5,73 @@
* 2.0.
*/
-import deepEqual from 'fast-deep-equal';
+import type { FieldError } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
-import type { PlaygroundForm, PlaygroundFormFields, UserQueryValidations } from '../types';
+import type { PlaygroundForm, PlaygroundFormFields } from '../types';
-export const validateUserElasticSearchQuery = (
- userQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery],
- elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery]
-): UserQueryValidations => {
- if (userQuery === null || userQuery === undefined || typeof userQuery !== 'string') {
- return { isValid: false, isUserCustomized: false };
+const USER_QUERY_PLACEHOLDER_MISSING_ERROR = i18n.translate(
+ 'xpack.searchPlayground.userQuery.errors.queryPlaceholder',
+ {
+ defaultMessage: 'Elasticsearch query must contain "{query}"',
+ values: { query: '{query}' },
}
- let userQueryErrors: string[] | undefined;
- if (!userQuery.includes('{query}')) {
- userQueryErrors = [
- i18n.translate('xpack.searchPlayground.userQuery.errors.queryPlaceholder', {
- defaultMessage: 'User query must contain "{query}"',
- values: { query: '{query}' },
- }),
- ];
+);
+const USER_QUERY_CANNOT_BE_EMPTY_ERROR = i18n.translate(
+ 'xpack.searchPlayground.userQuery.errors.queryCannotBeEmpty',
+ {
+ defaultMessage: 'Elasticsearch query cannot be empty',
}
+);
+const USER_QUERY_MUST_BE_VALID_JSON_ERROR = i18n.translate(
+ 'xpack.searchPlayground.userQuery.errors.queryMustBeValidJson',
+ {
+ defaultMessage: 'Elasticsearch query must be valid JSON',
+ }
+);
+
+export const validateUserElasticsearchQuery = (
+ userQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery]
+): FieldError | undefined => {
+ if (userQuery === null || userQuery === undefined || typeof userQuery !== 'string')
+ return undefined;
+
if (userQuery.trim().length === 0) {
- return { isValid: false, isUserCustomized: true, userQueryErrors };
- }
- let userQueryObject: {} = {};
- try {
- userQueryObject = JSON.parse(userQuery);
- } catch (e) {
- return { isValid: false, isUserCustomized: true, userQueryErrors };
- }
- if (deepEqual(userQueryObject, elasticsearchQuery)) {
- return { isValid: true, isUserCustomized: false };
- }
- if (userQueryErrors && userQueryErrors.length > 0) {
return {
- isValid: false,
- isUserCustomized: true,
- userQueryErrors,
+ type: 'required',
+ message: USER_QUERY_CANNOT_BE_EMPTY_ERROR,
+ types: {
+ value: USER_QUERY_PLACEHOLDER_MISSING_ERROR,
+ required: USER_QUERY_CANNOT_BE_EMPTY_ERROR,
+ },
};
}
- return { isValid: true, isUserCustomized: true };
+ if (!userQuery.includes('{query}')) {
+ return { type: 'value', message: USER_QUERY_PLACEHOLDER_MISSING_ERROR };
+ }
+ try {
+ JSON.parse(userQuery);
+ } catch (e) {
+ return { type: 'validate', message: USER_QUERY_MUST_BE_VALID_JSON_ERROR }; // return query must be valid JSON error
+ }
+ return undefined;
};
export const disableExecuteQuery = (
- validations: UserQueryValidations | undefined,
+ validElasticsearchQuery: boolean,
query: string | null | undefined
): boolean => {
- return (
- (validations?.isUserCustomized === true && validations?.isValid === false) ||
- !query ||
- query.trim().length === 0
- );
+ return !validElasticsearchQuery || !query || query.trim().length === 0;
};
export const elasticsearchQueryString = (
elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery],
userElasticsearchQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery],
- userElasticsearchQueryValidations: PlaygroundForm[PlaygroundFormFields.userElasticsearchQueryValidations]
+ userElasticsearchQueryError: FieldError | undefined
) => {
- if (!userElasticsearchQuery || userElasticsearchQueryValidations?.isUserCustomized === false) {
+ if (!userElasticsearchQuery) {
return JSON.stringify(elasticsearchQuery);
}
- if (userElasticsearchQueryValidations?.isValid === true) {
+ if (userElasticsearchQueryError === undefined) {
return userElasticsearchQuery;
}
return JSON.stringify(elasticsearchQuery);
@@ -75,12 +80,12 @@ export const elasticsearchQueryString = (
export const elasticsearchQueryObject = (
elasticsearchQuery: PlaygroundForm[PlaygroundFormFields.elasticsearchQuery],
userElasticsearchQuery: PlaygroundForm[PlaygroundFormFields.userElasticsearchQuery],
- userElasticsearchQueryValidations: PlaygroundForm[PlaygroundFormFields.userElasticsearchQueryValidations]
+ userElasticsearchQueryError: FieldError | undefined
): { retriever: any } => {
- if (!userElasticsearchQuery || userElasticsearchQueryValidations?.isUserCustomized === false) {
+ if (!userElasticsearchQuery) {
return elasticsearchQuery;
}
- if (userElasticsearchQueryValidations?.isValid === true) {
+ if (userElasticsearchQueryError === undefined) {
return JSON.parse(userElasticsearchQuery);
}
return elasticsearchQuery;
diff --git a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts
index fd3abb3eee7b..326fbb366277 100644
--- a/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts
+++ b/x-pack/solutions/search/plugins/search_playground/server/playground_saved_object/schema/v1/v1.ts
@@ -8,7 +8,7 @@
import { schema } from '@kbn/config-schema';
export const playgroundAttributesSchema = schema.object({
- name: schema.string(),
+ name: schema.string({ minLength: 1, maxLength: 50 }),
// Common fields
indices: schema.arrayOf(schema.string(), { minSize: 1 }),
queryFields: schema.recordOf(schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })),
diff --git a/x-pack/solutions/search/plugins/search_playground/tsconfig.json b/x-pack/solutions/search/plugins/search_playground/tsconfig.json
index b1cda6726494..a7ad7c531619 100644
--- a/x-pack/solutions/search/plugins/search_playground/tsconfig.json
+++ b/x-pack/solutions/search/plugins/search_playground/tsconfig.json
@@ -29,7 +29,6 @@
"@kbn/triggers-actions-ui-plugin",
"@kbn/langchain",
"@kbn/logging",
- "@kbn/react-kibana-context-render",
"@kbn/doc-links",
"@kbn/core-logging-server-mocks",
"@kbn/analytics",
diff --git a/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts b/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts
index c1a396bc02ce..54c03231f41a 100644
--- a/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts
+++ b/x-pack/solutions/search/plugins/serverless_search/public/navigation_tree.ts
@@ -101,6 +101,7 @@ export const navigationTree = ({ isAppRegistered }: ApplicationStart): Navigatio
defaultMessage: 'Playground',
}),
link: 'searchPlayground' as AppDeepLinkId,
+ breadcrumbStatus: 'hidden' as 'hidden',
}),
],
},
diff --git a/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts b/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts
new file mode 100644
index 000000000000..253c45359f01
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/apps/search_playground/saved_playgrounds.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+import { createPlayground, deletePlayground } from './utils/create_playground';
+
+const archivedBooksIndex = 'x-pack/test/functional_search/fixtures/search-books';
+
+export default function ({ getPageObjects, getService }: FtrProviderContext) {
+ const pageObjects = getPageObjects(['common', 'searchPlayground', 'solutionNavigation']);
+ const log = getService('log');
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+
+ const createIndices = async () => {
+ await esArchiver.load(archivedBooksIndex);
+ };
+ const deleteIndices = async () => {
+ await esArchiver.unload(archivedBooksIndex);
+ };
+
+ describe('Search Playground - Saved Playgrounds', function () {
+ let testPlaygroundId: string;
+ before(async () => {
+ await createIndices();
+ // Note: replace with creating playground via UI once thats supported
+ testPlaygroundId = await createPlayground(
+ {
+ name: 'FTR Search Playground',
+ indices: ['search-books'],
+ queryFields: { 'search-books': ['name', 'author'] },
+ elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["name","author"]}}}}}`,
+ },
+ { log, supertest }
+ );
+ });
+ after(async () => {
+ if (testPlaygroundId) {
+ await deletePlayground(testPlaygroundId, { log, supertest });
+ }
+ await deleteIndices();
+ });
+ describe('View a Saved Playground', function () {
+ it('should open saved playground', async () => {
+ expect(testPlaygroundId).not.to.be(undefined);
+
+ await pageObjects.common.navigateToUrl('searchPlayground', `p/${testPlaygroundId}`, {
+ shouldUseHashForSubUrl: false,
+ });
+ await pageObjects.searchPlayground.SavedPlaygroundPage.expectPlaygroundNameHeader(
+ 'FTR Search Playground'
+ );
+ const { solutionNavigation } = pageObjects;
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Build' });
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Playground' });
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({
+ text: 'FTR Search Playground',
+ });
+ });
+ it('should be able to search index', async () => {
+ // Should default to search mode for this playground
+ await pageObjects.searchPlayground.expectPageModeToBeSelected('search');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.hasModeSelectors();
+ // Preview mode enum shares data test subj with chat page mode
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectModeIsSelected('chatMode');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchBarToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('Neal');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('gibberish');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsNotToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clearSearchInput();
+ });
+ it('should have query mode', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.selectPageMode(
+ 'queryMode',
+ testPlaygroundId
+ );
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeComponentsToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsEmptyState();
+ });
+ it('should support changing fields to search', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('author');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name');
+ const queryEditorTextBefore =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorTextBefore).to.contain(`"author"`);
+ expect(queryEditorTextBefore).to.contain('"name"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', true);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected('name');
+
+ let queryEditorText =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorText).to.contain('"author"');
+ expect(queryEditorText).not.to.contain('"name"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', false);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', true);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected(
+ 'author'
+ );
+
+ queryEditorText =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorText).not.to.contain('"author"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', false);
+ });
+ it('should support running query in query mode', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.runQueryInQueryMode('atwood');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsCodeEditor();
+ });
+ });
+ });
+}
diff --git a/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts b/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts
new file mode 100644
index 000000000000..2d758fc1c8e7
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/apps/search_playground/utils/create_playground.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from 'expect';
+import type SuperTest from 'supertest';
+import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
+import {
+ type PlaygroundSavedObject,
+ APIRoutes,
+ ROUTE_VERSIONS,
+} from '@kbn/search-playground/common';
+import { ToolingLog } from '@kbn/tooling-log';
+
+function prefixApiRouteWithSpace(route: string, space?: string) {
+ if (!space) return route;
+ return `/s/${space}${route}`;
+}
+
+export async function createPlayground(
+ playground: PlaygroundSavedObject,
+ {
+ log,
+ supertest,
+ space,
+ }: {
+ log: ToolingLog;
+ supertest: SuperTest.Agent;
+ space?: string;
+ }
+): Promise {
+ const { body } = await supertest
+ .put(prefixApiRouteWithSpace(APIRoutes.PUT_PLAYGROUND_CREATE, space))
+ .set('kbn-xsrf', 'xxx')
+ .set(ELASTIC_HTTP_VERSION_HEADER, ROUTE_VERSIONS.v1)
+ .send(playground)
+ .expect(200);
+
+ expect(body).toBeDefined();
+ expect(body._meta).toBeDefined();
+ expect(body._meta.id).toBeDefined();
+
+ log.info(
+ `Created saved playground [${playground.name}] - ${space ? space + '/' : ''}${body._meta.id}`
+ );
+ log.debug(`Saved playground: ${JSON.stringify(playground)}`);
+ return body._meta.id;
+}
+
+export async function deletePlayground(
+ id: string,
+ {
+ log,
+ supertest,
+ space,
+ }: {
+ log: ToolingLog;
+ supertest: SuperTest.Agent;
+ space?: string;
+ }
+): Promise {
+ await supertest
+ .delete(prefixApiRouteWithSpace(APIRoutes.DELETE_PLAYGROUND.replace('{id}', id), space))
+ .set('kbn-xsrf', 'xxx')
+ .set(ELASTIC_HTTP_VERSION_HEADER, ROUTE_VERSIONS.v1)
+ .expect(200);
+ log.info(`Deleted saved playground [${space ? space + '/' : ''}${id}]`);
+}
diff --git a/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts b/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts
new file mode 100644
index 000000000000..70df5ec57dcd
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/apps/shared/solution_tour.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getPageObjects, getService }: FtrProviderContext) {
+ const PageObjects = getPageObjects(['common']);
+ const log = getService('log');
+ const testSubjects = getService('testSubjects');
+
+ describe('Close Solution Tour', function () {
+ // This is a test used to close the global solution tour when we
+ // use elasticsearch as the default solution. Solution tour is saved in a
+ // global setting index so we need to utilize the space tour close button
+ // or manually update the index.
+ it('should close the solution tour if its visible', async () => {
+ await PageObjects.common.navigateToApp('spaceSelector');
+ if (await testSubjects.exists('spaceSolutionTour')) {
+ log.info('Found the solution tour open, closing it');
+ await testSubjects.click('closeTourBtn'); // close the tour
+ await PageObjects.common.sleep(1000); // wait to save the setting
+ }
+ });
+ });
+}
diff --git a/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts b/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts
new file mode 100644
index 000000000000..707e9c4a9fee
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/config/config.feature_flags.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { FtrConfigProviderContext } from '@kbn/test';
+
+export default async function ({ readConfigFile }: FtrConfigProviderContext) {
+ const xpackFunctionalConfig = await readConfigFile(
+ require.resolve('@kbn/test-suites-xpack/functional/config.base')
+ );
+
+ return {
+ ...xpackFunctionalConfig.getAll(),
+ junit: {
+ reportName: 'Search Solution UI Functional Tests w/ Feature Flagged Features',
+ },
+ esTestCluster: {
+ ...xpackFunctionalConfig.get('esTestCluster'),
+ serverArgs: [
+ ...xpackFunctionalConfig.get('esTestCluster.serverArgs'),
+ 'xpack.security.enabled=true',
+ ],
+ },
+ kbnTestServer: {
+ ...xpackFunctionalConfig.get('kbnTestServer'),
+ serverArgs: [
+ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'),
+ '--xpack.spaces.defaultSolution=es', // Default to Search Solution
+ `--uiSettings.overrides.searchPlayground:searchModeEnabled=true`,
+ ],
+ },
+ // load tests in the index file
+ testFiles: [require.resolve('../index.feature_flags.ts')],
+ };
+}
diff --git a/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts b/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts
new file mode 100644
index 000000000000..3ab5c00f1c35
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/ftr_provider_context.d.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { GenericFtrProviderContext } from '@kbn/test';
+
+import { services } from '@kbn/test-suites-xpack/functional/services';
+import { pageObjects } from '@kbn/test-suites-xpack/functional/page_objects';
+
+export type FtrProviderContext = GenericFtrProviderContext;
diff --git a/x-pack/solutions/search/test/functional_search/index.feature_flags.ts b/x-pack/solutions/search/test/functional_search/index.feature_flags.ts
new file mode 100644
index 000000000000..7945e4037852
--- /dev/null
+++ b/x-pack/solutions/search/test/functional_search/index.feature_flags.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+/* eslint-disable import/no-default-export */
+
+import { FtrProviderContext } from './ftr_provider_context';
+
+export default ({ loadTestFile }: FtrProviderContext): void => {
+ describe('Search solution FF tests', function () {
+ // Shared test file to close the global solution tour
+ loadTestFile(require.resolve('./apps/shared/solution_tour'));
+ // add tests that require feature flags, defined in config.feature_flags.ts
+ loadTestFile(require.resolve('./apps/search_playground/saved_playgrounds'));
+ });
+};
diff --git a/x-pack/solutions/search/test/tsconfig.json b/x-pack/solutions/search/test/tsconfig.json
index 7da76ddbc301..e1dd392c45e9 100644
--- a/x-pack/solutions/search/test/tsconfig.json
+++ b/x-pack/solutions/search/test/tsconfig.json
@@ -27,5 +27,8 @@
"@kbn/enterprise-search-plugin",
"@kbn/test-suites-src",
"@kbn/guided-onboarding",
+ "@kbn/search-playground",
+ "@kbn/tooling-log",
+ "@kbn/test-suites-xpack",
]
}
diff --git a/x-pack/test/functional/page_objects/search_playground_page.ts b/x-pack/test/functional/page_objects/search_playground_page.ts
index 0cf09e0a4667..088add49ef09 100644
--- a/x-pack/test/functional/page_objects/search_playground_page.ts
+++ b/x-pack/test/functional/page_objects/search_playground_page.ts
@@ -220,12 +220,26 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
},
async expectChatWindowLoaded() {
expect(await testSubjects.getAttribute('viewModeSelector', 'disabled')).to.be(null);
- expect(await testSubjects.isEnabled('dataSourceActionButton')).to.be(true);
- expect(await testSubjects.isEnabled('viewCodeActionButton')).to.be(true);
-
- expect(await testSubjects.isEnabled('regenerateActionButton')).to.be(false);
- expect(await testSubjects.isEnabled('clearChatActionButton')).to.be(false);
- expect(await testSubjects.isEnabled('sendQuestionButton')).to.be(false);
+ expect(await testSubjects.isEnabled('dataSourceActionButton')).to.equal(
+ true,
+ 'dataSourceActionButton isEnabled should be true'
+ );
+ expect(await testSubjects.isEnabled('viewCodeActionButton')).to.equal(
+ true,
+ 'viewCodeActionButton isEnabled should be true'
+ );
+ expect(await testSubjects.isEnabled('regenerateActionButton')).to.equal(
+ false,
+ 'regenerateActionButton isEnabled should be false'
+ );
+ expect(await testSubjects.isEnabled('clearChatActionButton')).to.equal(
+ false,
+ 'clearChatActionButton isEnabled should be false'
+ );
+ expect(await testSubjects.isEnabled('sendQuestionButton')).to.equal(
+ false,
+ 'sendQuestionButton isEnabled should be false'
+ );
await testSubjects.existOrFail('questionInput');
const model = await testSubjects.find('summarizationModelSelect');
@@ -429,17 +443,29 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
const modeSelectedValue = await testSubjects.getAttribute(mode, 'aria-pressed');
expect(modeSelectedValue).to.be('true');
},
- async selectPageMode(mode: 'chatMode' | 'queryMode') {
+ async selectPageMode(mode: 'chatMode' | 'queryMode', playgroundId?: string) {
await testSubjects.existOrFail(mode);
await testSubjects.click(mode);
switch (mode) {
case 'queryMode':
- expect(await browser.getCurrentUrl()).contain('/app/search_playground/search/query');
+ expect(await browser.getCurrentUrl()).contain(
+ playgroundId
+ ? `/app/search_playground/p/${playgroundId}/search/query`
+ : '/app/search_playground/search/query'
+ );
break;
case 'chatMode':
const url = await browser.getCurrentUrl();
- expect(url).contain('/app/search_playground/search');
- expect(url).not.contain('/app/search_playground/search/query');
+ expect(url).contain(
+ playgroundId
+ ? `/app/search_playground/p/${playgroundId}/search`
+ : '/app/search_playground/search'
+ );
+ expect(url).not.contain(
+ playgroundId
+ ? `/app/search_playground/p/${playgroundId}/search/query`
+ : '/app/search_playground/search/query'
+ );
break;
}
},
@@ -540,5 +566,12 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
expect(queryResponse).to.contain(text);
},
},
+ SavedPlaygroundPage: {
+ async expectPlaygroundNameHeader(name: string) {
+ await testSubjects.existOrFail('playgroundName');
+ const nameTitle = await testSubjects.find('playgroundName');
+ expect(await nameTitle.getVisibleText()).to.be(name);
+ },
+ },
};
}
diff --git a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts
index acd369e19a18..40fd0debb74d 100644
--- a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts
+++ b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts
@@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./search_synonyms/search_synonyms_overview'));
loadTestFile(require.resolve('./search_synonyms/search_synonym_detail'));
loadTestFile(require.resolve('./search_playground/search_relevance'));
+ loadTestFile(require.resolve('./search_playground/saved_playgrounds'));
});
}
diff --git a/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts b/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts
new file mode 100644
index 000000000000..e79f1372e695
--- /dev/null
+++ b/x-pack/test_serverless/functional/test_suites/search/search_playground/saved_playgrounds.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 expect from '@kbn/expect';
+import type { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack-platform/api_integration_deployment_agnostic/services';
+import { FtrProviderContext } from '../../../ftr_provider_context';
+
+import { createPlayground, deletePlayground } from './utils/create_playground';
+
+const archivedBooksIndex = 'x-pack/test/functional_search/fixtures/search-books';
+
+export default function ({ getPageObjects, getService }: FtrProviderContext) {
+ const pageObjects = getPageObjects([
+ 'common',
+ 'svlCommonPage',
+ 'searchPlayground',
+ 'solutionNavigation',
+ ]);
+ const log = getService('log');
+ const roleScopedSupertest = getService('roleScopedSupertest');
+ const esArchiver = getService('esArchiver');
+
+ const createIndices = async () => {
+ await esArchiver.load(archivedBooksIndex);
+ };
+ const deleteIndices = async () => {
+ await esArchiver.unload(archivedBooksIndex);
+ };
+
+ describe('Search Playground - Saved Playgrounds', function () {
+ let supertest: SupertestWithRoleScopeType;
+ let testPlaygroundId: string;
+ before(async () => {
+ supertest = await roleScopedSupertest.getSupertestWithRoleScope('developer', {
+ useCookieHeader: true,
+ withInternalHeaders: true,
+ });
+ await createIndices();
+ // Note: replace with creating playground via UI once thats supported
+ testPlaygroundId = await createPlayground(
+ {
+ name: 'FTR Search Playground',
+ indices: ['search-books'],
+ queryFields: { 'search-books': ['name', 'author'] },
+ elasticsearchQueryJSON: `{"retriever":{"standard":{"query":{"multi_match":{"query":"{query}","fields":["name","author"]}}}}}`,
+ },
+ { log, supertest }
+ );
+
+ await pageObjects.svlCommonPage.loginWithRole('developer');
+ });
+ after(async () => {
+ if (testPlaygroundId) {
+ await deletePlayground(testPlaygroundId, { log, supertest });
+ }
+ await deleteIndices();
+ });
+ describe('View a Saved Playground', function () {
+ it('should open saved playground', async () => {
+ expect(testPlaygroundId).not.to.be(undefined);
+
+ await pageObjects.common.navigateToUrl('searchPlayground', `p/${testPlaygroundId}`, {
+ shouldUseHashForSubUrl: false,
+ });
+ await pageObjects.searchPlayground.SavedPlaygroundPage.expectPlaygroundNameHeader(
+ 'FTR Search Playground'
+ );
+ const { solutionNavigation } = pageObjects;
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Build' });
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({ text: 'Playground' });
+ await solutionNavigation.breadcrumbs.expectBreadcrumbExists({
+ text: 'FTR Search Playground',
+ });
+ });
+ it('should be able to search index', async () => {
+ // Should default to search mode for this playground
+ await pageObjects.searchPlayground.expectPageModeToBeSelected('search');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.hasModeSelectors();
+ // Preview mode enum shares data test subj with chat page mode
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectModeIsSelected('chatMode');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchBarToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('Neal');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.executeSearchQuery('gibberish');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectSearchResultsNotToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clearSearchInput();
+ });
+ it('should have query mode', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.selectPageMode(
+ 'queryMode',
+ testPlaygroundId
+ );
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeComponentsToExist();
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsEmptyState();
+ });
+ it('should support changing fields to search', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('author');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name');
+ const queryEditorTextBefore =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorTextBefore).to.contain(`"author"`);
+ expect(queryEditorTextBefore).to.contain('"name"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', true);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected('name');
+
+ let queryEditorText =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorText).to.contain('"author"');
+ expect(queryEditorText).not.to.contain('"name"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', false);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', true);
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldNotToBeSelected(
+ 'author'
+ );
+
+ queryEditorText =
+ await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
+ expect(queryEditorText).not.to.contain('"author"');
+
+ await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('author', false);
+ });
+ it('should support running query in query mode', async () => {
+ await pageObjects.searchPlayground.PlaygroundSearchPage.runQueryInQueryMode('atwood');
+ await pageObjects.searchPlayground.PlaygroundSearchPage.expectQueryModeResultsCodeEditor();
+ });
+ });
+ });
+}
diff --git a/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts b/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts
new file mode 100644
index 000000000000..767a935bad75
--- /dev/null
+++ b/x-pack/test_serverless/functional/test_suites/search/search_playground/utils/create_playground.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 expect from 'expect';
+import type SuperTest from 'supertest';
+import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
+import { ToolingLog } from '@kbn/tooling-log';
+import type { SupertestWithRoleScopeType } from '@kbn/test-suites-xpack-platform/api_integration_deployment_agnostic/services';
+
+function prefixApiRouteWithSpace(route: string, space?: string) {
+ if (!space) return route;
+ return `/s/${space}${route}`;
+}
+
+export async function createPlayground(
+ playground: any,
+ {
+ log,
+ supertest,
+ space,
+ }: {
+ log: ToolingLog;
+ supertest: SuperTest.Agent | SupertestWithRoleScopeType;
+ space?: string;
+ }
+): Promise {
+ const { body } = await supertest
+ .put(prefixApiRouteWithSpace(`/internal/search_playground/playgrounds`, space))
+ .set('kbn-xsrf', 'xxx')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .send(playground)
+ .expect(200);
+
+ expect(body).toBeDefined();
+ expect(body._meta).toBeDefined();
+ expect(body._meta.id).toBeDefined();
+
+ log.info(
+ `Created saved playground [${playground.name}] - ${space ? space + '/' : ''}${body._meta.id}`
+ );
+ log.debug(`Saved playground: ${JSON.stringify(playground)}`);
+ return body._meta.id;
+}
+
+export async function deletePlayground(
+ id: string,
+ {
+ log,
+ supertest,
+ space,
+ }: {
+ log: ToolingLog;
+ supertest: SuperTest.Agent | SupertestWithRoleScopeType;
+ space?: string;
+ }
+): Promise {
+ await supertest
+ .delete(prefixApiRouteWithSpace(`/internal/search_playground/playgrounds/${id}`, space))
+ .set('kbn-xsrf', 'xxx')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .expect(200);
+ log.info(`Deleted saved playground [${space ? space + '/' : ''}${id}]`);
+}