diff --git a/common/interfaces.ts b/common/interfaces.ts index 27712cdd..39cad171 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -596,6 +596,27 @@ export type SimulateIngestPipelineResponse = { export type SearchHit = SimulateIngestPipelineDoc; +export type SearchResponse = { + took: number; + timed_out: boolean; + _shards: { + total: number; + successful: number; + skipped: number; + failed: number; + }; + hits: { + total: { + value: number; + relation: string; + }; + max_score: number; + hits: SearchHit[]; + }; + aggregations?: {}; + ext?: {}; +}; + export type IndexResponse = { indexName: string; indexDetails: IndexConfiguration; diff --git a/public/general_components/index.ts b/public/general_components/index.ts index 1ce91089..60528d1e 100644 --- a/public/general_components/index.ts +++ b/public/general_components/index.ts @@ -7,4 +7,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 './results'; export * from './service_card'; diff --git a/public/general_components/results/index.ts b/public/general_components/results/index.ts new file mode 100644 index 00000000..b5201bd4 --- /dev/null +++ b/public/general_components/results/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { Results } from './results'; diff --git a/public/general_components/results/results.tsx b/public/general_components/results/results.tsx new file mode 100644 index 00000000..a85e320c --- /dev/null +++ b/public/general_components/results/results.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSmallButtonGroup, +} from '@elastic/eui'; +import { SearchResponse } from '../../../common'; +import { ResultsTable } from './results_table'; +import { ResultsJSON } from './results_json'; + +interface ResultsProps { + response: SearchResponse; +} + +enum VIEW { + HITS_TABLE = 'hits_table', + RAW_JSON = 'raw_json', +} + +/** + * Basic component to view OpenSearch response results. Can view hits in a tabular format, + * or the raw JSON response. + */ +export function Results(props: ResultsProps) { + // selected view state + const [selectedView, setSelectedView] = useState(VIEW.HITS_TABLE); + + return ( + + + + setSelectedView(id as VIEW)} + data-testid="resultsToggleButtonGroup" + /> + + + <> + {selectedView === VIEW.HITS_TABLE && ( + + )} + {selectedView === VIEW.RAW_JSON && ( + + )} + + + + + ); +} diff --git a/public/general_components/results/results_json.tsx b/public/general_components/results/results_json.tsx new file mode 100644 index 00000000..e2000c5f --- /dev/null +++ b/public/general_components/results/results_json.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiCodeEditor } from '@elastic/eui'; +import { customStringify, SearchResponse } from '../../../common'; + +interface ResultsJSONProps { + response: SearchResponse; +} + +/** + * Small component to render the raw search response. + */ +export function ResultsJSON(props: ResultsJSONProps) { + return ( + + ); +} diff --git a/public/general_components/results/results_table.tsx b/public/general_components/results/results_table.tsx new file mode 100644 index 00000000..d423dbd5 --- /dev/null +++ b/public/general_components/results/results_table.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiButtonIcon, + RIGHT_ALIGNMENT, + EuiInMemoryTable, + EuiCodeEditor, + EuiPanel, +} from '@elastic/eui'; +import { customStringify, SearchHit } from '../../../common'; + +interface ResultsTableProps { + hits: SearchHit[]; +} + +/** + * Small component to display a list of search results with pagination. + * Can expand each entry to view the full _source response + */ +export function ResultsTable(props: ResultsTableProps) { + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ + [itemId: string]: any; + }>({}); + + const toggleDetails = (hit: SearchHit) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[hit._id]) { + delete itemIdToExpandedRowMapValues[hit._id]; + } else { + itemIdToExpandedRowMapValues[hit._id] = ( + + + + ); + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + return ( + { + return ( + + {customStringify(item._source)} + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: SearchHit) => ( + toggleDetails(item)} + aria-label={ + itemIdToExpandedRowMap[item._id] ? 'Collapse' : 'Expand' + } + iconType={ + itemIdToExpandedRowMap[item._id] ? 'arrowUp' : 'arrowDown' + } + /> + ), + }, + ]} + /> + ); +} diff --git a/public/pages/workflow_detail/resizable_workspace.tsx b/public/pages/workflow_detail/resizable_workspace.tsx index b7976c6d..89d13268 100644 --- a/public/pages/workflow_detail/resizable_workspace.tsx +++ b/public/pages/workflow_detail/resizable_workspace.tsx @@ -82,7 +82,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { // 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); @@ -207,8 +206,6 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { void; hasSearchPipeline: boolean; hasIngestResources: boolean; selectedStep: CONFIG_STEP; @@ -65,6 +63,11 @@ export function Query(props: QueryProps) { // use custom query state const [useCustomQuery, setUseCustomQuery] = useState(false); + // query response state + const [queryResponse, setQueryResponse] = useState< + SearchResponse | undefined + >(undefined); + // 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), and update based on changes to the context @@ -116,7 +119,7 @@ export function Query(props: QueryProps) { })) ); } - props.setQueryResponse(''); + setQueryResponse(undefined); }, [tempRequest]); // empty states @@ -182,17 +185,11 @@ export function Query(props: QueryProps) { }) ) .unwrap() - .then(async (resp) => { - props.setQueryResponse( - customStringify( - resp?.hits?.hits?.map( - (hit: SearchHit) => hit._source - ) - ) - ); + .then(async (resp: SearchResponse) => { + setQueryResponse(resp); }) .catch((error: any) => { - props.setQueryResponse(''); + setQueryResponse(undefined); console.error('Error running query: ', error); }); }} @@ -283,7 +280,7 @@ export function Query(props: QueryProps) { Results - {isEmpty(props.queryResponse) ? ( + {queryResponse === undefined || isEmpty(queryResponse) ? ( No results} titleSize="s" @@ -294,23 +291,7 @@ export function Query(props: QueryProps) { } /> ) : ( - // 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 15fc4725..b973ca18 100644 --- a/public/pages/workflow_detail/tools/tools.tsx +++ b/public/pages/workflow_detail/tools/tools.tsx @@ -33,8 +33,6 @@ import { 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; @@ -83,13 +81,6 @@ export function Tools(props: ToolsProps) { } }, [props.ingestResponse]); - // auto-navigate to query tab if a populated value has been set, indicating search has been ran - useEffect(() => { - if (!isEmpty(props.queryResponse)) { - props.setSelectedTabId(INSPECTOR_TAB_ID.QUERY); - } - }, [props.queryResponse]); - return ( (false); + // optional search panel state. allows searching within the modal + const [searchPanelOpen, setSearchPanelOpen] = useState(false); + // results state - const [tempResults, setTempResults] = useState(''); + const [queryResponse, setQueryResponse] = useState< + SearchResponse | undefined + >(undefined); const [tempResultsError, setTempResultsError] = useState(''); // query/request params state @@ -111,7 +114,7 @@ export function EditQueryModal(props: EditQueryModalProps) { ); } setTempResultsError(''); - setTempResults(''); + setQueryResponse(undefined); }, [tempRequest]); // Clear any error if the parameters have been updated in any way @@ -171,43 +174,62 @@ export function EditQueryModal(props: EditQueryModalProps) { 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); + }, + }) + ), + }, + ]} + /> + + + setPopoverOpen(!popoverOpen)} - data-testid="searchQueryPresetButton" + data-testid="showOrHideSearchPanelButton" + fill={false} + iconType={ + searchPanelOpen ? 'menuLeft' : 'menuRight' + } iconSide="right" - iconType="arrowDown" + onClick={() => { + setSearchPanelOpen(!searchPanelOpen); + }} > - Query samples + Test query - } - isOpen={popoverOpen} - closePopover={() => setPopoverOpen(false)} - anchorPosition="downLeft" - > - ({ - name: preset.name, - onClick: () => { - formikProps.setFieldValue( - 'request', - preset.query - ); - setPopoverOpen(false); - }, - }) - ), - }, - ]} - /> - + + @@ -221,103 +243,95 @@ export function EditQueryModal(props: EditQueryModalProps) { - - - - - - 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) ? ( - - ) : ( - - )} - - - - + {searchPanelOpen && ( + + + + + + 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: SearchResponse) => { + setQueryResponse(resp); + setTempResultsError(''); + }) + .catch((error: any) => { + setQueryResponse(undefined); + const errorMsg = `Error running query: ${error}`; + setTempResultsError(errorMsg); + console.error(errorMsg); + }); + }} + > + Search + + + + + {/** + * This may return nothing if the list of params are empty + */} + + + <> + Results + {(queryResponse === undefined || + isEmpty(queryResponse)) && + isEmpty(tempResultsError) ? ( + No results} + titleSize="s" + body={ + <> + + Run search to view results. + + + } + /> + ) : (queryResponse === undefined || + isEmpty(queryResponse)) && + !isEmpty(tempResultsError) ? ( + + ) : ( + + )} + + + + + )} diff --git a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx index 9794e624..fc1485ea 100644 --- a/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/workflow_inputs.tsx @@ -171,10 +171,10 @@ export function WorkflowInputs(props: WorkflowInputsProps) { formikToUiConfig(values, props.uiConfig as WorkflowConfig), true, includeSearchDuringProvision - ).provision.nodes) || + )?.provision?.nodes) || [] ); - }, [values, props.uiConfig, props.workflow]); + }, [values, props.uiConfig, props.workflow, includeSearchDuringProvision]); // fetch the persisted template nodes for ingest & search useEffect(() => { @@ -502,6 +502,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { .unwrap() .then(async (resp) => { props.setIngestResponse(customStringify(resp)); + props.setIsRunningIngest(false); setLastIngested(Date.now()); }) .catch((error: any) => { @@ -792,6 +793,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { 'Search pipeline updated' ); props.displaySearchPanel(); + setSearchProvisioned(true); } }} > diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index d9daf1d2..302a012f 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -66,11 +66,11 @@ export function Workspace(props: WorkspaceProps) { >(TOGGLE_BUTTON_ID.VISUAL); const toggleButtons = [ { - id: `workspaceVisualButton`, + id: TOGGLE_BUTTON_ID.VISUAL, label: 'Visual', }, { - id: `workspaceJSONButton`, + id: TOGGLE_BUTTON_ID.JSON, label: 'JSON', }, ];