[Search][Playground] Query mode support for running search (#214482)

## Summary

Updated the Search Playground Query View to allow running the query and
seeing the JSON response.

### Screenshots
Empty State

![image](https://github.com/user-attachments/assets/1edb1ad8-5b5d-4069-a96f-4fbb0f9212b4)

With Query Response:

![image](https://github.com/user-attachments/assets/8fe7b1c5-70b3-4b24-91e5-f948d91d83d0)


### 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
- [ ] [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>
This commit is contained in:
Rodney Norris 2025-03-21 08:59:51 -05:00 committed by GitHub
parent 605651259e
commit 5b504f8f2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1610 additions and 500 deletions

View file

@ -0,0 +1,51 @@
/*
* 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 { getErrorMessage } from './errors';
describe('getErrorMessage', () => {
it('should return the message if the input is an Error instance', () => {
const error = new Error('Test error message');
expect(getErrorMessage(error)).toBe('Test error message');
});
it('should return the message and cause message if the input is an Error instance with a cause', () => {
const error = new Error('Test error message');
error.cause = new Error('Test cause message');
expect(getErrorMessage(error)).toBe('Test error message. Caused by: Test cause message');
});
it('should return the input if it is a string', () => {
const errorMessage = 'Test error message';
expect(getErrorMessage(errorMessage)).toBe(errorMessage);
});
it('should return the message property if the input is an object with a message property', () => {
const errorObject = { message: 'Test error message' };
expect(getErrorMessage(errorObject)).toBe('Test error message');
});
it('should return the result of toString if the input is an object with a toString method', () => {
const errorObject = {
toString: () => 'Test error message',
};
expect(getErrorMessage(errorObject)).toBe('Test error message');
});
it('should return the string representation of the input if it is not an Error, string, or object with message/toString', () => {
const errorNumber = 12345;
expect(getErrorMessage(errorNumber)).toBe('12345');
});
it('should return "undefined" if the input is undefined', () => {
expect(getErrorMessage(undefined)).toBe('undefined');
});
it('should return "null" if the input is null', () => {
expect(getErrorMessage(null)).toBe('null');
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { i18n } from '@kbn/i18n';
export function getErrorMessage(e: unknown): string {
if (e instanceof Error) {
if (e.cause instanceof Error) {
return i18n.translate('xpack.searchPlayground.errorWithCauseMessage', {
defaultMessage: '{message}. Caused by: {causeMessage}',
values: {
message: e.message,
causeMessage: e.cause.message,
},
});
}
return e.message;
} else if (typeof e === 'string') {
return e;
} else if (typeof e === 'object' && e !== null && 'message' in e) {
return (e as { message: string }).message;
} else if (typeof e === 'object' && e !== null && 'toString' in e) {
return (e as { toString: () => string }).toString();
} else {
return String(e);
}
}

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
export type IndicesQuerySourceFields = Record<string, QuerySourceFields>;
export enum MessageRole {
@ -52,6 +52,7 @@ export enum APIRoutes {
GET_INDICES = '/internal/search_playground/indices',
POST_SEARCH_QUERY = '/internal/search_playground/search',
GET_INDEX_MAPPINGS = '/internal/search_playground/mappings',
POST_QUERY_TEST = '/internal/search_playground/query_test',
}
export enum LLMs {
@ -92,3 +93,7 @@ export interface Pagination {
size: number;
total: number;
}
export interface QueryTestResponse {
searchResponse: SearchResponse;
}

View file

@ -6,13 +6,12 @@
*/
import React, { useCallback } from 'react';
import { css } from '@emotion/react';
import { Route, Routes } from '@kbn/shared-ux-router';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useWatch } from 'react-hook-form';
import { PLUGIN_ID } from '../../common';
import { QueryMode } from './query_mode/query_mode';
import { SearchQueryMode } from './query_mode/search_query_mode';
import { ChatSetupPage } from './setup_page/chat_setup_page';
import { Header } from './header';
import { useLoadConnectors } from '../hooks/use_load_connectors';
@ -31,13 +30,6 @@ import {
SEARCH_PLAYGROUND_SEARCH_PATH,
} from '../routes';
const SectionStyle = css`
display: flex;
flex-grow: 1;
position: absolute;
inset: 0;
`;
export interface AppProps {
showDocs?: boolean;
}
@ -67,16 +59,6 @@ export const App: React.FC<AppProps> = ({ showDocs = false }) => {
hasConnectors: Boolean(connectors?.length),
});
const restrictedWidth =
pageMode === PlaygroundPageMode.search && viewMode === PlaygroundViewMode.preview;
const paddingSize =
pageMode === PlaygroundPageMode.search && viewMode === PlaygroundViewMode.preview
? 'xl'
: 'none';
const useSectionStyling = !(
pageMode === PlaygroundPageMode.search && viewMode === PlaygroundViewMode.preview
);
return (
<>
<Header
@ -85,39 +67,27 @@ export const App: React.FC<AppProps> = ({ showDocs = false }) => {
isActionsDisabled={showSetupPage}
onSelectPageModeChange={handlePageModeChange}
/>
<KibanaPageTemplate.Section
alignment="top"
restrictWidth={restrictedWidth}
grow
css={{
position: 'relative',
}}
contentProps={{ css: useSectionStyling ? SectionStyle : undefined }}
paddingSize={paddingSize}
className="eui-fullHeight"
>
<Routes>
{showSetupPage ? (
<>
<Route path={SEARCH_PLAYGROUND_CHAT_PATH} component={ChatSetupPage} />
{isSearchModeEnabled && (
<Route path={SEARCH_PLAYGROUND_SEARCH_PATH} component={SearchPlaygroundSetupPage} />
)}
</>
) : (
<>
<Route exact path={SEARCH_PLAYGROUND_CHAT_PATH} component={Chat} />
<Route exact path={PLAYGROUND_CHAT_QUERY_PATH} component={QueryMode} />
{isSearchModeEnabled && (
<>
<Route exact path={SEARCH_PLAYGROUND_SEARCH_PATH} component={SearchMode} />
<Route exact path={PLAYGROUND_SEARCH_QUERY_PATH} component={QueryMode} />
</>
)}
</>
)}
</Routes>
</KibanaPageTemplate.Section>
<Routes>
{showSetupPage ? (
<>
<Route path={SEARCH_PLAYGROUND_CHAT_PATH} component={ChatSetupPage} />
{isSearchModeEnabled && (
<Route path={SEARCH_PLAYGROUND_SEARCH_PATH} component={SearchPlaygroundSetupPage} />
)}
</>
) : (
<>
<Route exact path={SEARCH_PLAYGROUND_CHAT_PATH} component={Chat} />
<Route exact path={PLAYGROUND_CHAT_QUERY_PATH} component={QueryMode} />
{isSearchModeEnabled && (
<>
<Route exact path={SEARCH_PLAYGROUND_SEARCH_PATH} component={SearchMode} />
<Route exact path={PLAYGROUND_SEARCH_QUERY_PATH} component={SearchQueryMode} />
</>
)}
</>
)}
</Routes>
</>
);
};

View file

@ -35,6 +35,7 @@ import { QuestionInput } from './question_input';
import { TelegramIcon } from './telegram_icon';
import { transformFromChatMessages } from '../utils/transform_to_messages';
import { useUsageTracker } from '../hooks/use_usage_tracker';
import { PlaygroundBodySection } from './playground_body_section';
const buildFormData = (formData: ChatForm): ChatRequestData => ({
connector_id: formData[ChatFormFields.summarizationModel].connectorId!,
@ -112,135 +113,137 @@ export const Chat = () => {
}, [usageTracker]);
return (
<EuiForm
component="form"
css={{ display: 'flex', flexGrow: 1 }}
onSubmit={handleSubmit(onSubmit)}
data-test-subj="chatPage"
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem
grow={2}
css={{
paddingTop: euiTheme.size.l,
paddingBottom: euiTheme.size.l,
// don't allow the chat to shrink below 66.6% of the screen
flexBasis: 0,
minWidth: '66.6%',
}}
>
<EuiFlexGroup direction="column" className="eui-fullHeight">
{/* // Set scroll at the border of parent element*/}
<EuiFlexGroup
direction="column"
className="eui-yScroll"
css={{ paddingLeft: euiTheme.size.l, paddingRight: euiTheme.size.l }}
tabIndex={0}
ref={messagesRef}
>
<MessageList messages={chatMessages} />
</EuiFlexGroup>
<EuiFlexItem
grow={false}
css={{ paddingLeft: euiTheme.size.l, paddingRight: euiTheme.size.l }}
>
<EuiHorizontalRule margin="none" />
<EuiSpacer size="s" />
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="sparkles"
disabled={isToolBarActionsDisabled}
onClick={regenerateMessages}
size="xs"
data-test-subj="regenerateActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.chat.regenerateBtn"
defaultMessage="Regenerate"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="refresh"
disabled={isToolBarActionsDisabled}
onClick={handleClearChat}
size="xs"
data-test-subj="clearChatActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.chat.clearChatBtn"
defaultMessage="Clear chat"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<PlaygroundBodySection>
<EuiForm
component="form"
css={{ display: 'flex', flexGrow: 1 }}
onSubmit={handleSubmit(onSubmit)}
data-test-subj="chatPage"
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem
grow={2}
css={{
paddingTop: euiTheme.size.l,
paddingBottom: euiTheme.size.l,
// don't allow the chat to shrink below 66.6% of the screen
flexBasis: 0,
minWidth: '66.6%',
}}
>
<EuiFlexGroup direction="column" className="eui-fullHeight">
{/* // Set scroll at the border of parent element*/}
<EuiFlexGroup
direction="column"
className="eui-yScroll"
css={{ paddingLeft: euiTheme.size.l, paddingRight: euiTheme.size.l }}
tabIndex={0}
ref={messagesRef}
>
<MessageList messages={chatMessages} />
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexItem
grow={false}
css={{ paddingLeft: euiTheme.size.l, paddingRight: euiTheme.size.l }}
>
<EuiHorizontalRule margin="none" />
<Controller
name={ChatFormFields.question}
control={control}
defaultValue=""
rules={{
required: true,
validate: (rule) => !!rule?.trim(),
}}
render={({ field }) => (
<QuestionInput
value={field.value}
onChange={field.onChange}
isDisabled={isSubmitting || isRegenerating}
button={
isSubmitting || isRegenerating ? (
<EuiButtonIcon
data-test-subj="stopRequestButton"
aria-label={i18n.translate(
'xpack.searchPlayground.chat.stopButtonAriaLabel',
{
defaultMessage: 'Stop request',
}
)}
display="base"
size="s"
iconType="stop"
onClick={handleStopRequest}
/>
) : (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.searchPlayground.chat.sendButtonAriaLabel',
{
defaultMessage: 'Send a question',
}
)}
display={isValid ? 'base' : 'empty'}
size="s"
type="submit"
isLoading={isSubmitting}
isDisabled={!isValid}
iconType={TelegramIcon}
data-test-subj="sendQuestionButton"
/>
)
}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar />
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="sparkles"
disabled={isToolBarActionsDisabled}
onClick={regenerateMessages}
size="xs"
data-test-subj="regenerateActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.chat.regenerateBtn"
defaultMessage="Regenerate"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="refresh"
disabled={isToolBarActionsDisabled}
onClick={handleClearChat}
size="xs"
data-test-subj="clearChatActionButton"
>
<FormattedMessage
id="xpack.searchPlayground.chat.clearChatBtn"
defaultMessage="Clear chat"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<Controller
name={ChatFormFields.question}
control={control}
defaultValue=""
rules={{
required: true,
validate: (rule) => !!rule?.trim(),
}}
render={({ field }) => (
<QuestionInput
value={field.value}
onChange={field.onChange}
isDisabled={isSubmitting || isRegenerating}
button={
isSubmitting || isRegenerating ? (
<EuiButtonIcon
data-test-subj="stopRequestButton"
aria-label={i18n.translate(
'xpack.searchPlayground.chat.stopButtonAriaLabel',
{
defaultMessage: 'Stop request',
}
)}
display="base"
size="s"
iconType="stop"
onClick={handleStopRequest}
/>
) : (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.searchPlayground.chat.sendButtonAriaLabel',
{
defaultMessage: 'Send a question',
}
)}
display={isValid ? 'base' : 'empty'}
size="s"
type="submit"
isLoading={isSubmitting}
isDisabled={!isValid}
iconType={TelegramIcon}
data-test-subj="sendQuestionButton"
/>
)
}
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiHideFor>
</EuiFlexGroup>
</EuiForm>
<EuiHideFor sizes={['xs', 's']}>
<EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar />
</EuiFlexItem>
</EuiHideFor>
</EuiFlexGroup>
</EuiForm>
</PlaygroundBodySection>
);
};

View file

@ -0,0 +1,42 @@
/*
* 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, { CSSProperties } from 'react';
import { css } from '@emotion/react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
const PlaygroundBodySectionStyle = css({
display: 'flex',
flexGrow: 1,
position: 'absolute',
inset: 0,
});
export const PlaygroundBodySection = ({
color,
children,
dataTestSubj,
}: {
color?: CSSProperties['backgroundColor'];
children: React.ReactNode | React.ReactNode[];
dataTestSubj?: string;
}) => (
<KibanaPageTemplate.Section
alignment="top"
grow
css={{
position: 'relative',
backgroundColor: color,
}}
contentProps={{ css: PlaygroundBodySectionStyle }}
paddingSize="none"
className="eui-fullHeight"
data-test-subj={dataTestSubj}
>
{children}
</KibanaPageTemplate.Section>
);

View file

@ -0,0 +1,154 @@
/*
* 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 {
EuiAccordion,
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPanel,
EuiSpacer,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../common/doc_links';
import type { ChatForm, ChatFormFields, QuerySourceFields } from '../../types';
const isQueryFieldSelected = (
queryFields: ChatForm[ChatFormFields.queryFields],
index: string,
field: string
): boolean => {
return Boolean(queryFields[index]?.includes(field));
};
export interface QueryFieldsPanelProps {
index: string;
indexFields: QuerySourceFields;
updateFields: (index: string, fieldName: string, checked: boolean) => void;
queryFields: ChatForm[ChatFormFields.queryFields];
}
export const QueryFieldsPanel = ({
index,
indexFields,
updateFields,
queryFields,
}: QueryFieldsPanelProps) => {
const queryTableFields = useMemo(
() =>
[
...indexFields.semantic_fields,
...indexFields.elser_query_fields,
...indexFields.dense_vector_query_fields,
...indexFields.bm25_query_fields,
].map((field) => ({
name: typeof field === 'string' ? field : field.field,
checked: isQueryFieldSelected(
queryFields,
index,
typeof field === 'string' ? field : field.field
),
})),
[index, indexFields, queryFields]
);
return (
<EuiPanel
grow={false}
hasShadow={false}
hasBorder
data-test-subj={`${index}-query-fields-panel`}
>
<EuiAccordion
id={index}
buttonContent={
<EuiText>
<h5>{index}</h5>
</EuiText>
}
initialIsOpen
data-test-subj={`${index}-fieldsAccordion`}
>
<EuiSpacer size="s" />
<EuiBasicTable
tableCaption={i18n.translate('xpack.searchPlayground.viewQuery.flyout.table.caption', {
defaultMessage: 'Query Model table',
})}
items={queryTableFields}
rowHeader="name"
columns={[
{
field: 'name',
name: i18n.translate(
'xpack.searchPlayground.viewQuery.flyout.table.column.field.name',
{ defaultMessage: 'Field' }
),
'data-test-subj': 'fieldName',
},
{
field: 'checked',
name: i18n.translate(
'xpack.searchPlayground.viewQuery.flyout.table.column.enabled.name',
{ defaultMessage: 'Enabled' }
),
align: 'right',
render: (checked, field) => {
return (
<EuiSwitch
showLabel={false}
label={field.name}
checked={checked}
onChange={(e) => updateFields(index, field.name, e.target.checked)}
compressed
data-test-subj={`field-${field.name}-${checked}`}
/>
);
},
},
]}
/>
{indexFields.skipped_fields > 0 && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s" color="subdued" data-test-subj={`${index}-skippedFields`}>
<EuiIcon type="eyeClosed" />
{` `}
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.hiddenFields"
defaultMessage="{skippedFields} fields are hidden."
values={{ skippedFields: indexFields.skipped_fields }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
href={docLinks.hiddenFields}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreLink"
defaultMessage="Learn more."
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiAccordion>
</EuiPanel>
);
};

View file

@ -28,6 +28,8 @@ import { ChatForm, ChatFormFields } from '../../types';
import { AnalyticsEvents } from '../../analytics/constants';
import { docLinks } from '../../../common/doc_links';
import { createQuery } from '../../utils/create_query';
import { PlaygroundBodySection } from '../playground_body_section';
import { QueryViewSidebarContainer, QueryViewContainer } from './styles';
const isQueryFieldSelected = (
queryFields: ChatForm[ChatFormFields.queryFields],
@ -72,134 +74,128 @@ export const QueryMode: React.FC = () => {
const query = useMemo(() => JSON.stringify(elasticsearchQuery, null, 2), [elasticsearchQuery]);
return (
<EuiFlexGroup>
<EuiFlexItem
grow={6}
className="eui-yScroll"
css={{ padding: euiTheme.size.l, paddingRight: 0 }}
>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="none"
lineNumbers
transparentBackground
data-test-subj="ViewElasticsearchQueryResult"
>
{query}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem
grow={3}
className="eui-yScroll"
css={{ padding: euiTheme.size.l, paddingLeft: 0 }}
>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.table.title"
defaultMessage="Fields to search (per index)"
/>
</h5>
</EuiText>
{Object.entries(fields).map(([index, group], indexNum) => (
<EuiFlexItem grow={false} key={index}>
<EuiPanel grow={false} hasShadow={false} hasBorder>
<EuiAccordion
id={index}
buttonContent={
<EuiText>
<h5>{index}</h5>
</EuiText>
}
initialIsOpen
data-test-subj={`fieldsAccordion-${indexNum}`}
>
<EuiSpacer size="s" />
<PlaygroundBodySection dataTestSubj="queryModeSection">
<EuiFlexGroup>
<EuiFlexItem grow={6} className="eui-yScroll" css={QueryViewContainer(euiTheme)}>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="none"
lineNumbers
transparentBackground
data-test-subj="ViewElasticsearchQueryResult"
>
{query}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem grow={3} className="eui-yScroll" css={QueryViewSidebarContainer(euiTheme)}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.table.title"
defaultMessage="Fields to search (per index)"
/>
</h5>
</EuiText>
{Object.entries(fields).map(([index, group], indexNum) => (
<EuiFlexItem grow={false} key={index}>
<EuiPanel grow={false} hasShadow={false} hasBorder>
<EuiAccordion
id={index}
buttonContent={
<EuiText>
<h5>{index}</h5>
</EuiText>
}
initialIsOpen
data-test-subj={`fieldsAccordion-${indexNum}`}
>
<EuiSpacer size="s" />
<EuiBasicTable
tableCaption="Query Model table"
items={[
...group.semantic_fields,
...group.elser_query_fields,
...group.dense_vector_query_fields,
...group.bm25_query_fields,
].map((field) => ({
name: typeof field === 'string' ? field : field.field,
checked: isQueryFieldSelected(
queryFields,
index,
typeof field === 'string' ? field : field.field
),
}))}
rowHeader="name"
columns={[
{
field: 'name',
name: 'Field',
'data-test-subj': 'fieldName',
},
{
field: 'checked',
name: 'Enabled',
align: 'right',
render: (checked, field) => {
return (
<EuiSwitch
showLabel={false}
label={field.name}
checked={checked}
onChange={(e) => updateFields(index, field.name, e.target.checked)}
compressed
data-test-subj={`field-${field.name}-${checked}`}
/>
);
<EuiBasicTable
tableCaption="Query Model table"
items={[
...group.semantic_fields,
...group.elser_query_fields,
...group.dense_vector_query_fields,
...group.bm25_query_fields,
].map((field) => ({
name: typeof field === 'string' ? field : field.field,
checked: isQueryFieldSelected(
queryFields,
index,
typeof field === 'string' ? field : field.field
),
}))}
rowHeader="name"
columns={[
{
field: 'name',
name: 'Field',
'data-test-subj': 'fieldName',
},
},
]}
/>
{
field: 'checked',
name: 'Enabled',
align: 'right',
render: (checked, field) => {
return (
<EuiSwitch
showLabel={false}
label={field.name}
checked={checked}
onChange={(e) => updateFields(index, field.name, e.target.checked)}
compressed
data-test-subj={`field-${field.name}-${checked}`}
/>
);
},
},
]}
/>
{group.skipped_fields > 0 && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText
size="s"
color="subdued"
data-test-subj={`skippedFields-${indexNum}`}
>
<EuiIcon type="eyeClosed" />
{` `}
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.hiddenFields"
defaultMessage="{skippedFields} fields are hidden."
values={{ skippedFields: group.skipped_fields }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
href={docLinks.hiddenFields}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreLink"
defaultMessage="Learn more."
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{group.skipped_fields > 0 && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText
size="s"
color="subdued"
data-test-subj={`skippedFields-${indexNum}`}
>
<EuiIcon type="eyeClosed" />
{` `}
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.hiddenFields"
defaultMessage="{skippedFields} fields are hidden."
values={{ skippedFields: group.skipped_fields }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
href={docLinks.hiddenFields}
target="_blank"
data-test-subj="hidden-fields-documentation-link"
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.learnMoreLink"
defaultMessage="Learn more."
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</PlaygroundBodySection>
);
};

View file

@ -0,0 +1,82 @@
/*
* 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 { EuiFlexGroup, EuiSplitPanel, EuiText, useEuiTheme } from '@elastic/eui';
import { CodeEditor } from '@kbn/code-editor';
import { FormattedMessage } from '@kbn/i18n-react';
import { getErrorMessage } from '../../../common/errors';
import { FullHeight, QueryViewTitlePanel } from './styles';
import { QueryTestResponse } from '../../types';
export interface ElasticsearchQueryOutputProps {
queryResponse?: QueryTestResponse;
queryError?: unknown;
isError: boolean;
isLoading: boolean;
}
export const ElasticsearchQueryOutput = ({
queryResponse,
isError,
queryError,
}: ElasticsearchQueryOutputProps) => {
const { euiTheme } = useEuiTheme();
const respJSON = useMemo(() => {
if (isError) {
return getErrorMessage(queryError);
}
return queryResponse ? JSON.stringify(queryResponse.searchResponse, null, 2) : undefined;
}, [isError, queryError, queryResponse]);
return (
<EuiSplitPanel.Outer hasBorder css={FullHeight} grow={false}>
<EuiSplitPanel.Inner grow={false} css={QueryViewTitlePanel(euiTheme)}>
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.queryOutput.title"
defaultMessage="Query output"
/>
</h5>
</EuiText>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner paddingSize="none">
{!!respJSON ? (
<CodeEditor
dataTestSubj="ViewElasticsearchQueryResponse"
languageId="json"
value={respJSON}
options={{
automaticLayout: true,
readOnly: true,
}}
enableFindAction
fullWidth
isCopyable
/>
) : (
<EuiFlexGroup
alignItems="center"
justifyContent="center"
css={FullHeight}
data-test-subj="ViewElasticsearchQueryResponseEmptyState"
>
<EuiText>
<p>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.queryOutput.emptyPrompt.body"
defaultMessage="Run your query above to view the raw JSON output here."
/>
</p>
</EuiText>
</EuiFlexGroup>
)}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);
};

View file

@ -0,0 +1,100 @@
/*
* 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 } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiPanel, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useController, useWatch } from 'react-hook-form';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { ChatForm, ChatFormFields } from '../../types';
import { AnalyticsEvents } from '../../analytics/constants';
import { createQuery } from '../../utils/create_query';
import { SearchQuery } from './search_query';
import { QueryFieldsPanel } from './query_fields_panel';
export interface QuerySidePanelProps {
executeQuery: () => void;
}
export const QuerySidePanel = ({ executeQuery }: QuerySidePanelProps) => {
const usageTracker = useUsageTracker();
const { fields } = useSourceIndicesFields();
const sourceFields = useWatch<ChatForm, ChatFormFields.sourceFields>({
name: ChatFormFields.sourceFields,
});
const {
field: { onChange: queryFieldsOnChange, value: queryFields },
} = useController<ChatForm, ChatFormFields.queryFields>({
name: ChatFormFields.queryFields,
});
const {
field: { onChange: elasticsearchQueryChange },
} = useController<ChatForm, ChatFormFields.elasticsearchQuery>({
name: ChatFormFields.elasticsearchQuery,
});
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
executeQuery();
},
[executeQuery]
);
const updateFields = useCallback(
(index: string, fieldName: string, checked: boolean) => {
const currentIndexFields = checked
? [...queryFields[index], fieldName]
: queryFields[index].filter((field) => fieldName !== field);
const updatedQueryFields = { ...queryFields, [index]: currentIndexFields };
queryFieldsOnChange(updatedQueryFields);
elasticsearchQueryChange(createQuery(updatedQueryFields, sourceFields, fields));
usageTracker?.count(AnalyticsEvents.queryFieldsUpdated, currentIndexFields.length);
},
[elasticsearchQueryChange, fields, queryFields, queryFieldsOnChange, sourceFields, usageTracker]
);
return (
<EuiPanel color="subdued" hasShadow={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.sideBar.query.title"
defaultMessage="Query"
/>
</h5>
</EuiText>
<EuiPanel grow={false} hasShadow={false} hasBorder>
<EuiForm component="form" onSubmit={handleSearch}>
<SearchQuery />
</EuiForm>
</EuiPanel>
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.flyout.table.title"
defaultMessage="Fields to search (per index)"
/>
</h5>
</EuiText>
{Object.entries(fields).map(([index, group]) => (
<EuiFlexItem grow={false} key={index}>
<QueryFieldsPanel
index={index}
indexFields={group}
updateFields={updateFields}
queryFields={queryFields}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,100 @@
/*
* 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 {
EuiBadge,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { CodeEditor } from '@kbn/code-editor';
import { useController } from 'react-hook-form';
import { ChatForm, ChatFormFields } from '../../types';
import { FullHeight, QueryViewTitlePanel } from './styles';
export const ElasticsearchQueryViewer = ({
executeQuery,
isLoading,
}: {
executeQuery: Function;
isLoading: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const {
field: { value: elasticsearchQuery },
} = useController<ChatForm, ChatFormFields.elasticsearchQuery>({
name: ChatFormFields.elasticsearchQuery,
});
const query = useMemo(() => JSON.stringify(elasticsearchQuery, null, 2), [elasticsearchQuery]);
return (
<EuiSplitPanel.Outer grow hasBorder css={FullHeight}>
<EuiSplitPanel.Inner grow={false} css={QueryViewTitlePanel(euiTheme)}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiText>
<h5>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.queryViewer.title"
defaultMessage="Query"
/>
</h5>
</EuiText>
<EuiBadge>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.elasticGenerated.badge"
defaultMessage="Elastic-generated"
/>
</EuiBadge>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
size="s"
color="primary"
iconSide="left"
iconType="play"
data-test-subj="RunElasticsearchQueryButton"
onClick={() => executeQuery()}
isLoading={isLoading}
aria-label={i18n.translate('xpack.searchPlayground.viewQuery.runQuery.ariaLabel', {
defaultMessage: 'Run the elasticsearch query to view results.',
})}
>
<FormattedMessage
id="xpack.searchPlayground.viewQuery.runQuery.action"
defaultMessage="Run"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner paddingSize="none">
<CodeEditor
dataTestSubj="ViewElasticsearchQueryResult"
languageId="json"
value={query}
options={{
automaticLayout: true,
readOnly: true,
}}
enableFindAction
fullWidth
isCopyable
/>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFieldText } from '@elastic/eui';
import { Controller, useController, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { ChatForm, ChatFormFields } from '../../types';
export const SearchQuery = () => {
const { control } = useFormContext();
const {
field: { value: searchBarValue },
formState: { isSubmitting },
} = useController<ChatForm, ChatFormFields.searchQuery>({
name: ChatFormFields.searchQuery,
});
return (
<Controller
control={control}
name={ChatFormFields.searchQuery}
render={({ field }) => (
<EuiFieldText
data-test-subj="searchPlaygroundSearchModeFieldText"
prepend="{query}"
{...field}
value={searchBarValue}
icon="search"
fullWidth
placeholder={i18n.translate(
'xpack.searchPlayground.searchMode.queryView.searchBar.placeholder',
{ defaultMessage: 'Search for documents' }
)}
isLoading={isSubmitting}
/>
)}
/>
);
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { css } from '@emotion/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiResizableContainer,
EuiPanel,
useEuiTheme,
} from '@elastic/eui';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../../analytics/constants';
import { PlaygroundBodySection } from '../playground_body_section';
import { ElasticsearchQueryViewer } from './query_viewer';
import { ElasticsearchQueryOutput } from './query_output';
import { QuerySidePanel } from './query_side_panel';
import { useElasticsearchQuery } from '../../hooks/use_elasticsearch_query';
import { FullHeight, QueryViewContainer, QueryViewSidebarContainer } from './styles';
export const SearchQueryMode: React.FC = () => {
const { euiTheme } = useEuiTheme();
const usageTracker = useUsageTracker();
useEffect(() => {
usageTracker?.load(AnalyticsEvents.queryModeLoaded);
}, [usageTracker]);
const { executeQuery, data, error, isError, fetchStatus } = useElasticsearchQuery();
const isLoading = fetchStatus !== 'idle';
return (
<PlaygroundBodySection
color={euiTheme.colors.backgroundBasePlain}
dataTestSubj="queryModeSection"
>
<EuiFlexGroup>
<EuiFlexItem grow={6} css={QueryViewContainer(euiTheme)}>
<EuiPanel
paddingSize="none"
hasShadow={false}
css={css({
// This is needed to maintain the resizable container height when rendering output editor with larger content
height: '95%',
})}
>
<EuiResizableContainer direction="vertical" css={FullHeight}>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel initialSize={60} minSize="20%" tabIndex={0} paddingSize="none">
<ElasticsearchQueryViewer executeQuery={executeQuery} isLoading={isLoading} />
</EuiResizablePanel>
<EuiResizableButton accountForScrollbars="both" />
<EuiResizablePanel initialSize={40} minSize="25%" tabIndex={0} paddingSize="none">
<ElasticsearchQueryOutput
queryResponse={data}
queryError={error}
isError={isError}
isLoading={isLoading}
/>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={3} className="eui-yScroll" css={QueryViewSidebarContainer(euiTheme)}>
<QuerySidePanel executeQuery={executeQuery} />
</EuiFlexItem>
</EuiFlexGroup>
</PlaygroundBodySection>
);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { css } from '@emotion/react';
import { type EuiThemeComputed } from '@elastic/eui';
export const FullHeight = css({
height: '100%',
});
export const QueryViewContainer = (euiTheme: EuiThemeComputed<{}>) =>
css({
padding: euiTheme.size.l,
paddingRight: 0,
});
export const QueryViewSidebarContainer = (euiTheme: EuiThemeComputed<{}>) =>
css({
padding: euiTheme.size.l,
paddingLeft: 0,
});
export const QueryViewTitlePanel = (euiTheme: EuiThemeComputed<{}>) =>
css({
borderBottom: euiTheme.border.thin,
padding: `${euiTheme.size.s} ${euiTheme.size.base}`,
});

View file

@ -17,6 +17,7 @@ import React from 'react';
import { css } from '@emotion/react';
import { Controller, useController, useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useQueryClient } from '@tanstack/react-query';
import { DEFAULT_PAGINATION } from '../../../common';
import { ResultList } from './result_list';
@ -54,72 +55,84 @@ export const SearchMode: React.FC = () => {
};
return (
<EuiFlexGroup direction="row" justifyContent="center">
<EuiFlexItem
grow
css={css`
max-width: ${euiTheme.base * 48}px;
`}
>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiForm component="form" onSubmit={handleSubmit(() => handleSearch())}>
<Controller
control={control}
name={ChatFormFields.searchQuery}
render={({ field }) => (
<EuiFieldText
data-test-subj="searchPlaygroundSearchModeFieldText"
{...field}
value={searchBarValue}
icon="search"
fullWidth
placeholder={i18n.translate(
'xpack.searchPlayground.searchMode.searchBar.placeholder',
{ defaultMessage: 'Search for documents' }
)}
isLoading={isSubmitting}
/>
)}
/>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{searchQuery.query ? (
<ResultList
searchResults={results}
mappings={mappingData}
pagination={pagination}
onPaginationChange={onPagination}
/>
) : (
<EuiEmptyPrompt
iconType={'checkInCircleFilled'}
iconColor="success"
title={
<h2>
{i18n.translate('xpack.searchPlayground.searchMode.readyToSearch', {
defaultMessage: 'Ready to search',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.searchPlayground.searchMode.searchPrompt', {
defaultMessage:
'Type in a query in the search bar above or view the query we automatically created for you.',
})}
</p>
}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<KibanaPageTemplate.Section
alignment="top"
restrictWidth
grow
css={{
position: 'relative',
}}
paddingSize="xl"
className="eui-fullHeight"
data-test-subj="playground-search-section"
>
<EuiFlexGroup direction="row" justifyContent="center">
<EuiFlexItem
grow
css={css`
max-width: ${euiTheme.base * 48}px;
`}
>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiForm component="form" onSubmit={handleSubmit(() => handleSearch())}>
<Controller
control={control}
name={ChatFormFields.searchQuery}
render={({ field }) => (
<EuiFieldText
data-test-subj="searchPlaygroundSearchModeFieldText"
{...field}
value={searchBarValue}
icon="search"
fullWidth
placeholder={i18n.translate(
'xpack.searchPlayground.searchMode.searchBar.placeholder',
{ defaultMessage: 'Search for documents' }
)}
isLoading={isSubmitting}
/>
)}
/>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{searchQuery.query ? (
<ResultList
searchResults={results}
mappings={mappingData}
pagination={pagination}
onPaginationChange={onPagination}
/>
) : (
<EuiEmptyPrompt
iconType={'checkInCircleFilled'}
iconColor="success"
title={
<h2>
{i18n.translate('xpack.searchPlayground.searchMode.readyToSearch', {
defaultMessage: 'Ready to search',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.searchPlayground.searchMode.searchPrompt', {
defaultMessage:
'Type in a query in the search bar above or view the query we automatically created for you.',
})}
</p>
}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</KibanaPageTemplate.Section>
);
};

View file

@ -16,6 +16,7 @@ import {
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { docLinks } from '../../../common/doc_links';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
@ -24,6 +25,7 @@ import { AddDataSources } from './add_data_sources';
import { ConnectLLMButton } from './connect_llm_button';
import { CreateIndexButton } from './create_index_button';
import { UploadFileButton } from '../upload_file_button';
import { PlaygroundBodySection } from '../playground_body_section';
export const ChatSetupPage: React.FC = () => {
const usageTracker = useUsageTracker();
@ -34,75 +36,77 @@ export const ChatSetupPage: React.FC = () => {
}, [usageTracker]);
return (
<EuiEmptyPrompt
iconType="discuss"
data-test-subj="setupPage"
title={
<h2>
<FormattedMessage
id="xpack.searchPlayground.setupPage.title"
defaultMessage="Set up a chat experience"
/>
</h2>
}
body={
<>
<p>
<PlaygroundBodySection>
<EuiEmptyPrompt
iconType="discuss"
data-test-subj="setupPage"
title={
<h2>
<FormattedMessage
id="xpack.searchPlayground.setupPage.description"
defaultMessage="Experiment with combining your Elasticsearch data with powerful large language models (LLMs) using Playground for retrieval augmented generation (RAG)."
id="xpack.searchPlayground.setupPage.title"
defaultMessage="Set up a chat experience"
/>
</p>
<p>
<FormattedMessage
id="xpack.searchPlayground.setupPage.descriptionLLM"
defaultMessage="Connect to your LLM provider and select your data sources to get started."
/>
</p>
</>
}
actions={
<EuiFlexGroup justifyContent="center">
{isIndicesLoading ? (
<EuiLoadingSpinner />
) : (
<>
<EuiFlexItem grow={false}>
<ConnectLLMButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{indices.length ? <AddDataSources /> : <CreateIndexButton />}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UploadFileButton isSetup={true} />
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
}
footer={
<>
<EuiTitle size="xxs">
<span>
</h2>
}
body={
<>
<p>
<FormattedMessage
id="xpack.searchPlayground.setupPage.learnMore"
defaultMessage="Want to learn more?"
id="xpack.searchPlayground.setupPage.description"
defaultMessage="Experiment with combining your Elasticsearch data with powerful large language models (LLMs) using Playground for retrieval augmented generation (RAG)."
/>
</span>
</EuiTitle>{' '}
<EuiLink
data-test-subj="searchPlaygroundChatSetupPageReadDocumentationLink"
href={docLinks.chatPlayground}
target="_blank"
external
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.documentationLink"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
}
/>
</p>
<p>
<FormattedMessage
id="xpack.searchPlayground.setupPage.descriptionLLM"
defaultMessage="Connect to your LLM provider and select your data sources to get started."
/>
</p>
</>
}
actions={
<EuiFlexGroup justifyContent="center">
{isIndicesLoading ? (
<EuiLoadingSpinner />
) : (
<>
<EuiFlexItem grow={false}>
<ConnectLLMButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{indices.length ? <AddDataSources /> : <CreateIndexButton />}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UploadFileButton isSetup={true} />
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
}
footer={
<>
<EuiTitle size="xxs">
<span>
<FormattedMessage
id="xpack.searchPlayground.setupPage.learnMore"
defaultMessage="Want to learn more?"
/>
</span>
</EuiTitle>{' '}
<EuiLink
data-test-subj="searchPlaygroundChatSetupPageReadDocumentationLink"
href={docLinks.chatPlayground}
target="_blank"
external
>
<FormattedMessage
id="xpack.searchPlayground.setupPage.documentationLink"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
}
/>
</PlaygroundBodySection>
);
};

View file

@ -19,6 +19,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../../analytics/constants';
import { PlaygroundBodySection } from '../playground_body_section';
import { AddDataSources } from './add_data_sources';
export const SearchPlaygroundSetupPage: React.FC = () => {
@ -30,46 +31,48 @@ export const SearchPlaygroundSetupPage: React.FC = () => {
}, [usageTracker]);
return (
<EuiEmptyPrompt
iconType="indexOpen"
data-test-subj="setupPage"
title={
<h2>
<FormattedMessage
id="xpack.searchPlayground.setupPage.queryBuilder.title"
defaultMessage="Add data to query"
/>
</h2>
}
actions={
<EuiFlexGroup justifyContent="center">
{isIndicesLoading ? (
<EuiLoadingSpinner />
) : (
<EuiFlexItem grow={false}>
<AddDataSources />
</EuiFlexItem>
)}
</EuiFlexGroup>
}
footer={
<>
<EuiTitle size="xxs">
<span>
<FormattedMessage
id="xpack.searchPlayground.setupPage.learnMore"
defaultMessage="Want to learn more?"
/>
</span>
</EuiTitle>{' '}
<EuiLink href="todo" target="_blank" external>
<PlaygroundBodySection>
<EuiEmptyPrompt
iconType="indexOpen"
data-test-subj="setupPage"
title={
<h2>
<FormattedMessage
id="xpack.searchPlayground.setupPage.documentationLink"
defaultMessage="Read documentation"
id="xpack.searchPlayground.setupPage.queryBuilder.title"
defaultMessage="Add data to query"
/>
</EuiLink>
</>
}
/>
</h2>
}
actions={
<EuiFlexGroup justifyContent="center">
{isIndicesLoading ? (
<EuiLoadingSpinner />
) : (
<EuiFlexItem grow={false}>
<AddDataSources />
</EuiFlexItem>
)}
</EuiFlexGroup>
}
footer={
<>
<EuiTitle size="xxs">
<span>
<FormattedMessage
id="xpack.searchPlayground.setupPage.learnMore"
defaultMessage="Want to learn more?"
/>
</span>
</EuiTitle>{' '}
<EuiLink href="todo" target="_blank" external>
<FormattedMessage
id="xpack.searchPlayground.setupPage.documentationLink"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
}
/>
</PlaygroundBodySection>
);
};

View file

@ -27,7 +27,7 @@ export const ViewCodeAction: React.FC<{ selectedPageMode: PlaygroundPageMode }>
<EuiButton
iconType="editorCodeBlock"
color="primary"
fill
fill={selectedPageMode === PlaygroundPageMode.chat}
onClick={() => setShowFlyout(true)}
disabled={!selectedIndices || selectedIndices?.length === 0}
data-test-subj="viewCodeActionButton"

View file

@ -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 { useQuery } from '@tanstack/react-query';
import { useFormContext } from 'react-hook-form';
import { APIRoutes, ChatFormFields, QueryTestResponse } from '../types';
import { useKibana } from './use_kibana';
export const useElasticsearchQuery = () => {
const { http } = useKibana().services;
const { getValues } = useFormContext();
const executeEsQuery = () => {
const indices = getValues(ChatFormFields.indices);
const elasticsearchQuery = getValues(ChatFormFields.elasticsearchQuery);
const query = getValues(ChatFormFields.searchQuery);
return http.post<QueryTestResponse>(APIRoutes.POST_QUERY_TEST, {
body: JSON.stringify({
elasticsearch_query: JSON.stringify(elasticsearchQuery),
indices,
query,
}),
});
};
const { refetch: executeQuery, ...rest } = useQuery({
queryFn: executeEsQuery,
enabled: false,
});
return {
executeQuery,
...rest,
};
};

View file

@ -5,14 +5,20 @@
* 2.0.
*/
import {
import type {
HealthStatus,
IndexName,
IndicesStatsIndexMetadataState,
Uuid,
} from '@elastic/elasticsearch/lib/api/types';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import React from 'react';
import type {
ReactNode,
ChangeEvent,
FormEvent,
Dispatch as ReactDispatch,
SetStateAction as ReactSetStateAction,
} from 'react';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
@ -28,7 +34,7 @@ import type {
UserConfiguredActionConnector,
} from '@kbn/alerts-ui-shared/src/common/types';
import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { ChatRequestData, MessageRole, LLMs } from '../common/types';
export * from '../common/types';
@ -93,7 +99,7 @@ export interface ChatForm {
export interface Message {
id: string;
content: string | React.ReactNode;
content: string | ReactNode;
createdAt?: Date;
annotations?: Annotation[];
role: MessageRole;
@ -205,14 +211,9 @@ export interface UseChatHelpers {
stop: () => void;
setMessages: (messages: Message[]) => void;
input: string;
setInput: React.Dispatch<React.SetStateAction<string>>;
handleInputChange: (
e: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>
) => void;
handleSubmit: (
e: React.FormEvent<HTMLFormElement>,
chatRequestOptions?: ChatRequestOptions
) => void;
setInput: ReactDispatch<ReactSetStateAction<string>>;
handleInputChange: (e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>, chatRequestOptions?: ChatRequestOptions) => void;
isLoading: boolean;
}

View file

@ -11,7 +11,7 @@ import { coreMock } from '@kbn/core/server/mocks';
import { MockRouter } from '../__mocks__/router.mock';
import { ConversationalChain } from './lib/conversational_chain';
import { getChatParams } from './lib/get_chat_params';
import { createRetriever, defineRoutes } from './routes';
import { parseElasticsearchQuery, defineRoutes } from './routes';
import { ContextLimitError } from './lib/errors';
jest.mock('./lib/get_chat_params', () => ({
@ -20,16 +20,31 @@ jest.mock('./lib/get_chat_params', () => ({
jest.mock('./lib/conversational_chain');
describe('createRetriever', () => {
describe('parseElasticsearchQuery', () => {
test('works when the question has quotes', () => {
const esQuery = '{"query": {"match": {"text": "{query}"}}}';
const question = 'How can I "do something" with quotes?';
const retriever = createRetriever(esQuery);
const retriever = parseElasticsearchQuery(esQuery);
const result = retriever(question);
expect(result).toEqual({ query: { match: { text: 'How can I "do something" with quotes?' } } });
});
test('throws an error when esQuery is invalid JSON', () => {
const esQuery = 'invalid json';
const question = 'How can I "do something" with quotes?';
try {
parseElasticsearchQuery(esQuery)(question);
fail('Expected an error to be thrown');
} catch (e) {
expect(e.message).toMatchInlineSnapshot(
`"Failed to parse the Elasticsearch Query. Check Query to make sure it's valid."`
);
expect(e.cause).toBeDefined();
expect(e.cause).toBeInstanceOf(SyntaxError);
}
});
});
describe('Search Playground routes', () => {

View file

@ -7,6 +7,7 @@
import { schema } from '@kbn/config-schema';
import type { Logger } from '@kbn/logging';
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { IRouter, StartServicesAccessor } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { PLUGIN_ID } from '../common';
@ -19,6 +20,7 @@ import { handleStreamResponse } from './utils/handle_stream_response';
import {
APIRoutes,
ElasticsearchRetrieverContentField,
QueryTestResponse,
SearchPlaygroundPluginStart,
SearchPlaygroundPluginStartDependencies,
} from './types';
@ -28,15 +30,31 @@ import { isNotNullish } from '../common/is_not_nullish';
import { MODELS } from '../common/models';
import { ContextLimitError } from './lib/errors';
import { parseSourceFields } from './utils/parse_source_fields';
import { getErrorMessage } from '../common/errors';
export function createRetriever(esQuery: string) {
const EMPTY_INDICES_ERROR_MESSAGE = i18n.translate(
'xpack.searchPlayground.serverErrors.emptyIndices',
{
defaultMessage: 'Indices cannot be empty',
}
);
export function parseElasticsearchQuery(esQuery: string) {
return (question: string) => {
try {
const replacedQuery = esQuery.replace(/\"{query}\"/g, JSON.stringify(question));
const query = JSON.parse(replacedQuery);
return query;
return query as Partial<SearchRequest>;
} catch (e) {
throw Error("Failed to parse the Elasticsearch Query. Check Query to make sure it's valid.");
throw new Error(
i18n.translate('xpack.searchPlayground.serverErrors.parseRetriever', {
defaultMessage:
"Failed to parse the Elasticsearch Query. Check Query to make sure it's valid.",
}),
{
cause: e,
}
);
}
};
}
@ -143,7 +161,7 @@ export function defineRoutes({
model: chatModel,
rag: {
index: data.indices,
retriever: createRetriever(data.elasticsearch_query),
retriever: parseElasticsearchQuery(data.elasticsearch_query),
content_field: sourceFields,
size: Number(data.doc_size),
inputTokensLimit: modelPromptLimit,
@ -269,15 +287,27 @@ export function defineRoutes({
if (indices.length === 0) {
return response.badRequest({
body: {
message: 'Indices cannot be empty',
message: EMPTY_INDICES_ERROR_MESSAGE,
},
});
}
let parsedElasticsearchQuery: Partial<SearchRequest>;
try {
parsedElasticsearchQuery = parseElasticsearchQuery(elasticsearchQuery)(
request.body.search_query
);
} catch (e) {
logger.error(e);
return response.badRequest({
body: {
message: getErrorMessage(e),
},
});
}
const retriever = createRetriever(elasticsearchQuery)(request.body.search_query);
const searchResult = await client.asCurrentUser.search({
...parsedElasticsearchQuery,
index: indices,
retriever: retriever.retriever,
from,
size,
});
@ -337,7 +367,7 @@ export function defineRoutes({
if (indices.length === 0) {
return response.badRequest({
body: {
message: 'Indices cannot be empty',
message: EMPTY_INDICES_ERROR_MESSAGE,
},
});
}
@ -363,4 +393,66 @@ export function defineRoutes({
}
})
);
router.post(
{
path: APIRoutes.POST_QUERY_TEST,
options: {
access: 'internal',
},
security: {
authz: {
requiredPrivileges: [PLUGIN_ID],
},
},
validate: {
body: schema.object({
query: schema.string(),
elasticsearch_query: schema.string(),
indices: schema.arrayOf(schema.string()),
size: schema.maybe(schema.number({ defaultValue: 10, min: 0 })),
from: schema.maybe(schema.number({ defaultValue: 0, min: 0 })),
}),
},
},
errorHandler(logger)(async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const { elasticsearch_query: elasticsearchQuery, indices, size, from } = request.body;
if (indices.length === 0) {
return response.badRequest({
body: {
message: EMPTY_INDICES_ERROR_MESSAGE,
},
});
}
let searchQuery: Partial<SearchRequest>;
try {
searchQuery = parseElasticsearchQuery(elasticsearchQuery)(request.body.query);
} catch (e) {
logger.error(e);
return response.badRequest({
body: {
message: getErrorMessage(e),
},
});
}
const searchResponse = await client.asCurrentUser.search({
...searchQuery,
index: indices,
from,
size,
});
const body: QueryTestResponse = {
searchResponse,
};
return response.ok({
body,
headers: {
'content-type': 'application/json',
},
});
})
);
}

View file

@ -54,6 +54,7 @@
"@kbn/deeplinks-search",
"@kbn/logging-mocks",
"@kbn/inference-plugin",
"@kbn/code-editor",
],
"exclude": [
"target/**/*",

View file

@ -10,6 +10,7 @@ import { FtrProviderContext } from '../ftr_provider_context';
export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const findService = getService('find');
const browser = getService('browser');
const comboBox = getService('comboBox');
const selectIndex = async () => {
@ -19,6 +20,13 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
await testSubjects.click('sourceIndex-0');
await testSubjects.click('saveButton');
};
const selectIndexByName = async (indexName: string) => {
await testSubjects.existOrFail('addDataSourcesButton');
await testSubjects.click('addDataSourcesButton');
await testSubjects.existOrFail('selectIndicesFlyout');
await findService.clickByCssSelector(`li[title="${indexName}"]`);
await testSubjects.click('saveButton');
};
const SESSION_KEY = 'search_playground_session';
@ -55,6 +63,20 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
expect(state[key]).to.be(value);
},
},
async expectPageSelectorToExist() {
await testSubjects.existOrFail('page-mode-select');
},
async expectPageModeToBeSelected(mode: 'chat' | 'search') {
await testSubjects.existOrFail('page-mode-select');
const selectedModeText = await testSubjects.getAttribute('page-mode-select', 'value');
expect(selectedModeText?.toLowerCase()).to.be(mode);
},
async selectPageMode(mode: 'chat' | 'search') {
await testSubjects.existOrFail('page-mode-select');
await testSubjects.selectValue('page-mode-select', mode);
const selectedModeText = await testSubjects.getAttribute('page-mode-select', 'value');
expect(selectedModeText?.toLowerCase()).to.be(mode);
},
PlaygroundStartChatPage: {
async expectPlaygroundStartChatPageComponentsToExist() {
await testSubjects.existOrFail('setupPage');
@ -250,5 +272,97 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
await browser.switchTab(0);
},
},
PlaygroundStartSearchPage: {
async expectPlaygroundStartSearchPageComponentsToExist() {
await testSubjects.existOrFail('setupPage');
await testSubjects.existOrFail('addDataSourcesButton');
},
async expectToSelectIndicesAndLoadSearch(indexName: string) {
await selectIndexByName(indexName);
await testSubjects.existOrFail('playground-search-section');
},
},
PlaygroundSearchPage: {
async expectSearchBarToExist() {
await testSubjects.existOrFail('playground-search-section');
await testSubjects.existOrFail('searchPlaygroundSearchModeFieldText');
},
async executeSearchQuery(queryText: string) {
await testSubjects.existOrFail('searchPlaygroundSearchModeFieldText');
await testSubjects.setValue('searchPlaygroundSearchModeFieldText', `${queryText}`, {
typeCharByChar: true,
});
const searchInput = await testSubjects.find('searchPlaygroundSearchModeFieldText');
await searchInput.pressKeys(browser.keys.ENTER);
},
async expectSearchResultsToExist() {
await testSubjects.existOrFail('search-index-documents-result');
},
async expectSearchResultsNotToExist() {
await testSubjects.missingOrFail('search-index-documents-result');
},
async clearSearchInput() {
await testSubjects.existOrFail('searchPlaygroundSearchModeFieldText');
await testSubjects.setValue('searchPlaygroundSearchModeFieldText', '');
},
async hasModeSelectors() {
await testSubjects.existOrFail('chatMode');
await testSubjects.existOrFail('queryMode');
},
async expectModeIsSelected(mode: 'chatMode' | 'queryMode') {
await testSubjects.existOrFail(mode);
const modeSelectedValue = await testSubjects.getAttribute(mode, 'aria-pressed');
expect(modeSelectedValue).to.be('true');
},
async selectPageMode(mode: 'chatMode' | 'queryMode') {
await testSubjects.existOrFail(mode);
await testSubjects.click(mode);
switch (mode) {
case 'queryMode':
expect(await browser.getCurrentUrl()).contain('/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');
break;
}
},
async expectQueryModeComponentsToExist() {
await testSubjects.existOrFail('queryModeSection');
await testSubjects.existOrFail('RunElasticsearchQueryButton');
await testSubjects.existOrFail('ViewElasticsearchQueryResult');
await testSubjects.existOrFail('queryModeSection');
},
async expectQueryModeResultsEmptyState() {
await testSubjects.existOrFail('ViewElasticsearchQueryResponseEmptyState');
},
async expectQueryModeResultsCodeEditor() {
await testSubjects.existOrFail('ViewElasticsearchQueryResponse');
},
async runQueryInQueryMode(queryText: string) {
await testSubjects.existOrFail('searchPlaygroundSearchModeFieldText');
await testSubjects.setValue('searchPlaygroundSearchModeFieldText', `${queryText}`);
await testSubjects.click('RunElasticsearchQueryButton');
},
async expectFieldToBeSelected(fieldName: string) {
await testSubjects.existOrFail(`field-${fieldName}-true`);
},
async expectFieldNotToBeSelected(fieldName: string) {
await testSubjects.existOrFail(`field-${fieldName}-false`);
},
async clickFieldSwitch(fieldName: string, selected: boolean) {
await testSubjects.existOrFail(`field-${fieldName}-${selected}`);
await testSubjects.click(`field-${fieldName}-${selected}`);
},
async getQueryEditorText() {
await testSubjects.existOrFail('ViewElasticsearchQueryResult');
const result = await testSubjects.getVisibleTextAll('ViewElasticsearchQueryResult');
expect(Array.isArray(result)).to.be(true);
expect(result.length).to.be(1);
expect(typeof result[0]).to.be('string');
return result[0];
},
},
};
}

View file

@ -52,5 +52,11 @@ export function SvlSearchNavigationServiceProvider({
shouldLoginIfPrompted: false,
});
},
async navigateToSearchPlayground() {
await retry.tryForTime(60 * 1000, async () => {
await PageObjects.common.navigateToApp('searchPlayground');
await testSubjects.existOrFail('svlPlaygroundPage', { timeout: 2000 });
});
},
};
}

View file

@ -20,6 +20,7 @@ export default createTestConfig({
// add feature flags
kbnServerArgs: [
`--xpack.cloud.id=ES3_FTR_TESTS:ZmFrZS1kb21haW4uY2xkLmVsc3RjLmNvJGZha2Vwcm9qZWN0aWQuZXMkZmFrZXByb2plY3RpZC5rYg==`,
`--uiSettings.overrides.searchPlayground:searchModeEnabled=true`,
],
// load tests in the index file
testFiles: [require.resolve('./index.feature_flags.ts')],

View file

@ -0,0 +1,36 @@
{
"type": "index",
"value": {
"aliases": {
},
"index": "search-playground-books",
"mappings": {
"properties": {
"author": {
"fields": {
"keyword": {
"ignore_above": 256,
"type": "keyword"
}
},
"type": "text"
},
"name": {
"fields": {
"keyword": {
"ignore_above": 256,
"type": "keyword"
}
},
"type": "text"
},
"page_count": {
"type": "long"
},
"release_date": {
"type": "date"
}
}
}
}
}

View file

@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
// add tests that require feature flags, defined in config.feature_flags.ts
loadTestFile(require.resolve('./search_synonyms/search_synonyms_overview'));
loadTestFile(require.resolve('./search_synonyms/search_synonym_detail'));
loadTestFile(require.resolve('./search_playground/search_relevance'));
});
}

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const ARCHIVE_INDEX_NAME = 'search-playground-books';
const esArchiveIndex =
'x-pack/test_serverless/functional/test_suites/search/fixtures/playground_books';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const pageObjects = getPageObjects([
'svlCommonPage',
'svlCommonNavigation',
'searchPlayground',
'embeddedConsole',
]);
const svlSearchNavigation = getService('svlSearchNavigation');
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const createIndex = async () => await esArchiver.load(esArchiveIndex);
describe('Serverless Search Relevance Playground', function () {
before(async () => {
await createIndex();
await pageObjects.svlCommonPage.loginWithRole('developer');
await pageObjects.searchPlayground.session.clearSession();
await svlSearchNavigation.navigateToSearchPlayground();
});
after(async () => {
await esArchiver.unload(esArchiveIndex);
});
it('should be able to navigate to the search relevance playground', async () => {
await pageObjects.searchPlayground.expectPageSelectorToExist();
// playground defaults to chat mode
await pageObjects.searchPlayground.expectPageModeToBeSelected('chat');
await pageObjects.searchPlayground.selectPageMode('search');
expect(await browser.getCurrentUrl()).contain('/app/search_playground/search');
});
it('should start with setup page', async () => {
await pageObjects.searchPlayground.PlaygroundStartSearchPage.expectPlaygroundStartSearchPageComponentsToExist();
await pageObjects.searchPlayground.PlaygroundStartSearchPage.expectToSelectIndicesAndLoadSearch(
ARCHIVE_INDEX_NAME
);
});
it('should be able to search index', async () => {
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');
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.expectFieldNotToBeSelected('name');
const queryEditorTextBefore =
await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
expect(queryEditorTextBefore).to.contain(`"author"`);
expect(queryEditorTextBefore).not.to.contain('"name"');
await pageObjects.searchPlayground.PlaygroundSearchPage.clickFieldSwitch('name', false);
await pageObjects.searchPlayground.PlaygroundSearchPage.expectFieldToBeSelected('name');
let queryEditorText =
await pageObjects.searchPlayground.PlaygroundSearchPage.getQueryEditorText();
expect(queryEditorText).to.contain('"author"');
expect(queryEditorText).to.contain('"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();
});
});
}