Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.x] Edit viz in a dialog #356

Merged
merged 1 commit into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions public/components/visualization/text2vega.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { DataSourceAttributes } from '../../../../../src/plugins/data_source/com
const topN = (ppl: string, n: number) => `${ppl} | head ${n}`;

interface Input {
prompt: string;
inputQuestion: string;
inputInstruction?: string;
index: string;
dataSourceId?: string;
}

export class Text2Vega {
input$ = new BehaviorSubject<Input>({ prompt: '', index: '' });
input$ = new BehaviorSubject<Input>({ inputQuestion: '', index: '' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result$: Observable<Record<string, any> | { error: any }>;
status$ = new BehaviorSubject<'RUNNING' | 'STOPPED'>('STOPPED');
Expand All @@ -37,7 +38,7 @@ export class Text2Vega {
this.savedObjects = savedObjects;
this.result$ = this.input$
.pipe(
filter((v) => v.prompt.length > 0),
filter((v) => v.inputQuestion.length > 0),
tap(() => this.status$.next('RUNNING')),
debounceTime(200)
)
Expand All @@ -46,7 +47,7 @@ export class Text2Vega {
of(v).pipe(
// text to ppl
switchMap(async (value) => {
const pplQuestion = value.prompt.split('//')[0];
const pplQuestion = value.inputQuestion;
const ppl = await this.text2ppl(pplQuestion, value.index, value.dataSourceId);
return {
...value,
Expand All @@ -71,7 +72,8 @@ export class Text2Vega {
// call llm to generate vega
switchMap(async (value) => {
const result = await this.text2vega({
input: value.prompt,
inputQuestion: value.inputQuestion,
inputInstruction: value.inputInstruction,
ppl: value.ppl,
sampleData: JSON.stringify(value.sample.jsonData),
dataSchema: JSON.stringify(value.sample.schema),
Expand All @@ -96,13 +98,15 @@ export class Text2Vega {
}

async text2vega({
input,
inputQuestion,
inputInstruction = '',
ppl,
sampleData,
dataSchema,
dataSourceId,
}: {
input: string;
inputQuestion: string;
inputInstruction?: string;
ppl: string;
sampleData: string;
dataSchema: string;
Expand All @@ -122,7 +126,6 @@ export class Text2Vega {
}
}
};
const [inputQuestion, inputInstruction = ''] = input.split('//');
const res = await this.http.post(TEXT2VIZ_API.TEXT2VEGA, {
body: JSON.stringify({
input_question: inputQuestion.trim(),
Expand Down
20 changes: 18 additions & 2 deletions public/components/visualization/text2viz.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,26 @@
padding-left: 30px;
}

.feedback_thumbs {
.text2viz__actionContainer {
position: absolute;
right: 16px;
top: 4px;
right: 16px;
z-index: 9999;

// No existing button from OUI with the same style, have to customize here
.vizStyleEditor__editButton {
height: 22px;
padding: 2px;
font-size: $ouiFontSizeXS;
}

.text2viz__feedbackContainer {
padding-right: $euiSizeS;
border-right: 1px solid $euiColorLightShade
}

.text2viz__vizStyleEditorContainer {
padding-left: $euiSizeS;
}
}
}
125 changes: 78 additions & 47 deletions public/components/visualization/text2viz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { VIS_NLQ_APP_ID, VIS_NLQ_SAVED_OBJECT } from '../../../common/constants/
import { HeaderVariant } from '../../../../../src/core/public';
import { TEXT2VEGA_INPUT_SIZE_LIMIT } from '../../../common/constants/llm';
import { FeedbackThumbs } from '../feedback_thumbs';
import { VizStyleEditor } from './viz_style_editor';

export const INDEX_PATTERN_URL_SEARCH_KEY = 'indexPatternId';
export const ASSISTANT_INPUT_URL_SEARCH_KEY = 'assistantInput';
Expand Down Expand Up @@ -96,7 +97,10 @@ export const Text2Viz = () => {

const useUpdatedUX = uiSettings.get('home:useNewHomePage');

const [input, setInput] = useState(searchParams.get(ASSISTANT_INPUT_URL_SEARCH_KEY) ?? '');
const [inputQuestion, setInputQuestion] = useState(
searchParams.get(ASSISTANT_INPUT_URL_SEARCH_KEY) ?? ''
);
const [currentInstruction, setCurrentInstruction] = useState('');
const [editorInput, setEditorInput] = useState('');
const text2vegaRef = useRef(new Text2Vega(http, data.search, savedObjects));

Expand Down Expand Up @@ -174,7 +178,8 @@ export const Text2Viz = () => {
}
}
if (savedVis?.uiState) {
setInput(JSON.parse(savedVis.uiState ?? '{}').input);
setInputQuestion(JSON.parse(savedVis.uiState ?? '{}').input ?? '');
setCurrentInstruction(JSON.parse(savedVis.uiState ?? '{}').instruction ?? '');
}
})
.catch(() => {
Expand All @@ -196,42 +201,48 @@ export const Text2Viz = () => {
/**
* Submit user's natural language input to generate visualization
*/
const onSubmit = useCallback(async () => {
if (status === 'RUNNING' || !selectedSource) return;

const [inputQuestion = '', inputInstruction = ''] = input.split('//');
if (
inputQuestion.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT ||
inputInstruction.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT
) {
notifications.toasts.addDanger({
title: i18n.translate('dashboardAssistant.feature.text2viz.invalidInput', {
defaultMessage: `Input size exceed limit: {limit}. Actual size: question({inputQuestionLength}), instruction({inputInstructionLength})`,
values: {
limit: TEXT2VEGA_INPUT_SIZE_LIMIT,
inputQuestionLength: inputQuestion.trim().length,
inputInstructionLength: inputInstruction.trim().length,
},
}),
});
return;
}
const onSubmit = useCallback(
async (inputInstruction: string = '') => {
setCurrentInstruction(inputInstruction);

if (status === 'RUNNING' || !selectedSource) return;

if (
inputQuestion.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT ||
inputInstruction.trim().length > TEXT2VEGA_INPUT_SIZE_LIMIT
) {
notifications.toasts.addDanger({
title: i18n.translate('dashboardAssistant.feature.text2viz.invalidInput', {
defaultMessage:
'Input size exceed limit: {limit}. Actual size: question({inputQuestionLength}), instruction({inputInstructionLength})',
values: {
limit: TEXT2VEGA_INPUT_SIZE_LIMIT,
inputQuestionLength: inputQuestion.trim().length,
inputInstructionLength: inputInstruction.trim().length,
},
}),
});
return;
}

setSubmitting(true);
setSubmitting(true);

const indexPatterns = getIndexPatterns();
const indexPattern = await indexPatterns.get(selectedSource);
currentUsedIndexPatternRef.current = indexPattern;
const indexPatterns = getIndexPatterns();
const indexPattern = await indexPatterns.get(selectedSource);
currentUsedIndexPatternRef.current = indexPattern;

const text2vega = text2vegaRef.current;
text2vega.invoke({
index: indexPattern.title,
prompt: input,
dataSourceId: indexPattern.dataSourceRef?.id,
});
const text2vega = text2vegaRef.current;
text2vega.invoke({
index: indexPattern.title,
inputQuestion,
inputInstruction,
dataSourceId: indexPattern.dataSourceRef?.id,
});

setSubmitting(false);
}, [selectedSource, input, status, notifications.toasts]);
setSubmitting(false);
},
[selectedSource, inputQuestion, status, notifications.toasts]
);

/**
* Display the save visualization dialog to persist the current generated visualization
Expand All @@ -252,7 +263,8 @@ export const Text2Viz = () => {
},
});
savedVis.uiState = JSON.stringify({
input,
input: inputQuestion,
instruction: currentInstruction,
});
savedVis.searchSourceFields = { index: indexPattern };
savedVis.title = onSaveProps.newTitle;
Expand Down Expand Up @@ -311,7 +323,16 @@ export const Text2Viz = () => {
/>
)
);
}, [notifications, vegaSpec, input, overlays, selectedSource, savedObjectId, usageCollection]);
}, [
notifications,
vegaSpec,
inputQuestion,
overlays,
selectedSource,
savedObjectId,
usageCollection,
currentInstruction,
]);

const pageTitle = savedObjectId
? i18n.translate('dashboardAssistant.feature.text2viz.breadcrumbs.editVisualization', {
Expand Down Expand Up @@ -380,8 +401,8 @@ export const Text2Viz = () => {
</EuiFlexItem>
<EuiFlexItem grow={8}>
<EuiFieldText
value={input}
onChange={(e) => setInput(e.target.value)}
value={inputQuestion}
onChange={(e) => setInputQuestion(e.target.value)}
fullWidth
compressed
prepend={<EuiIcon type={config.branding.logo || chatIcon} />}
Expand All @@ -393,8 +414,8 @@ export const Text2Viz = () => {
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label="submit"
onClick={onSubmit}
isDisabled={loading || input.trim().length === 0 || !selectedSource}
onClick={() => onSubmit()}
isDisabled={loading || inputQuestion.trim().length === 0 || !selectedSource}
display="base"
size="s"
iconType="returnKey"
Expand Down Expand Up @@ -453,13 +474,23 @@ export const Text2Viz = () => {
paddingSize="none"
scrollable={false}
>
{usageCollection ? (
<FeedbackThumbs
usageCollection={usageCollection}
appName={VIS_NLQ_APP_ID}
className="feedback_thumbs"
/>
) : null}
<EuiFlexGroup className="text2viz__actionContainer" gutterSize="none">
{usageCollection ? (
<EuiFlexItem className="text2viz__feedbackContainer">
<FeedbackThumbs
usageCollection={usageCollection}
appName={VIS_NLQ_APP_ID}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem className="text2viz__vizStyleEditorContainer">
<VizStyleEditor
iconType={config.branding.logo || chatIcon}
onApply={(instruction) => onSubmit(instruction)}
value={currentInstruction}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EmbeddableRenderer factory={factory} input={visInput} />
</EuiResizablePanel>
<EuiResizableButton />
Expand Down
44 changes: 44 additions & 0 deletions public/components/visualization/viz_style_editor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { VizStyleEditor } from './viz_style_editor';

describe('<VizStyleEditor />', () => {
test('should render visual style editor', () => {
const onApplyFn = jest.fn();
render(<VizStyleEditor onApply={onApplyFn} iconType="icon" />);
expect(screen.queryByText('Edit visual')).toBeInTheDocument();

// click Edit visual button to open the modal
expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null);
fireEvent.click(screen.getByText('Edit visual'));
expect(screen.queryByTestId('text2vizStyleEditorModal')).toBeInTheDocument();

// Click cancel to close the modal
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null);

// Apply button is disabled
fireEvent.click(screen.getByText('Edit visual'));
expect(screen.getByTestId('text2vizStyleEditorModalApply')).toBeDisabled();

// After input text, Apply button is enabled
fireEvent.input(screen.getByLabelText('Input instructions to tweak the visual'), {
target: { value: 'test input' },
});
expect(screen.getByTestId('text2vizStyleEditorModalApply')).not.toBeDisabled();
fireEvent.click(screen.getByText('Apply'));
expect(onApplyFn).toHaveBeenCalledWith('test input');
expect(screen.queryByTestId('text2vizStyleEditorModal')).toBe(null);
});

test('should open the modal with initial value', () => {
render(<VizStyleEditor onApply={jest.fn()} iconType="icon" value="test input" />);
fireEvent.click(screen.getByText('Edit visual'));
expect(screen.getByDisplayValue('test input')).toBeInTheDocument();
});
});
Loading
Loading