Skip to content

Commit 78f57ca

Browse files
authored
Add popover to view model inputs; add validation feedback icons (#341)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent 0f565a6 commit 78f57ca

File tree

4 files changed

+174
-91
lines changed

4 files changed

+174
-91
lines changed

common/interfaces.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,15 @@ export type ModelConfig = {
377377
export type ModelInput = {
378378
type: string;
379379
description?: string;
380+
items?: ModelInput;
381+
properties?: ModelInputMap;
380382
};
381383

382384
export type ModelOutput = ModelInput;
383385

386+
export type ModelInputMap = { [key: string]: ModelInput };
387+
export type ModelOutputMap = { [key: string]: ModelOutput };
388+
384389
// For rendering options, we extract the name (the key in the input/output obj) and combine into a single obj
385390
export type ModelInputFormField = ModelInput & {
386391
label: string;
@@ -389,8 +394,8 @@ export type ModelInputFormField = ModelInput & {
389394
export type ModelOutputFormField = ModelInputFormField;
390395

391396
export type ModelInterface = {
392-
input: { [key: string]: ModelInput };
393-
output: { [key: string]: ModelOutput };
397+
input: ModelInput;
398+
output: ModelOutput;
394399
};
395400

396401
export type ConnectorParameters = {

public/pages/workflow_detail/workflow_inputs/processor_inputs/input_transform_modal.tsx

+125-48
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import React, { useState } from 'react';
6+
import React, { useState, useEffect } from 'react';
77
import { useFormikContext, getIn } from 'formik';
88
import { isEmpty } from 'lodash';
9+
import Ajv from 'ajv';
910
import {
1011
EuiCodeEditor,
1112
EuiFlexGroup,
@@ -20,6 +21,11 @@ import {
2021
EuiSmallButton,
2122
EuiSpacer,
2223
EuiText,
24+
EuiPopover,
25+
EuiSmallButtonEmpty,
26+
EuiCodeBlock,
27+
EuiPopoverTitle,
28+
EuiIconTip,
2329
} from '@elastic/eui';
2430
import {
2531
IConfigField,
@@ -48,7 +54,11 @@ import {
4854
useAppDispatch,
4955
} from '../../../../store';
5056
import { getCore } from '../../../../services';
51-
import { getDataSourceId, parseModelInputs } from '../../../../utils/utils';
57+
import {
58+
getDataSourceId,
59+
parseModelInputs,
60+
parseModelInputsObj,
61+
} from '../../../../utils/utils';
5262
import { MapArrayField } from '../input_fields';
5363

5464
interface InputTransformModalProps {
@@ -89,9 +99,54 @@ export function InputTransformModal(props: InputTransformModalProps) {
8999
number | undefined
90100
>((outputOptions[0]?.value as number) ?? undefined);
91101

92-
// TODO: integrated with Ajv to fetch any model interface and perform validation
93-
// on the produced output on-the-fly. For examples, see
102+
// popover state containing the model interface details, if applicable
103+
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
104+
105+
// validation state utilizing the model interface, if applicable. undefined if
106+
// there is no model interface and/or no source input
107+
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
108+
109+
// hook to re-generate the transform when any inputs to the transform are updated
110+
useEffect(() => {
111+
if (
112+
!isEmpty(map) &&
113+
!isEmpty(JSON.parse(sourceInput)) &&
114+
selectedOutputOption !== undefined
115+
) {
116+
let sampleSourceInput = {};
117+
try {
118+
// In the context of ingest or search resp, this input will be an array (list of docs)
119+
// In the context of request, it will be a single JSON
120+
sampleSourceInput =
121+
props.context === PROCESSOR_CONTEXT.INGEST ||
122+
props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE
123+
? JSON.parse(sourceInput)[0]
124+
: JSON.parse(sourceInput);
125+
const output = generateTransform(
126+
sampleSourceInput,
127+
map[selectedOutputOption]
128+
);
129+
setTransformedOutput(customStringify(output));
130+
} catch {}
131+
}
132+
}, [map, sourceInput, selectedOutputOption]);
133+
134+
// hook to re-determine validity when the generated output changes
135+
// utilize Ajv JSON schema validator library. For more info/examples, see
94136
// https://www.npmjs.com/package/ajv
137+
useEffect(() => {
138+
if (
139+
!isEmpty(JSON.parse(sourceInput)) &&
140+
!isEmpty(props.modelInterface?.input?.properties?.parameters)
141+
) {
142+
const validateFn = new Ajv().compile(
143+
props.modelInterface?.input?.properties?.parameters || {}
144+
);
145+
setIsValid(validateFn(JSON.parse(transformedOutput)));
146+
} else {
147+
setIsValid(undefined);
148+
}
149+
}, [transformedOutput]);
95150

96151
return (
97152
<EuiModal onClose={props.onClose} style={{ width: '70vw' }}>
@@ -105,7 +160,7 @@ export function InputTransformModal(props: InputTransformModalProps) {
105160
<EuiFlexItem>
106161
<>
107162
<EuiText color="subdued">
108-
Fetch some sample input data and how it is transformed.
163+
Fetch some sample input data and see how it is transformed.
109164
</EuiText>
110165
<EuiSpacer size="s" />
111166
<EuiText>Expected input</EuiText>
@@ -279,49 +334,71 @@ export function InputTransformModal(props: InputTransformModalProps) {
279334
</EuiFlexItem>
280335
<EuiFlexItem>
281336
<>
282-
{outputOptions.length === 1 ? (
283-
<EuiText>Expected output</EuiText>
284-
) : (
285-
<EuiCompressedSelect
286-
prepend={<EuiText>Expected output for</EuiText>}
287-
options={outputOptions}
288-
value={selectedOutputOption}
289-
onChange={(e) => {
290-
setSelectedOutputOption(Number(e.target.value));
291-
setTransformedOutput('{}');
292-
}}
293-
/>
294-
)}
295-
<EuiSpacer size="s" />
296-
<EuiSmallButton
297-
style={{ width: '100px' }}
298-
disabled={isEmpty(map) || isEmpty(JSON.parse(sourceInput))}
299-
onClick={async () => {
300-
if (
301-
!isEmpty(map) &&
302-
!isEmpty(JSON.parse(sourceInput)) &&
303-
selectedOutputOption !== undefined
304-
) {
305-
let sampleSourceInput = {};
306-
try {
307-
// In the context of ingest or search resp, this input will be an array (list of docs)
308-
// In the context of request, it will be a single JSON
309-
sampleSourceInput =
310-
props.context === PROCESSOR_CONTEXT.INGEST ||
311-
props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE
312-
? JSON.parse(sourceInput)[0]
313-
: JSON.parse(sourceInput);
314-
const output = generateTransform(
315-
sampleSourceInput,
316-
map[selectedOutputOption]
317-
);
318-
setTransformedOutput(customStringify(output));
319-
} catch {}
320-
}
321-
}}
322-
>
323-
Generate
324-
</EuiSmallButton>
337+
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
338+
{isValid !== undefined && (
339+
<EuiFlexItem
340+
grow={false}
341+
style={{
342+
marginTop: '16px',
343+
marginLeft: '8px',
344+
marginRight: '-8px',
345+
}}
346+
>
347+
<EuiIconTip
348+
type={isValid ? 'check' : 'cross'}
349+
color={isValid ? 'success' : 'danger'}
350+
size="m"
351+
content={
352+
isValid
353+
? 'Meets model interface requirements'
354+
: 'Does not meet model interface requirements'
355+
}
356+
/>
357+
</EuiFlexItem>
358+
)}
359+
<EuiFlexItem grow={true}>
360+
{outputOptions.length === 1 ? (
361+
<EuiText>Expected output</EuiText>
362+
) : (
363+
<EuiCompressedSelect
364+
prepend={<EuiText>Expected output for</EuiText>}
365+
options={outputOptions}
366+
value={selectedOutputOption}
367+
onChange={(e) => {
368+
setSelectedOutputOption(Number(e.target.value));
369+
}}
370+
/>
371+
)}
372+
</EuiFlexItem>
373+
{!isEmpty(parseModelInputsObj(props.modelInterface)) && (
374+
<EuiFlexItem grow={false}>
375+
<EuiPopover
376+
isOpen={popoverOpen}
377+
closePopover={() => setPopoverOpen(false)}
378+
button={
379+
<EuiSmallButtonEmpty
380+
onClick={() => setPopoverOpen(!popoverOpen)}
381+
>
382+
View model inputs
383+
</EuiSmallButtonEmpty>
384+
}
385+
>
386+
<EuiPopoverTitle>
387+
The JSON Schema defining the model's expected input
388+
</EuiPopoverTitle>
389+
<EuiCodeBlock
390+
language="json"
391+
fontSize="m"
392+
isCopyable={false}
393+
>
394+
{customStringify(
395+
parseModelInputsObj(props.modelInterface)
396+
)}
397+
</EuiCodeBlock>
398+
</EuiPopover>
399+
</EuiFlexItem>
400+
)}
401+
</EuiFlexGroup>
325402
<EuiSpacer size="s" />
326403
<EuiCodeEditor
327404
mode="json"

public/pages/workflow_detail/workflow_inputs/processor_inputs/output_transform_modal.tsx

+27-33
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import React, { useState } from 'react';
6+
import React, { useState, useEffect } from 'react';
77
import { useFormikContext, getIn } from 'formik';
88
import { cloneDeep, isEmpty, set } from 'lodash';
99
import {
@@ -86,6 +86,31 @@ export function OutputTransformModal(props: OutputTransformModalProps) {
8686
number | undefined
8787
>((outputOptions[0]?.value as number) ?? undefined);
8888

89+
// hook to re-generate the transform when any inputs to the transform are updated
90+
useEffect(() => {
91+
if (
92+
!isEmpty(map) &&
93+
!isEmpty(JSON.parse(sourceInput)) &&
94+
selectedOutputOption !== undefined
95+
) {
96+
let sampleSourceInput = {};
97+
try {
98+
// In the context of ingest or search resp, this input will be an array (list of docs)
99+
// In the context of request, it will be a single JSON
100+
sampleSourceInput =
101+
props.context === PROCESSOR_CONTEXT.INGEST ||
102+
props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE
103+
? JSON.parse(sourceInput)[0]
104+
: JSON.parse(sourceInput);
105+
const output = generateTransform(
106+
sampleSourceInput,
107+
map[selectedOutputOption]
108+
);
109+
setTransformedOutput(customStringify(output));
110+
} catch {}
111+
}
112+
}, [map, sourceInput, selectedOutputOption]);
113+
89114
return (
90115
<EuiModal onClose={props.onClose} style={{ width: '70vw' }}>
91116
<EuiModalHeader>
@@ -98,7 +123,7 @@ export function OutputTransformModal(props: OutputTransformModalProps) {
98123
<EuiFlexItem>
99124
<>
100125
<EuiText color="subdued">
101-
Fetch some sample output data and how it is transformed.
126+
Fetch some sample output data and see how it is transformed.
102127
</EuiText>
103128
<EuiSpacer size="s" />
104129
<EuiText>Expected input</EuiText>
@@ -267,41 +292,10 @@ export function OutputTransformModal(props: OutputTransformModalProps) {
267292
value={selectedOutputOption}
268293
onChange={(e) => {
269294
setSelectedOutputOption(Number(e.target.value));
270-
setTransformedOutput('{}');
271295
}}
272296
/>
273297
)}
274298
<EuiSpacer size="s" />
275-
<EuiSmallButton
276-
style={{ width: '100px' }}
277-
disabled={isEmpty(map) || isEmpty(JSON.parse(sourceInput))}
278-
onClick={async () => {
279-
if (
280-
!isEmpty(map) &&
281-
!isEmpty(JSON.parse(sourceInput)) &&
282-
selectedOutputOption !== undefined
283-
) {
284-
let sampleSourceInput = {};
285-
try {
286-
// In the context of ingest or search resp, this input will be an array (list of docs)
287-
// In the context of request, it will be a single JSON
288-
sampleSourceInput =
289-
props.context === PROCESSOR_CONTEXT.INGEST ||
290-
props.context === PROCESSOR_CONTEXT.SEARCH_RESPONSE
291-
? JSON.parse(sourceInput)[0]
292-
: JSON.parse(sourceInput);
293-
const output = generateTransform(
294-
sampleSourceInput,
295-
map[selectedOutputOption]
296-
);
297-
setTransformedOutput(customStringify(output));
298-
} catch {}
299-
}
300-
}}
301-
>
302-
Generate
303-
</EuiSmallButton>
304-
<EuiSpacer size="s" />
305299
<EuiCodeEditor
306300
mode="json"
307301
theme="textmate"

public/utils/utils.ts

+15-8
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
customStringify,
2323
} from '../../common';
2424
import { getCore, getDataSourceEnabled } from '../services';
25-
import { MDSQueryParams } from '../../common/interfaces';
25+
import { MDSQueryParams, ModelInputMap } from '../../common/interfaces';
2626
import queryString from 'query-string';
2727
import { useLocation } from 'react-router-dom';
2828
import * as pluginManifest from '../../opensearch_dashboards.json';
@@ -205,13 +205,7 @@ export function generateTransform(input: {}, map: MapFormValue): {} {
205205
export function parseModelInputs(
206206
modelInterface: ModelInterface | undefined
207207
): ModelInputFormField[] {
208-
const modelInputsObj = get(
209-
modelInterface,
210-
// model interface input values will always be nested under a base "parameters" obj.
211-
// we iterate through the obj properties to extract the individual inputs
212-
'input.properties.parameters.properties',
213-
{}
214-
) as { [key: string]: ModelInput };
208+
const modelInputsObj = parseModelInputsObj(modelInterface);
215209
return Object.keys(modelInputsObj).map(
216210
(inputName: string) =>
217211
({
@@ -221,6 +215,19 @@ export function parseModelInputs(
221215
);
222216
}
223217

218+
// Derive the collection of model inputs as an obj
219+
export function parseModelInputsObj(
220+
modelInterface: ModelInterface | undefined
221+
): ModelInputMap {
222+
return get(
223+
modelInterface,
224+
// model interface input values will always be nested under a base "parameters" obj.
225+
// we iterate through the obj properties to extract the individual inputs
226+
'input.properties.parameters.properties',
227+
{}
228+
) as ModelInputMap;
229+
}
230+
224231
// Derive the collection of model outputs from the model interface JSONSchema into a form-ready list
225232
export function parseModelOutputs(
226233
modelInterface: ModelInterface | undefined

0 commit comments

Comments
 (0)