diff --git a/common/constants.ts b/common/constants.ts index d036669b..b8c51b85 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -546,6 +546,7 @@ export const MAX_DOCS = 1000; export const MAX_STRING_LENGTH = 100; export const MAX_JSON_STRING_LENGTH = 10000; export const MAX_TEMPLATE_STRING_LENGTH = 10000; +export const MAX_BYTES = 1048576; // OSD REST request payload size limit export const MAX_WORKFLOW_NAME_TO_DISPLAY = 40; export const WORKFLOW_NAME_REGEXP = RegExp('^[a-zA-Z0-9_-]*$'); export const EMPTY_MAP_ENTRY = { key: '', value: '' } as MapEntry; diff --git a/public/general_components/index.ts b/public/general_components/index.ts index 60528d1e..ed0d4957 100644 --- a/public/general_components/index.ts +++ b/public/general_components/index.ts @@ -7,5 +7,6 @@ export { MultiSelectFilter } from './multi_select_filter'; export { ProcessorsTitle } from './processors_title'; export { ExperimentalBadge } from './experimental_badge'; export { QueryParamsList } from './query_params_list'; +export { JsonPathExamplesTable } from './jsonpath_examples_table'; export * from './results'; export * from './service_card'; diff --git a/public/general_components/jsonpath_examples_table.tsx b/public/general_components/jsonpath_examples_table.tsx new file mode 100644 index 00000000..b05c9eb6 --- /dev/null +++ b/public/general_components/jsonpath_examples_table.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { isEmpty } from 'lodash'; +import { + EuiInMemoryTable, + EuiCode, + EuiText, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +interface JsonPathExamplesTableProps { + headerText?: string; +} + +type JSONPathExample = { + expression: string; + meaning: string; + example: string; +}; + +const examples = [ + { + expression: '$.data', + meaning: 'The entire input', + example: '$.data', + }, +] as JSONPathExample[]; + +const columns = [ + { + field: 'expression', + name: 'Expression', + width: '25%', + sortable: false, + render: (expression: string) => {expression}, + }, + { + field: 'meaning', + name: 'Meaning', + width: '50%', + sortable: false, + render: (meaning: string) => {meaning}, + }, + { + field: 'example', + name: 'Example', + width: '25%', + sortable: false, + render: (example: string) => {example}, + }, +]; + +/** + * A stateless component containing JSONPath examples in a table. Optionally takes in + * a header text for some more contextual information. + */ +export function JsonPathExamplesTable(props: JsonPathExamplesTableProps) { + return ( + + + {!isEmpty(props.headerText) && ( + + {props.headerText} + + )} + + + items={examples} + columns={columns} + pagination={false} + sorting={false} + hasActions={false} + /> + + + + ); +} diff --git a/public/pages/workflow_detail/tools/query/query.tsx b/public/pages/workflow_detail/tools/query/query.tsx index 155751d6..4d4be165 100644 --- a/public/pages/workflow_detail/tools/query/query.tsx +++ b/public/pages/workflow_detail/tools/query/query.tsx @@ -4,6 +4,7 @@ */ import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { isEmpty } from 'lodash'; import { useFormikContext } from 'formik'; import { @@ -13,7 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSmallButton, - EuiSwitch, + EuiSmallButtonEmpty, EuiText, } from '@elastic/eui'; import { @@ -24,7 +25,7 @@ import { SearchResponse, WorkflowFormValues, } from '../../../../../common'; -import { searchIndex, useAppDispatch } from '../../../../store'; +import { AppState, searchIndex, useAppDispatch } from '../../../../store'; import { containsEmptyValues, containsSameValues, @@ -57,12 +58,11 @@ export function Query(props: QueryProps) { const dispatch = useAppDispatch(); const dataSourceId = getDataSourceId(); + const { loading } = useSelector((state: AppState) => state.opensearch); + // Form state const { values } = useFormikContext(); - // use custom query state - const [useCustomQuery, setUseCustomQuery] = useState(false); - // query response state const [queryResponse, setQueryResponse] = useState< SearchResponse | undefined @@ -158,11 +158,41 @@ export function Query(props: QueryProps) { - Search + + + Search + + + { + setIncludePipeline(!includePipeline); + }} + /> + + - { - setIncludePipeline(!includePipeline); - }} - /> + + + Query + + {props.selectedStep === CONFIG_STEP.SEARCH && + !isEmpty(values?.search?.request) && + values?.search?.request !== tempRequest && ( + + { + setTempRequest(values?.search?.request); + }} + > + Revert to query definition + + + )} + - - setUseCustomQuery(!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} /> - {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 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 7542912f..5d0e2ef7 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 @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; -import { useFormikContext, getIn } from 'formik'; +import React, { useEffect, useState } from 'react'; +import { useFormikContext, getIn, Formik } from 'formik'; +import * as yup from 'yup'; import { EuiFlexGroup, EuiFlexItem, @@ -23,8 +24,10 @@ import { EuiCopy, EuiButtonIcon, EuiContextMenu, + EuiSmallButtonEmpty, } from '@elastic/eui'; import { + IConfigField, IMAGE_FIELD_PATTERN, IProcessorConfig, LABEL_FIELD_PATTERN, @@ -35,6 +38,7 @@ import { QUERY_PRESETS, QUERY_TEXT_PATTERN, QueryPreset, + RequestFormValues, TEXT_FIELD_PATTERN, VECTOR_FIELD_PATTERN, VECTOR_PATTERN, @@ -42,6 +46,7 @@ import { } from '../../../../../../../common'; import { parseModelOutputs } from '../../../../../../utils/utils'; import { JsonField } from '../../../input_fields'; +import { getFieldSchema, getInitialValue } from '../../../../../../utils'; interface OverrideQueryModalProps { config: IProcessorConfig; @@ -59,6 +64,19 @@ export function OverrideQueryModal(props: OverrideQueryModalProps) { WorkflowFormValues >(); + // sub-form values/schema + const requestFormValues = { + request: getInitialValue('json'), + } as RequestFormValues; + const requestFormSchema = yup.object({ + request: getFieldSchema({ + type: 'json', + } as IConfigField), + }) as yup.Schema; + + // persist standalone values. update / initialize when it is first opened + const [tempRequest, setTempRequest] = useState('{}'); + // get some current form values const modelOutputs = parseModelOutputs(props.modelInterface); const queryFieldPath = `${props.baseConfigPath}.${props.config.id}.query_template`; @@ -82,145 +100,199 @@ export function OverrideQueryModal(props: OverrideQueryModalProps) { const [presetsPopoverOpen, setPresetsPopoverOpen] = useState(false); return ( - {}} + validate={(values) => {}} > - - -

{`Override query`}

-
-
- - - Configure a custom query template to override the existing one. - Optionally inject dynamic model outputs into the new query. - - - - <> - - setPresetsPopoverOpen(!presetsPopoverOpen)} - iconSide="right" - iconType="arrowDown" - > - Choose from a preset - - } - isOpen={presetsPopoverOpen} - closePopover={() => setPresetsPopoverOpen(false)} - anchorPosition="downLeft" - > - ({ - name: preset.name, - onClick: () => { - setFieldValue( - queryFieldPath, - preset.query - // sanitize the query preset string into valid template placeholder format, for - // any placeholder values in the query. - // for example, replacing `"{{vector}}"` with `${vector}` - .replace( - new RegExp(`"${VECTOR_FIELD_PATTERN}"`, 'g'), - `\$\{vector_field\}` - ) - .replace( - new RegExp(`"${VECTOR_PATTERN}"`, 'g'), - `\$\{vector\}` - ) - .replace( - new RegExp(`"${TEXT_FIELD_PATTERN}"`, 'g'), - `\$\{text_field\}` - ) - .replace( - new RegExp(`"${IMAGE_FIELD_PATTERN}"`, 'g'), - `\$\{image_field\}` - ) - .replace( - new RegExp(`"${LABEL_FIELD_PATTERN}"`, 'g'), - `\$\{label_field\}` - ) - .replace( - new RegExp(`"${QUERY_TEXT_PATTERN}"`, 'g'), - `\$\{query_text\}` - ) - .replace( - new RegExp(`"${QUERY_IMAGE_PATTERN}"`, 'g'), - `\$\{query_image\}` - ) - .replace( - new RegExp(`"${MODEL_ID_PATTERN}"`, 'g'), - `\$\{model_id\}` - ) - ); - setFieldTouched(queryFieldPath, true); - setPresetsPopoverOpen(false); - }, - })), - }, - ]} - /> - - - - {finalModelOutputs.length > 0 && ( - <> - - - <> - - { + // override to parent form value when changes detected + useEffect(() => { + formikProps.setFieldValue('request', getIn(values, queryFieldPath)); + }, [getIn(values, queryFieldPath)]); + + // update tempRequest when form changes are detected + useEffect(() => { + setTempRequest(getIn(formikProps.values, 'request')); + }, [getIn(formikProps.values, 'request')]); + + return ( + + + +

{`Override query`}

+
+
+ + + Configure a custom query template to override the existing one. + Optionally inject dynamic model outputs into the new query. + + + + <> + + + setPresetsPopoverOpen(!presetsPopoverOpen) + } + iconSide="right" + iconType="arrowDown" + > + Choose from a preset + + } + isOpen={presetsPopoverOpen} + closePopover={() => setPresetsPopoverOpen(false)} + anchorPosition="downLeft" + > + - To use any model outputs in the query template, copy the - placeholder string directly. -
- - ({ + name: preset.name, + onClick: () => { + formikProps.setFieldValue( + 'request', + preset.query + // sanitize the query preset string into valid template placeholder format, for + // any placeholder values in the query. + // for example, replacing `"{{vector}}"` with `${vector}` + .replace( + new RegExp( + `"${VECTOR_FIELD_PATTERN}"`, + 'g' + ), + `\$\{vector_field\}` + ) + .replace( + new RegExp(`"${VECTOR_PATTERN}"`, 'g'), + `\$\{vector\}` + ) + .replace( + new RegExp( + `"${TEXT_FIELD_PATTERN}"`, + 'g' + ), + `\$\{text_field\}` + ) + .replace( + new RegExp( + `"${IMAGE_FIELD_PATTERN}"`, + 'g' + ), + `\$\{image_field\}` + ) + .replace( + new RegExp( + `"${LABEL_FIELD_PATTERN}"`, + 'g' + ), + `\$\{label_field\}` + ) + .replace( + new RegExp( + `"${QUERY_TEXT_PATTERN}"`, + 'g' + ), + `\$\{query_text\}` + ) + .replace( + new RegExp( + `"${QUERY_IMAGE_PATTERN}"`, + 'g' + ), + `\$\{query_image\}` + ) + .replace( + new RegExp(`"${MODEL_ID_PATTERN}"`, 'g'), + `\$\{model_id\}` + ) + ); + formikProps.setFieldTouched('request', true); + setPresetsPopoverOpen(false); + }, + })), + }, + ]} /> - -
- - - )} - -
-
-
- - - Close - - -
+ + + + {finalModelOutputs.length > 0 && ( + <> + + + <> + + + To use any model outputs in the query template, + copy the placeholder string directly. + + + + + + + + )} + +
+
+ + + + Cancel + + { + setFieldValue(queryFieldPath, tempRequest); + setFieldTouched(queryFieldPath, true); + props.onClose(); + }} + isDisabled={false} // users can always save. we can't easily validate the JSON, as it can contain placeholders that isn't valid JSON. + fill={true} + color="primary" + data-testid="updateOverrideQueryButton" + > + Save + + + + ); + }} + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_inputs.tsx index 6ec1da14..1723dc43 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_inputs.tsx @@ -190,16 +190,7 @@ export function ModelInputs(props: ModelInputsProps) { // Adding a map entry to the end of the existing arr function addMapEntry(curEntries: InputMapFormValue): void { - const updatedEntries = [ - ...curEntries, - { - key: '', - value: { - transformType: '' as TRANSFORM_TYPE, - value: '', - }, - } as InputMapEntry, - ]; + const updatedEntries = [...curEntries, EMPTY_INPUT_MAP_ENTRY]; setFieldValue(inputMapFieldPath, updatedEntries); setFieldTouched(inputMapFieldPath, true); } diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_outputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_outputs.tsx index 0ca94d8d..57383c5e 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_outputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/model_outputs.tsx @@ -112,16 +112,7 @@ export function ModelOutputs(props: ModelOutputsProps) { // Adding a map entry to the end of the existing arr function addMapEntry(curEntries: OutputMapFormValue): void { - const updatedEntries = [ - ...curEntries, - { - key: '', - value: { - transformType: '' as TRANSFORM_TYPE, - value: '', - }, - } as OutputMapEntry, - ]; + const updatedEntries = [...curEntries, EMPTY_OUTPUT_MAP_ENTRY]; setFieldValue(outputMapFieldPath, updatedEntries); setFieldTouched(outputMapFieldPath, true); } @@ -427,6 +418,9 @@ export function ModelOutputs(props: ModelOutputsProps) { } showError={false} /> + ) : transformType === + NO_TRANSFORMATION ? ( + - ) : undefined}
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 3823bd29..ce18a570 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 @@ -4,6 +4,7 @@ */ import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { Formik, getIn, useFormikContext } from 'formik'; import * as yup from 'yup'; import { isEmpty } from 'lodash'; @@ -42,7 +43,7 @@ import { getPlaceholdersFromQuery, injectParameters, } from '../../../../utils'; -import { searchIndex, useAppDispatch } from '../../../../store'; +import { AppState, searchIndex, useAppDispatch } from '../../../../store'; import { QueryParamsList, Results } from '../../../../general_components'; interface EditQueryModalProps { @@ -57,6 +58,7 @@ interface EditQueryModalProps { export function EditQueryModal(props: EditQueryModalProps) { const dispatch = useAppDispatch(); const dataSourceId = getDataSourceId(); + const { loading } = useSelector((state: AppState) => state.opensearch); // sub-form values/schema const requestFormValues = { @@ -257,6 +259,7 @@ export function EditQueryModal(props: EditQueryModalProps) { { dispatch( diff --git a/public/utils/config_to_schema_utils.ts b/public/utils/config_to_schema_utils.ts index 3741fbad..3d3b3c98 100644 --- a/public/utils/config_to_schema_utils.ts +++ b/public/utils/config_to_schema_utils.ts @@ -21,6 +21,7 @@ import { MAX_JSON_STRING_LENGTH, MAX_TEMPLATE_STRING_LENGTH, TRANSFORM_TYPE, + MAX_BYTES, } from '../../common'; /* @@ -173,6 +174,18 @@ export function getFieldSchema( return false; } } + ) + .test( + 'jsonArray', + `Too large. Exceeds OpenSearch Dashboards limit of ${MAX_BYTES} bytes.`, + (value) => { + try { + // @ts-ignore + return new TextEncoder().encode(value)?.length < MAX_BYTES; + } catch (error) { + return false; + } + } ); break; }