diff --git a/common/constants.ts b/common/constants.ts index 2d8787ff..15142d37 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -522,3 +522,32 @@ export enum SOURCE_OPTIONS { UPLOAD = 'upload', EXISTING_INDEX = 'existing_index', } +export enum INSPECTOR_TAB_ID { + INGEST = 'ingest', + QUERY = 'query', + ERRORS = 'errors', + RESOURCES = 'resources', +} + +export const INSPECTOR_TABS = [ + { + id: INSPECTOR_TAB_ID.INGEST, + name: 'Ingest response', + disabled: false, + }, + { + id: INSPECTOR_TAB_ID.QUERY, + name: 'Search response', + disabled: false, + }, + { + id: INSPECTOR_TAB_ID.ERRORS, + name: 'Errors', + disabled: false, + }, + { + id: INSPECTOR_TAB_ID.RESOURCES, + name: 'Resources', + disabled: false, + }, +]; diff --git a/common/interfaces.ts b/common/interfaces.ts index 5ace9341..228666cc 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -557,6 +557,14 @@ export type QuickConfigureFields = { llmResponseField?: string; }; +export type QueryParamType = 'Text' | 'Binary'; + +export type QueryParam = { + name: string; + type: QueryParamType; + value: string; +}; + /** ********** OPENSEARCH TYPES/INTERFACES ************ */ diff --git a/public/general_components/index.ts b/public/general_components/index.ts index f475b50d..1ce91089 100644 --- a/public/general_components/index.ts +++ b/public/general_components/index.ts @@ -6,4 +6,5 @@ export { MultiSelectFilter } from './multi_select_filter'; export { ProcessorsTitle } from './processors_title'; export { ExperimentalBadge } from './experimental_badge'; +export { QueryParamsList } from './query_params_list'; export * from './service_card'; diff --git a/public/general_components/query_params_list.tsx b/public/general_components/query_params_list.tsx new file mode 100644 index 00000000..6460ef00 --- /dev/null +++ b/public/general_components/query_params_list.tsx @@ -0,0 +1,152 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { get } from 'lodash'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiFieldText, + EuiComboBox, + EuiCompressedFilePicker, +} from '@elastic/eui'; +import { QueryParam, QueryParamType } from '../../common'; + +interface QueryParamsListProps { + queryParams: QueryParam[]; + setQueryParams: (params: QueryParam[]) => void; +} + +// The keys will be more static in general. Give more space for values where users +// will typically be writing out more complex transforms/configuration (in the case of ML inference processors). +const KEY_FLEX_RATIO = 3; +const TYPE_FLEX_RATIO = 2; +const VALUE_FLEX_RATIO = 5; + +const OPTIONS = [ + { + label: 'Text' as QueryParamType, + }, + { + label: 'Binary' as QueryParamType, + }, +]; + +/** + * Basic, reusable component for displaying a list of query parameters, and allowing + * users to freely enter values for each. + */ +export function QueryParamsList(props: QueryParamsListProps) { + return ( + <> + {props.queryParams?.length > 0 && ( + + + + + + + Parameter + + + + + Type + + + + + Value + + + + + {props.queryParams.map((queryParam, idx) => { + return ( + + + + + {queryParam.name} + + + + { + props.setQueryParams( + props.queryParams.map((qp, i) => + i === idx + ? { ...qp, type: get(options, '0.label') } + : qp + ) + ); + }} + /> + + + {queryParam.type === 'Binary' ? ( + // For binary filetypes, accept images + { + if (files && files.length > 0) { + const fileReader = new FileReader(); + fileReader.onload = (e) => { + try { + const binaryData = e.target?.result as string; + const base64Str = binaryData.split(',')[1]; + props.setQueryParams( + props.queryParams.map((qp, i) => + i === idx + ? { ...qp, value: base64Str } + : qp + ) + ); + } catch {} + }; + fileReader.readAsDataURL(files[0]); + } + }} + display="default" + /> + ) : ( + // Default to freeform text input + { + props.setQueryParams( + props.queryParams.map((qp, i) => + i === idx + ? { ...qp, value: e?.target?.value } + : qp + ) + ); + }} + /> + )} + + + + ); + })} + + + )} + + ); +} diff --git a/public/pages/workflow_detail/components/export_modal.tsx b/public/pages/workflow_detail/components/export_modal.tsx index 22c61906..9c3aaffb 100644 --- a/public/pages/workflow_detail/components/export_modal.tsx +++ b/public/pages/workflow_detail/components/export_modal.tsx @@ -71,7 +71,11 @@ export function ExportModal(props: ExportModalProps) { }, [props.workflow, selectedOption]); return ( - props.setIsExportModalOpen(false)}> + props.setIsExportModalOpen(false)} + >

{`Export ${getCharacterLimitedString( diff --git a/public/pages/workflow_detail/resizable_workspace.tsx b/public/pages/workflow_detail/resizable_workspace.tsx index 8600b4d2..d681b720 100644 --- a/public/pages/workflow_detail/resizable_workspace.tsx +++ b/public/pages/workflow_detail/resizable_workspace.tsx @@ -14,6 +14,7 @@ import { } from '@elastic/eui'; import { CONFIG_STEP, + INSPECTOR_TAB_ID, Workflow, WorkflowConfig, customStringify, @@ -78,9 +79,13 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { setIsToolsPanelOpen(!isToolsPanelOpen); }; - // ingest / search response states to be populated in the Tools panel + // Inspector panel state vars. Actions taken in the form can update the Inspector panel, + // hence we keep top-level vars here to pass to both form and inspector components. const [ingestResponse, setIngestResponse] = useState(''); const [queryResponse, setQueryResponse] = useState(''); + const [selectedInspectorTabId, setSelectedInspectorTabId] = useState< + INSPECTOR_TAB_ID + >(INSPECTOR_TAB_ID.INGEST); // is valid workflow state, + associated hook to set it as such const [isValidWorkflow, setIsValidWorkflow] = useState(true); @@ -132,6 +137,12 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { setSelectedStep={props.setSelectedStep} setUnsavedIngestProcessors={props.setUnsavedIngestProcessors} setUnsavedSearchProcessors={props.setUnsavedSearchProcessors} + displaySearchPanel={() => { + if (!isToolsPanelOpen) { + onToggleToolsChange(); + } + setSelectedInspectorTabId(INSPECTOR_TAB_ID.QUERY); + }} /> @@ -198,6 +209,10 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { workflow={props.workflow} ingestResponse={ingestResponse} queryResponse={queryResponse} + setQueryResponse={setQueryResponse} + selectedTabId={selectedInspectorTabId} + setSelectedTabId={setSelectedInspectorTabId} + selectedStep={props.selectedStep} /> diff --git a/public/pages/workflow_detail/tools/ingest/ingest.tsx b/public/pages/workflow_detail/tools/ingest/ingest.tsx index cdae1df0..f2b3c8ec 100644 --- a/public/pages/workflow_detail/tools/ingest/ingest.tsx +++ b/public/pages/workflow_detail/tools/ingest/ingest.tsx @@ -4,7 +4,8 @@ */ import React from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { EuiCodeEditor, EuiEmptyPrompt, EuiText } from '@elastic/eui'; interface IngestProps { ingestResponse: string; @@ -19,19 +20,33 @@ export function Ingest(props: IngestProps) { // TODO: known issue with the editor where resizing the resizablecontainer does not // trigger vertical scroll updates. Updating the window, or reloading the component // by switching tabs etc. will refresh it correctly - + <> + {isEmpty(props.ingestResponse) ? ( + No data} + titleSize="s" + body={ + <> + Run ingest and view the response here. + + } + /> + ) : ( + + )} + ); } diff --git a/public/pages/workflow_detail/tools/query/query.tsx b/public/pages/workflow_detail/tools/query/query.tsx index 752c0dde..d7de4a87 100644 --- a/public/pages/workflow_detail/tools/query/query.tsx +++ b/public/pages/workflow_detail/tools/query/query.tsx @@ -3,35 +3,315 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { useFormikContext } from 'formik'; +import { + EuiCodeEditor, + EuiComboBox, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSmallButton, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import { + CONFIG_STEP, + customStringify, + FETCH_ALL_QUERY, + QueryParam, + SearchHit, + WorkflowFormValues, +} from '../../../../../common'; +import { searchIndex, useAppDispatch } from '../../../../store'; +import { + containsEmptyValues, + containsSameValues, + getDataSourceId, + getPlaceholdersFromQuery, + injectParameters, +} from '../../../../utils'; +import { QueryParamsList } from '../../../../general_components'; interface QueryProps { queryResponse: string; + setQueryResponse: (queryResponse: string) => void; + hasSearchPipeline: boolean; + hasIngestResources: boolean; + selectedStep: CONFIG_STEP; } +const SEARCH_OPTIONS = [ + { + label: 'FULL search pipeline', + }, + { + label: 'No search pipeline', + }, +]; + /** - * The basic query component for the Tools panel. - * Displays a read-only view of the query response after users perform search. + * The search component for the Tools panel. + * Lets users configure query parameters, execute search, and view responses. */ export function Query(props: QueryProps) { + const dispatch = useAppDispatch(); + const dataSourceId = getDataSourceId(); + + // Form state + const { values } = useFormikContext(); + + // use custom query state + const [useCustomQuery, setUseCustomQuery] = useState(false); + + // Standalone / sandboxed search request state. Users can test things out + // without updating the base form / persisted value. We default to different values + // based on the context (ingest or search). + const [tempRequest, setTempRequest] = useState( + props.selectedStep === CONFIG_STEP.INGEST + ? customStringify(FETCH_ALL_QUERY) + : values?.search?.request || '{}' + ); + + // state for if to execute search w/ or w/o any configured search pipeline. + // default based on if there is an available search pipeline or not. + const [includePipeline, setIncludePipeline] = useState(false); + useEffect(() => { + setIncludePipeline(props.hasSearchPipeline); + }, [props.hasSearchPipeline]); + + // query params state + const [queryParams, setQueryParams] = useState([]); + + // listen for changes to the upstream / form query, and reset the default + useEffect(() => { + if (!isEmpty(values?.search?.request)) { + setTempRequest(values?.search?.request); + } + }, [values?.search?.request]); + + // Do a few things when the request is changed: + // 1. Check if there is a new set of query parameters, and if so, + // reset the form. + // 2. Clear any stale results + useEffect(() => { + const placeholders = getPlaceholdersFromQuery(tempRequest); + if ( + !containsSameValues( + placeholders, + queryParams.map((queryParam) => queryParam.name) + ) + ) { + setQueryParams( + placeholders.map((placeholder) => ({ + name: placeholder, + type: 'Text', + value: '', + })) + ); + } + props.setQueryResponse(''); + }, [tempRequest]); + + // empty states + const noSearchIndex = isEmpty(values?.search?.index?.name); + const noSearchRequest = isEmpty(values?.search?.request); + const onIngestAndInvalid = + props.selectedStep === CONFIG_STEP.INGEST && !props.hasIngestResources; + const onSearchAndInvalid = + props.selectedStep === CONFIG_STEP.SEARCH && + (noSearchIndex || noSearchRequest); + const indexToSearch = + props.selectedStep === CONFIG_STEP.INGEST + ? values?.ingest?.index?.name + : values?.search?.index?.name; + return ( - // TODO: known issue with the editor where resizing the resizablecontainer does not - // trigger vertical scroll updates. Updating the window, or reloading the component - // by switching tabs etc. will refresh it correctly - + <> + {onIngestAndInvalid || onSearchAndInvalid ? ( + Missing search configurations} + titleSize="s" + body={ + <> + + {onIngestAndInvalid + ? `Create an index and ingest data first.` + : `Configure a search request and an index to search against first.`} + + + } + /> + ) : ( + + + + + + + Search + + + { + dispatch( + searchIndex({ + apiBody: { + index: indexToSearch, + body: injectParameters(queryParams, tempRequest), + searchPipeline: + props.hasSearchPipeline && + includePipeline && + props.selectedStep === CONFIG_STEP.SEARCH && + !isEmpty(values?.search?.pipelineName) + ? values?.search?.pipelineName + : '_none', + }, + dataSourceId, + }) + ) + .unwrap() + .then(async (resp) => { + props.setQueryResponse( + customStringify( + resp?.hits?.hits?.map( + (hit: SearchHit) => hit._source + ) + ) + ); + }) + .catch((error: any) => { + props.setQueryResponse(''); + console.error('Error running query: ', error); + }); + }} + > + Search + + + + + + { + setIncludePipeline(!includePipeline); + }} + /> + + + setUseCustomQuery(!useCustomQuery)} + /> + + {useCustomQuery && ( + + { + setTempRequest(input); + }} + onBlur={() => { + try { + setTempRequest( + customStringify(JSON.parse(tempRequest)) + ); + } catch (error) {} + }} + readOnly={false} + setOptions={{ + fontSize: '14px', + useWorker: true, + highlightActiveLine: true, + highlightSelectedWord: true, + highlightGutterLine: true, + wrap: true, + }} + aria-label="Code Editor" + tabSize={2} + /> + + )} + + {/** + * This may return nothing if the list of params are empty + */} + + + + + + + + Results + + + {isEmpty(props.queryResponse) ? ( + No results} + titleSize="s" + body={ + <> + Run search to view results. + + } + /> + ) : ( + // Known issue with the editor where resizing the resizablecontainer does not + // trigger vertical scroll updates. Updating the window, or reloading the component + // by switching tabs etc. will refresh it correctly + + )} + + + + + )} + ); } diff --git a/public/pages/workflow_detail/tools/tools.tsx b/public/pages/workflow_detail/tools/tools.tsx index f4d2a098..00ba59dd 100644 --- a/public/pages/workflow_detail/tools/tools.tsx +++ b/public/pages/workflow_detail/tools/tools.tsx @@ -15,49 +15,31 @@ import { EuiTabs, EuiText, } from '@elastic/eui'; -import { Workflow } from '../../../../common'; +import { + CONFIG_STEP, + INSPECTOR_TAB_ID, + INSPECTOR_TABS, + Workflow, +} from '../../../../common'; import { Resources } from './resources'; import { Query } from './query'; import { Ingest } from './ingest'; import { Errors } from './errors'; +import { + hasProvisionedIngestResources, + hasProvisionedSearchResources, +} from '../../../utils'; interface ToolsProps { workflow?: Workflow; ingestResponse: string; queryResponse: string; + setQueryResponse: (queryResponse: string) => void; + selectedTabId: INSPECTOR_TAB_ID; + setSelectedTabId: (tabId: INSPECTOR_TAB_ID) => void; + selectedStep: CONFIG_STEP; } -enum TAB_ID { - INGEST = 'ingest', - QUERY = 'query', - ERRORS = 'errors', - RESOURCES = 'resources', -} - -const inputTabs = [ - { - id: TAB_ID.INGEST, - name: 'Ingest response', - disabled: false, - }, - { - id: TAB_ID.QUERY, - name: 'Search response', - disabled: false, - }, - { - id: TAB_ID.ERRORS, - name: 'Errors', - disabled: false, - }, - { - id: TAB_ID.RESOURCES, - name: 'Resources', - disabled: false, - }, -]; - -// TODO: this may change in the future const PANEL_TITLE = 'Inspector'; /** @@ -70,9 +52,6 @@ export function Tools(props: ToolsProps) { const workflowsError = workflows.errorMessage; const [curErrorMessage, setCurErrorMessage] = useState(''); - // selected tab state - const [selectedTabId, setSelectedTabId] = useState(TAB_ID.INGEST); - // auto-navigate to errors tab if a new error has been set as a result of // executing OpenSearch or Flow Framework workflow APIs, or from the workflow state // (note that if provision/deprovision fails, there is no concrete exception returned at the API level - @@ -80,34 +59,34 @@ export function Tools(props: ToolsProps) { useEffect(() => { setCurErrorMessage(opensearchError); if (!isEmpty(opensearchError)) { - setSelectedTabId(TAB_ID.ERRORS); + props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); } }, [opensearchError]); useEffect(() => { setCurErrorMessage(workflowsError); if (!isEmpty(workflowsError)) { - setSelectedTabId(TAB_ID.ERRORS); + props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); } }, [workflowsError]); useEffect(() => { setCurErrorMessage(props.workflow?.error || ''); if (!isEmpty(props.workflow?.error)) { - setSelectedTabId(TAB_ID.ERRORS); + props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); } }, [props.workflow?.error]); // auto-navigate to ingest tab if a populated value has been set, indicating ingest has been ran useEffect(() => { if (!isEmpty(props.ingestResponse)) { - setSelectedTabId(TAB_ID.INGEST); + props.setSelectedTabId(INSPECTOR_TAB_ID.INGEST); } }, [props.ingestResponse]); // auto-navigate to query tab if a populated value has been set, indicating search has been ran useEffect(() => { if (!isEmpty(props.queryResponse)) { - setSelectedTabId(TAB_ID.QUERY); + props.setSelectedTabId(INSPECTOR_TAB_ID.QUERY); } }, [props.queryResponse]); @@ -120,8 +99,10 @@ export function Tools(props: ToolsProps) { > @@ -131,11 +112,11 @@ export function Tools(props: ToolsProps) { - {inputTabs.map((tab, idx) => { + {INSPECTOR_TABS.map((tab, idx) => { return ( setSelectedTabId(tab.id)} - isSelected={tab.id === selectedTabId} + onClick={() => props.setSelectedTabId(tab.id)} + isSelected={tab.id === props.selectedTabId} disabled={tab.disabled} key={idx} > @@ -149,16 +130,26 @@ export function Tools(props: ToolsProps) { <> - {selectedTabId === TAB_ID.INGEST && ( + {props.selectedTabId === INSPECTOR_TAB_ID.INGEST && ( )} - {selectedTabId === TAB_ID.QUERY && ( - + {props.selectedTabId === INSPECTOR_TAB_ID.QUERY && ( + )} - {selectedTabId === TAB_ID.ERRORS && ( + {props.selectedTabId === INSPECTOR_TAB_ID.ERRORS && ( )} - {selectedTabId === TAB_ID.RESOURCES && ( + {props.selectedTabId === INSPECTOR_TAB_ID.RESOURCES && ( )} diff --git a/public/pages/workflow_detail/workflow_detail.test.tsx b/public/pages/workflow_detail/workflow_detail.test.tsx index ddb9f3fe..24359bc4 100644 --- a/public/pages/workflow_detail/workflow_detail.test.tsx +++ b/public/pages/workflow_detail/workflow_detail.test.tsx @@ -93,10 +93,9 @@ describe('WorkflowDetail Page with create ingestion option', () => { expect(getByRole('tab', { name: 'Errors' })).toBeInTheDocument(); expect(getByRole('tab', { name: 'Resources' })).toBeInTheDocument(); - // "Run ingestion" button should be enabled by default + // "Run ingestion" button exists const runIngestionButton = getByTestId('runIngestionButton'); expect(runIngestionButton).toBeInTheDocument(); - expect(runIngestionButton).toBeEnabled(); // "Search pipeline" button should be disabled by default const searchPipelineButton = getByTestId('searchPipelineButton'); @@ -194,15 +193,13 @@ describe('WorkflowDetail Page with skip ingestion option (Hybrid Search Workflow expect(getAllByText('Define search pipeline').length).toBeGreaterThan(0); }); expect(getAllByText('Configure query').length).toBeGreaterThan(0); - const searchTestButton = getByTestId('searchTestButton'); - expect(searchTestButton).toBeInTheDocument(); // Edit Search Query const queryEditButton = getByTestId('queryEditButton'); expect(queryEditButton).toBeInTheDocument(); userEvent.click(queryEditButton); await waitFor(() => { - expect(getAllByText('Edit query').length).toBeGreaterThan(0); + expect(getAllByText('Edit query definition').length).toBeGreaterThan(0); }); const searchQueryPresetButton = getByTestId('searchQueryPresetButton'); expect(searchQueryPresetButton).toBeInTheDocument(); diff --git a/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data_modal.tsx b/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data_modal.tsx index 97c31b61..667596fe 100644 --- a/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data_modal.tsx @@ -188,7 +188,11 @@ export function SourceDataModal(props: SourceDataProps) { }, [formikProps.errors]); return ( - onClose()} style={{ width: '70vw' }}> + onClose()} + style={{ width: '70vw' }} + >

{`Import data`}

diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_expression_modal.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_expression_modal.tsx index c321dd74..86a12a4b 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_expression_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_expression_modal.tsx @@ -260,6 +260,7 @@ export function ConfigureExpressionModal(props: ConfigureExpressionModalProps) { return ( +

{`Extract data with expression`}

diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_template_modal.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_template_modal.tsx index 8817e3bb..8cfc280f 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_template_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/configure_template_modal.tsx @@ -281,7 +281,11 @@ export function ConfigureTemplateModal(props: ConfigureTemplateModalProps) { } return ( - +

{`Configure prompt`}

diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/override_query_modal.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/override_query_modal.tsx index 430e8063..7542912f 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/override_query_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/modals/override_query_modal.tsx @@ -82,7 +82,11 @@ export function OverrideQueryModal(props: OverrideQueryModalProps) { const [presetsPopoverOpen, setPresetsPopoverOpen] = useState(false); return ( - +

{`Override query`}

diff --git a/public/pages/workflow_detail/workflow_inputs/search_inputs/configure_search_request.tsx b/public/pages/workflow_detail/workflow_inputs/search_inputs/configure_search_request.tsx index 04fbaee4..fea99ff3 100644 --- a/public/pages/workflow_detail/workflow_inputs/search_inputs/configure_search_request.tsx +++ b/public/pages/workflow_detail/workflow_inputs/search_inputs/configure_search_request.tsx @@ -17,13 +17,8 @@ import { EuiCodeBlock, EuiSmallButtonEmpty, } from '@elastic/eui'; -import { - SearchHit, - WorkflowFormValues, - customStringify, -} from '../../../../../common'; -import { AppState, searchIndex, useAppDispatch } from '../../../../store'; -import { getDataSourceId } from '../../../../utils/utils'; +import { WorkflowFormValues } from '../../../../../common'; +import { AppState } from '../../../../store'; import { EditQueryModal } from './edit_query_modal'; interface ConfigureSearchRequestProps { @@ -35,9 +30,6 @@ interface ConfigureSearchRequestProps { * Input component for configuring a search request */ export function ConfigureSearchRequest(props: ConfigureSearchRequestProps) { - const dispatch = useAppDispatch(); - const dataSourceId = getDataSourceId(); - // Form state const { values, setFieldValue, setFieldTouched } = useFormikContext< WorkflowFormValues @@ -133,43 +125,6 @@ export function ConfigureSearchRequest(props: ConfigureSearchRequestProps) { Edit - - { - // for this test query, we don't want to involve any configured search pipelines, if any exist - // see https://opensearch.org/docs/latest/search-plugins/search-pipelines/using-search-pipeline/#disabling-the-default-pipeline-for-a-request - dispatch( - searchIndex({ - apiBody: { - index: values.search.index.name, - body: values.search.request, - searchPipeline: '_none', - }, - dataSourceId, - }) - ) - .unwrap() - .then(async (resp) => { - props.setQueryResponse( - customStringify( - resp?.hits?.hits?.map( - (hit: SearchHit) => hit._source - ) - ) - ); - }) - .catch((error: any) => { - props.setQueryResponse(''); - console.error('Error running query: ', error); - }); - }} - data-testid="searchTestButton" - iconType="play" - iconSide="left" - > - Test query - - diff --git a/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx b/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx index 1f5bbc06..48affada 100644 --- a/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx @@ -16,18 +16,36 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiPopover, - EuiSpacer, EuiSmallButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiCodeEditor, + EuiEmptyPrompt, + EuiCallOut, } from '@elastic/eui'; import { JsonField } from '../input_fields'; import { + customStringify, IConfigField, QUERY_PRESETS, + QueryParam, QueryPreset, RequestFormValues, + SearchHit, WorkflowFormValues, } from '../../../../../common'; -import { getFieldSchema, getInitialValue } from '../../../../utils'; +import { + containsEmptyValues, + containsSameValues, + getDataSourceId, + getFieldSchema, + getInitialValue, + getPlaceholdersFromQuery, + injectParameters, +} from '../../../../utils'; +import { searchIndex, useAppDispatch } from '../../../../store'; +import { QueryParamsList } from '../../../../general_components'; interface EditQueryModalProps { queryFieldPath: string; @@ -39,6 +57,9 @@ interface EditQueryModalProps { * a set of pre-defined queries targeted for different use cases. */ export function EditQueryModal(props: EditQueryModalProps) { + const dispatch = useAppDispatch(); + const dataSourceId = getDataSourceId(); + // sub-form values/schema const requestFormValues = { request: getInitialValue('json'), @@ -61,6 +82,43 @@ export function EditQueryModal(props: EditQueryModalProps) { // popover state const [popoverOpen, setPopoverOpen] = useState(false); + // results state + const [tempResults, setTempResults] = useState(''); + const [tempResultsError, setTempResultsError] = useState(''); + + // query/request params state + const [queryParams, setQueryParams] = useState([]); + + // Do a few things when the request is changed: + // 1. Check if there is a new set of query parameters, and if so, + // reset the form. + // 2. Clear any persisted error + // 3. Clear any stale results + useEffect(() => { + const placeholders = getPlaceholdersFromQuery(tempRequest); + if ( + !containsSameValues( + placeholders, + queryParams.map((queryParam) => queryParam.name) + ) + ) { + setQueryParams( + placeholders.map((placeholder) => ({ + name: placeholder, + type: 'Text', + value: '', + })) + ); + } + setTempResultsError(''); + setTempResults(''); + }, [tempRequest]); + + // Clear any error if the parameters have been updated in any way + useEffect(() => { + setTempResultsError(''); + }, [queryParams]); + return ( props.setModalOpen(false)} - style={{ width: '70vw' }} + style={{ width: '70vw', height: '70vh' }} data-testid="editQueryModal" + maxWidth={false} > -

{`Edit query`}

+

{`Edit query definition`}

- setPopoverOpen(!popoverOpen)} - data-testid="searchQueryPresetButton" - iconSide="right" - iconType="arrowDown" - > - Choose from a preset - - } - isOpen={popoverOpen} - closePopover={() => setPopoverOpen(false)} - anchorPosition="downLeft" - > - ({ - name: preset.name, - onClick: () => { - formikProps.setFieldValue('request', preset.query); - setPopoverOpen(false); - }, - })), - }, - ]} - /> - - - + + + + + + + Query definition + + + setPopoverOpen(!popoverOpen)} + data-testid="searchQueryPresetButton" + iconSide="right" + iconType="arrowDown" + > + Query samples + + } + isOpen={popoverOpen} + closePopover={() => setPopoverOpen(false)} + anchorPosition="downLeft" + > + ({ + name: preset.name, + onClick: () => { + formikProps.setFieldValue( + 'request', + preset.query + ); + setPopoverOpen(false); + }, + }) + ), + }, + ]} + /> + + + + + + + + + + + + + + + Test query + + + { + dispatch( + searchIndex({ + apiBody: { + index: values?.search?.index?.name, + body: injectParameters( + queryParams, + tempRequest + ), + // Run the query independent of the pipeline inside this modal + searchPipeline: '_none', + }, + dataSourceId, + }) + ) + .unwrap() + .then(async (resp) => { + setTempResults( + customStringify( + resp?.hits?.hits?.map( + (hit: SearchHit) => hit._source + ) + ) + ); + setTempResultsError(''); + }) + .catch((error: any) => { + setTempResults(''); + const errorMsg = `Error running query: ${error}`; + setTempResultsError(errorMsg); + console.error(errorMsg); + }); + }} + > + Search + + + + + {/** + * This may return nothing if the list of params are empty + */} + + + <> + Results + {isEmpty(tempResults) && isEmpty(tempResultsError) ? ( + No results} + titleSize="s" + body={ + <> + + Run search to view results. + + + } + /> + ) : !isEmpty(tempResultsError) ? ( + + ) : ( + + )} + + + + + void; setUnsavedIngestProcessors: (unsavedIngestProcessors: boolean) => void; setUnsavedSearchProcessors: (unsavedSearchProcessors: boolean) => void; + displaySearchPanel: () => void; } /** @@ -106,9 +106,10 @@ export function WorkflowInputs(props: WorkflowInputsProps) { // provisioned resources states const [ingestProvisioned, setIngestProvisioned] = useState(false); + const [searchProvisioned, setSearchProvisioned] = useState(false); // confirm modal state - const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); // last ingested state const [lastIngested, setLastIngested] = useState( @@ -120,6 +121,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { const onSearch = props.selectedStep === CONFIG_STEP.SEARCH; const ingestEnabled = values?.ingest?.enabled || false; const onIngestAndProvisioned = onIngest && ingestProvisioned; + const onSearchAndProvisioned = onSearch && searchProvisioned; const onIngestAndUnprovisioned = onIngest && !ingestProvisioned; const onIngestAndDisabled = onIngest && !ingestEnabled; const isProposingNoSearchResources = @@ -235,9 +237,22 @@ export function WorkflowInputs(props: WorkflowInputsProps) { useEffect(() => { setIngestProvisioned(hasProvisionedIngestResources(props.workflow)); }, [props.workflow]); + useEffect(() => { + setSearchProvisioned(hasProvisionedSearchResources(props.workflow)); + }, [props.workflow]); + + // populated ingest docs state + const [docsPopulated, setDocsPopulated] = useState(false); + useEffect(() => { + let parsedDocsObjs = [] as {}[]; + try { + parsedDocsObjs = JSON.parse(props.ingestDocs); + } catch (e) {} + setDocsPopulated(parsedDocsObjs.length > 0 && !isEmpty(parsedDocsObjs[0])); + }, [props.ingestDocs]); // maintain global states (button eligibility) - const ingestRunButtonDisabled = !ingestTemplatesDifferent; + const ingestRunButtonDisabled = !ingestTemplatesDifferent || !docsPopulated; const ingestToSearchButtonDisabled = ingestTemplatesDifferent || props.isRunningIngest; const searchBackButtonDisabled = @@ -585,8 +600,11 @@ export function WorkflowInputs(props: WorkflowInputsProps) { }, ]} /> - {isModalOpen && ( - setIsModalOpen(false)}> + {isDeleteModalOpen && ( + setIsDeleteModalOpen(false)} + >

{`Delete resources for workflow ${getCharacterLimitedString( @@ -602,7 +620,9 @@ export function WorkflowInputs(props: WorkflowInputsProps) { - setIsModalOpen(false)}> + setIsDeleteModalOpen(false)} + > {' '} Cancel @@ -612,6 +632,9 @@ export function WorkflowInputs(props: WorkflowInputsProps) { onClick={async () => { setIsRunningDelete(true); await dispatch( + // If in the future we want to start with a fresh/empty state after deleting resources, + // will need to update the workflow with an empty UI config before re-fetching the workflow. + // For now we still persist the config, just clean up / deprovision the resources. deprovisionWorkflow({ apiBody: { workflowId: props.workflow?.id as string, @@ -624,6 +647,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { ) .unwrap() .then(async (result) => { + props.setSelectedStep(CONFIG_STEP.INGEST); setFieldValue('ingest.enabled', false); // @ts-ignore await dispatch( @@ -635,7 +659,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { }) .catch((error: any) => {}) .finally(() => { - setIsModalOpen(false); + setIsDeleteModalOpen(false); setIsRunningDelete(false); }); }} @@ -660,19 +684,38 @@ export function WorkflowInputs(props: WorkflowInputsProps) {

{onIngestAndUnprovisioned ? ( 'Define ingest pipeline' - ) : onIngestAndProvisioned ? ( + ) : onIngestAndProvisioned || onSearchAndProvisioned ? ( - Edit ingest pipeline + {onIngestAndProvisioned + ? `Edit ingest pipeline` + : `Edit search pipeline`} - setIsModalOpen(true)} - > - - {` `}Delete resources - + + + setIsDeleteModalOpen(true)} + iconType="trash" + iconSide="left" + > + Delete resources + + + {onSearchAndProvisioned && ( + + { + props.displaySearchPanel(); + }} + > + Test pipeline + + + )} + ) : ( diff --git a/public/pages/workflows/new_workflow/index.ts b/public/pages/workflows/new_workflow/index.ts index a1918c7a..4fc77b3d 100644 --- a/public/pages/workflows/new_workflow/index.ts +++ b/public/pages/workflows/new_workflow/index.ts @@ -4,3 +4,4 @@ */ export { NewWorkflow } from './new_workflow'; +export { fetchEmptyMetadata, fetchEmptyUIConfig } from './utils'; diff --git a/public/pages/workflows/new_workflow/utils.ts b/public/pages/workflows/new_workflow/utils.ts index 30b15854..39d1ab84 100644 --- a/public/pages/workflows/new_workflow/utils.ts +++ b/public/pages/workflows/new_workflow/utils.ts @@ -27,6 +27,7 @@ import { VECTOR_PATTERN, KNN_QUERY, HYBRID_SEARCH_QUERY_MATCH_KNN, + WorkflowConfig, } from '../../../../common'; import { generateId } from '../../../utils'; @@ -74,64 +75,68 @@ export function enrichPresetWorkflowWithUiMetadata( export function fetchEmptyMetadata(): UIState { return { type: WORKFLOW_TYPE.CUSTOM, - config: { - ingest: { - enabled: { - id: 'enabled', - type: 'boolean', - value: true, - }, - pipelineName: { - id: 'pipelineName', + config: fetchEmptyUIConfig(), + }; +} + +export function fetchEmptyUIConfig(): WorkflowConfig { + return { + ingest: { + enabled: { + id: 'enabled', + type: 'boolean', + value: true, + }, + pipelineName: { + id: 'pipelineName', + type: 'string', + value: generateId('ingest_pipeline'), + }, + enrich: { + processors: [], + }, + index: { + name: { + id: 'indexName', type: 'string', - value: generateId('ingest_pipeline'), + value: generateId('my_index', 6), }, - enrich: { - processors: [], - }, - index: { - name: { - id: 'indexName', - type: 'string', - value: generateId('my_index', 6), - }, - mappings: { - id: 'indexMappings', - type: 'json', - value: customStringify({ - properties: {}, - }), - }, - settings: { - id: 'indexSettings', - type: 'json', - }, + mappings: { + id: 'indexMappings', + type: 'json', + value: customStringify({ + properties: {}, + }), }, - }, - search: { - request: { - id: 'request', + settings: { + id: 'indexSettings', type: 'json', - value: customStringify(FETCH_ALL_QUERY), }, - pipelineName: { - id: 'pipelineName', + }, + }, + search: { + request: { + id: 'request', + type: 'json', + value: customStringify(FETCH_ALL_QUERY), + }, + pipelineName: { + id: 'pipelineName', + type: 'string', + value: generateId('search_pipeline'), + }, + index: { + name: { + id: 'indexName', type: 'string', - value: generateId('search_pipeline'), - }, - index: { - name: { - id: 'indexName', - type: 'string', - }, - }, - enrichRequest: { - processors: [], - }, - enrichResponse: { - processors: [], }, }, + enrichRequest: { + processors: [], + }, + enrichResponse: { + processors: [], + }, }, }; } diff --git a/public/utils/utils.ts b/public/utils/utils.ts index d456d0ae..a7b2f09b 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -32,6 +32,7 @@ import { ModelInputMap, ModelOutputMap, OutputMapEntry, + QueryParam, } from '../../common/interfaces'; import queryString from 'query-string'; import { useLocation } from 'react-router-dom'; @@ -544,3 +545,56 @@ export function sanitizeJSONPath(path: string): string { } }); } + +// given a stringified query, extract out all unique placeholder vars +// that follow the pattern {{some-placeholder}} +export function getPlaceholdersFromQuery(queryString: string): string[] { + const regex = /\{\{([^}]+)\}\}/g; + return [ + // convert to set to collapse duplicate names + ...new Set([...queryString.matchAll(regex)].map((match) => match[1])), + ]; +} + +// simple fn to check if the values in an arr are the same. used for +// checking if the same set of placeholders exists when a new query is selected, +// or an existing query is updated. +export function containsSameValues(arr1: string[], arr2: string[]) { + if (arr1.length !== arr2.length) { + return false; + } + arr1.sort(); + arr2.sort(); + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + return true; +} + +// simple util fn to check for empty/missing query param values +export function containsEmptyValues(params: QueryParam[]): boolean { + let containsEmpty = false; + params.forEach((param) => { + if (isEmpty(param.value)) { + containsEmpty = true; + } + }); + return containsEmpty; +} + +// simple util fn to inject parameters in the base query string with its associated value +export function injectParameters( + params: QueryParam[], + queryString: string +): string { + let finalQueryString = queryString; + params.forEach((param) => { + finalQueryString = finalQueryString.replace( + new RegExp(`{{${param.name}}}`, 'g'), + param.value + ); + }); + return finalQueryString; +}