diff --git a/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap new file mode 100644 index 000000000000..bac6be2733fd --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/import_flyout.test.tsx.snap @@ -0,0 +1,489 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportFlyout Component renders correctly 1`] = ` + + + +
+ + +
+ + +
+ + + + + +
+
+ + + + + +
+
+
+`; diff --git a/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap new file mode 100644 index 000000000000..343c6b351229 --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/import_mode_control.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ImportModeControl Component should render correclty 1`] = ` + + + Import options + + , + } + } +> + + +`; diff --git a/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap b/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap new file mode 100644 index 000000000000..27228e29674e --- /dev/null +++ b/src/plugins/console/public/application/components/__snapshots__/overwrite_modal.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OverwriteModal Component should render correclty 1`] = ` + +

+ Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "Merge with existing queries" option instead +

+
+`; diff --git a/src/plugins/console/public/application/components/import_flyout.test.tsx b/src/plugins/console/public/application/components/import_flyout.test.tsx new file mode 100644 index 000000000000..5e7093fe306c --- /dev/null +++ b/src/plugins/console/public/application/components/import_flyout.test.tsx @@ -0,0 +1,268 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ImportFlyout } from './import_flyout'; +import { ContextValue, ServicesContextProvider } from '../contexts'; +import { serviceContextMock } from '../contexts/services_context.mock'; +import { wrapWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { ReactWrapper, mount } from 'enzyme'; + +const mockFile = new File(['{"text":"Sample JSON data"}'], 'sample.json', { + type: 'application/json', +}); +const mockFile1 = new File(['{"text":"Sample JSON data1"}'], 'sample.json', { + type: 'application/json', +}); + +const mockInvalidFile = new File(['Some random data'], 'sample.json', { + type: 'application/json', +}); + +const filePickerIdentifier = '[data-test-subj="queryFilePicker"]'; +const confirmBtnIdentifier = '[data-test-subj="importQueriesConfirmBtn"]'; +const cancelBtnIdentifier = '[data-test-subj="importQueriesCancelBtn"]'; +const confirmModalConfirmButton = '[data-test-subj="confirmModalConfirmButton"]'; +const confirmModalCancelButton = '[data-test-subj="confirmModalCancelButton"]'; +const importErrorTextIdentifier = '[data-test-subj="importSenseObjectsErrorText"]'; +const mergeOptionIdentifier = '[id="overwriteDisabled"]'; +const overwriteOptionIdentifier = '[id="overwriteEnabled"]'; +const callOutIdentifier = 'EuiCallOut'; + +const invalidFileError = 'The selected file is not valid. Please select a valid JSON file.'; + +const defaultQuery = { + id: '43461752-1fd5-472e-8487-b47ff7fccbc8', + createdAt: 1690958998493, + updatedAt: 1690958998493, + text: 'GET _search\n{\n "query": {\n "match_all": {}\n }\n}', +}; + +describe('ImportFlyout Component', () => { + let mockedAppContextValue: ContextValue; + const mockClose = jest.fn(); + const mockRefresh = jest.fn(); + const mockFindAll = jest.fn(); + const mockCreate = jest.fn(); + const mockUpdate = jest.fn(); + + let component: ReactWrapper, React.Component<{}, {}, any>>; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockedAppContextValue = serviceContextMock.create(); + + mockedAppContextValue.services.objectStorageClient.text = { + create: mockCreate, + update: mockUpdate, + findAll: mockFindAll, + }; + + mockFindAll.mockResolvedValue([defaultQuery]); + + await act(async () => { + component = mount(wrapWithIntl(), { + wrappingComponent: ServicesContextProvider, + wrappingComponentProps: { + value: mockedAppContextValue, + }, + }); + }); + await nextTick(); + component.update(); + }); + + it('renders correctly', () => { + expect(component).toMatchSnapshot(); + }); + + it('should enable confirm button when select file', async () => { + // Confirm button should be disable if no file selected + expect(component.find(confirmBtnIdentifier).first().props().disabled).toBe(true); + component.update(); + + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile1]); + await new Promise((r) => setTimeout(r, 1)); + }); + await nextTick(); + component.update(); + + // Confirm button should be enable after importing file + expect(component.find(confirmBtnIdentifier).first().props().disabled).toBe(false); + }); + + it('should handle import process with default import mode', async () => { + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + // Applied a timeout after FileReader.onload event to resolve side effect issue. + await new Promise((r) => setTimeout(r, 10)); + }); + + await nextTick(); + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + // default option "Merge with existing queries" should be checked by default + expect(component.find(mergeOptionIdentifier).first().props().checked).toBe(true); + expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(false); + + // should update existing query + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); + + it('should handle errors during import', async () => { + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockInvalidFile]); + }); + + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + expect(component.find(callOutIdentifier).exists()).toBe(true); + + expect(component.find(importErrorTextIdentifier).text()).toEqual(invalidFileError); + }); + + it('should handle internal errors', async () => { + const errorMessage = 'some internal error'; + mockFindAll.mockRejectedValue(new Error(errorMessage)); + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + await new Promise((r) => setTimeout(r, 1)); + }); + + component.update(); + + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + + expect(component.find(callOutIdentifier).exists()).toBe(true); + + expect(component.find(importErrorTextIdentifier).text()).toEqual( + `The file could not be processed due to error: "${errorMessage}"` + ); + }); + + it('should cancel button work normally', async () => { + act(() => { + component.find(cancelBtnIdentifier).first().simulate('click'); + }); + + expect(mockClose).toBeCalledTimes(1); + }); + + describe('OverwriteModal', () => { + beforeEach(async () => { + jest.clearAllMocks(); + // Select a file + await act(async () => { + const nodes = component.find(filePickerIdentifier); + // @ts-ignore + nodes.first().props().onChange([mockFile]); + await new Promise((r) => setTimeout(r, 1)); + }); + component.update(); + + // change import mode to overwrite + await act(async () => { + await nextTick(); + component.find(overwriteOptionIdentifier).last().simulate('change'); + }); + + component.update(); + + // import selected file + await act(async () => { + await nextTick(); + component.find(confirmBtnIdentifier).first().simulate('click'); + }); + + component.update(); + }); + it('should handle overwrite confirmation', async () => { + // Check confirm overwrite modal exist before confirmation + expect(component.find('OverwriteModal').exists()).toBe(true); + + // confirm overwrite + await act(async () => { + await nextTick(); + component.find(confirmModalConfirmButton).first().simulate('click'); + }); + + component.update(); + + // should update existing query + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + + // confirm overwrite modal should close after confirmation. + expect(component.find('OverwriteModal').exists()).toBe(false); + }); + + it('should handle overwrite skip', async () => { + // Check confirm overwrite modal exist before skip + expect(component.find('OverwriteModal').exists()).toBe(true); + + // confirm overwrite + act(() => { + component.find(confirmModalCancelButton).first().simulate('click'); + }); + await nextTick(); + component.update(); + + // confirm overwrite modal should close after cancel. + expect(component.find('OverwriteModal').exists()).toBe(false); + }); + + it('should create storage text when storage client returns empty result with overwrite import mode', async () => { + mockFindAll.mockResolvedValue([]); + + // confirm overwrite + await act(async () => { + await nextTick(); + component.find(confirmModalConfirmButton).first().simulate('click'); + }); + + component.update(); + + expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(true); + + // should create new query + expect(mockCreate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/plugins/console/public/application/components/import_flyout.tsx b/src/plugins/console/public/application/components/import_flyout.tsx new file mode 100644 index 000000000000..d8c791411ade --- /dev/null +++ b/src/plugins/console/public/application/components/import_flyout.tsx @@ -0,0 +1,298 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiCallOut, + EuiSpacer, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLoadingSpinner, + EuiText, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import React, { Fragment, useState } from 'react'; +import { ImportMode, ImportModeControl } from './import_mode_control'; +import { useServicesContext } from '../contexts'; +import { TextObject } from '../../../common/text_object'; +import { OverwriteModal } from './overwrite_modal'; + +const OVERWRITE_ALL_DEFAULT = false; + +export interface ImportFlyoutProps { + close: () => void; + refresh: () => void; +} + +const getErrorMessage = (e: any) => { + const errorMessage = + e.body?.error && e.body?.message ? `${e.body.error}: ${e.body.message}` : e.message; + return i18n.translate('console.ImportFlyout.importFileErrorMessage', { + defaultMessage: 'The file could not be processed due to error: "{error}"', + values: { + error: errorMessage, + }, + }); +}; + +export const ImportFlyout = ({ close, refresh }: ImportFlyoutProps) => { + const [error, setError] = useState(); + const [status, setStatus] = useState('idle'); + const [loadingMessage, setLoadingMessage] = useState(); + const [file, setFile] = useState(); + const [jsonData, setJsonData] = useState(); + const [showOverwriteModal, setShowOverwriteModal] = useState(false); + const [importMode, setImportMode] = useState({ + overwrite: OVERWRITE_ALL_DEFAULT, + }); + + const { + services: { + objectStorageClient, + uiSettings, + notifications: { toasts }, + }, + } = useServicesContext(); + + const dateFormat = uiSettings.get('dateFormat'); + + const setImportFile = (files: FileList | null) => { + const isFirstFileMissing = !files?.[0]; + if (isFirstFileMissing) { + setFile(undefined); + return; + } + const fileContent = files[0]; + const reader = new FileReader(); + + reader.onload = (event) => { + const fileData = event.target?.result; + if (typeof fileData === 'string') { + const parsedData = JSON.parse(fileData); + setJsonData(parsedData); + } + }; + + reader.readAsText(fileContent); + setFile(fileContent); + setStatus('idle'); + }; + + const renderError = () => { + if (status !== 'error') { + return null; + } + + return ( + + + } + color="danger" + > +

{error}

+
+ +
+ ); + }; + + const renderBody = () => { + if (status === 'loading') { + return ( + + + + + +

{loadingMessage}

+
+
+
+ ); + } + + return ( + + + } + > + + } + onChange={setImportFile} + data-test-subj="queryFilePicker" + /> + + + setImportMode(newValues)} + /> + + + ); + }; + + const importFile = async (isOverwriteConfirmed?: boolean) => { + setStatus('loading'); + setError(undefined); + try { + const results = await objectStorageClient.text.findAll(); + const currentText = results.sort((a, b) => a.createdAt - b.createdAt)[0]; + + if (jsonData?.text) { + if (importMode.overwrite) { + if (!isOverwriteConfirmed) { + setShowOverwriteModal(true); + return; + } else { + setLoadingMessage('Importing queries and overwriting existing ones...'); + const newObject = { + createdAt: Date.now(), + updatedAt: Date.now(), + text: jsonData.text, + }; + if (results.length) { + await objectStorageClient.text.update({ + ...currentText, + ...newObject, + }); + } else { + await objectStorageClient.text.create({ + ...newObject, + }); + } + } + toasts.addSuccess('Queries overwritten.'); + } else { + setLoadingMessage('Importing queries and merging with existing ones...'); + if (results.length) { + await objectStorageClient.text.update({ + ...currentText, + createdAt: Date.now(), + updatedAt: Date.now(), + text: currentText.text.concat( + `\n\n#Imported on ${moment(Date.now()).format(dateFormat)}\n\n${jsonData.text}` + ), + }); + toasts.addSuccess('Queries merged.'); + } + } + refresh(); + setLoadingMessage(undefined); + setStatus('idle'); + close(); + } else { + setStatus('error'); + setError( + i18n.translate('console.ImportFlyout.importFileErrorMessage', { + defaultMessage: 'The selected file is not valid. Please select a valid JSON file.', + }) + ); + return; + } + } catch (e) { + setStatus('error'); + setError(getErrorMessage(e)); + } + }; + + const onConfirm = () => { + setShowOverwriteModal(false); + importFile(true); + }; + + const onSkip = () => { + setShowOverwriteModal(false); + setStatus('idle'); + }; + + const renderFooter = () => { + return ( + + + + + + + + importFile(false)} + size="s" + fill + isLoading={status === 'loading'} + data-test-subj="importQueriesConfirmBtn" + > + + + + + ); + }; + + return ( + + + +

+ +

+
+
+ + + {renderError()} + {renderBody()} + + + {renderFooter()} + {showOverwriteModal && } +
+ ); +}; diff --git a/src/plugins/console/public/application/components/import_mode_control.test.tsx b/src/plugins/console/public/application/components/import_mode_control.test.tsx new file mode 100644 index 000000000000..219cc2f1c5fc --- /dev/null +++ b/src/plugins/console/public/application/components/import_mode_control.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ReactElement } from 'react'; +import { act } from '@testing-library/react-hooks'; +import { ShallowWrapper, shallow } from 'enzyme'; +import { nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { ImportModeControl } from './import_mode_control'; +import { EuiFormLegendProps, EuiRadioGroupProps } from '@elastic/eui'; + +const radioGroupIdentifier = 'EuiRadioGroup'; + +describe('ImportModeControl Component', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + const mockUpdateSelection = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + act(() => { + component = shallowWithIntl( + + ); + }); + await nextTick(); + component.update(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correclty', () => { + expect(component).toMatchSnapshot(); + }); + + it('should render the correct title in the fieldset legend', () => { + const legendText = 'Import options'; + const legend: EuiFormLegendProps = component.find('EuiFormFieldset').prop('legend'); + const legendTitle = shallow(legend?.children as ReactElement); + + expect(legendTitle.text()).toBe(legendText); + }); + + it('should display the correct labels for radio options', () => { + const componentProps = (component + .find(radioGroupIdentifier) + .props() as unknown) as EuiRadioGroupProps; + + // Check if the labels for radio options are displayed correctly + const radioOptions = componentProps.options; + expect(radioOptions[0].label).toBe('Merge with existing queries'); + expect(radioOptions[1].label).toBe('Overwrite existing queries'); + + // Check the initial selection (overwrite is false, so Merge with existing queries should be selected) + const selectedOptionId = component.find(radioGroupIdentifier).prop('idSelected'); + expect(selectedOptionId).toBe('overwriteDisabled'); + }); + + it('should call updateSelection when the selection is changed', async () => { + act(() => { + // @ts-ignore + component.find(radioGroupIdentifier).first().props().onChange('overwriteEnabled'); + }); + component.update(); + + // Expect that the updateSelection function has been called with the correct parameters + expect(mockUpdateSelection).toHaveBeenCalledWith({ overwrite: true }); + }); +}); diff --git a/src/plugins/console/public/application/components/import_mode_control.tsx b/src/plugins/console/public/application/components/import_mode_control.tsx new file mode 100644 index 000000000000..0b543a9c593c --- /dev/null +++ b/src/plugins/console/public/application/components/import_mode_control.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { EuiFormFieldset, EuiTitle, EuiRadioGroup } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface ImportModeControlProps { + initialValues: ImportMode; + updateSelection: (result: ImportMode) => void; +} + +export interface ImportMode { + overwrite: boolean; +} + +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate('console.importModeControl.overwrite.enabledLabel', { + defaultMessage: 'Overwrite existing queries', + }), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate('console.importModeControl.overwrite.disabledLabel', { + defaultMessage: 'Merge with existing queries', + }), +}; +const importOptionsTitle = i18n.translate('console.importModeControl.importOptionsTitle', { + defaultMessage: 'Import options', +}); + +export const ImportModeControl = ({ initialValues, updateSelection }: ImportModeControlProps) => { + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ overwrite, ...partial }); + }; + + const overwriteRadio = ( + onChange({ overwrite: id === overwriteEnabled.id })} + /> + ); + + return ( + + {importOptionsTitle} + + ), + }} + > + {overwriteRadio} + + ); +}; diff --git a/src/plugins/console/public/application/components/index.ts b/src/plugins/console/public/application/components/index.ts index d6cceb7c7ca8..dca2bb72d211 100644 --- a/src/plugins/console/public/application/components/index.ts +++ b/src/plugins/console/public/application/components/index.ts @@ -36,3 +36,4 @@ export { WelcomePanel } from './welcome_panel'; export { AutocompleteOptions, DevToolsSettingsModal } from './settings_modal'; export { HelpPanel } from './help_panel'; export { EditorContentSpinner } from './editor_content_spinner'; +export { ImportFlyout } from './import_flyout'; diff --git a/src/plugins/console/public/application/components/overwrite_modal.test.tsx b/src/plugins/console/public/application/components/overwrite_modal.test.tsx new file mode 100644 index 000000000000..46ce18d6534a --- /dev/null +++ b/src/plugins/console/public/application/components/overwrite_modal.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { OverwriteModal } from './overwrite_modal'; +import { act } from '@testing-library/react-hooks'; +import { ShallowWrapper } from 'enzyme'; + +const confirmModalIdentifier = 'EuiConfirmModal'; + +describe('OverwriteModal Component', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + const mockOnConfirm = jest.fn(); + const mockOnSkip = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + act(() => { + component = shallowWithIntl(); + }); + + component.update(); + }); + + it('should render correclty', () => { + expect(component).toMatchSnapshot(); + }); + + it('should call onConfirm when clicking the "Overwrite" button', () => { + act(() => { + // @ts-ignore + component.find(confirmModalIdentifier).first().props().onConfirm(); + }); + + component.update(); + + // Expect that the onConfirm function has been called + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + it('should call onSkip when clicking the "Skip" button', () => { + act(() => { + // @ts-ignore + component.find(confirmModalIdentifier).first().props().onCancel(); + }); + + component.update(); + + // Expect that the onSkip function has been called + expect(mockOnSkip).toHaveBeenCalled(); + }); + + it('should display the correct title and body text', () => { + // Find the title and body text elements + const componentProps = component.find(confirmModalIdentifier).first().props(); + // Find the

element inside the component + const paragraphElement = component.find('p'); + + // Expect the correct translations for title and body text + expect(componentProps.title).toBe('Confirm Overwrite'); + + // Check the text content of the

element + const expectedText = + 'Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "Merge with existing queries" option instead'; + expect(paragraphElement.text()).toEqual(expectedText); + }); +}); diff --git a/src/plugins/console/public/application/components/overwrite_modal.tsx b/src/plugins/console/public/application/components/overwrite_modal.tsx new file mode 100644 index 000000000000..5432a0fc86c2 --- /dev/null +++ b/src/plugins/console/public/application/components/overwrite_modal.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; + +export interface OverwriteModalProps { + onSkip: () => void; + onConfirm: () => void; +} +export const OverwriteModal = ({ onSkip, onConfirm }: OverwriteModalProps) => { + return ( + +

+ {i18n.translate('console.overwriteModal.body.conflict', { + defaultMessage: + 'Are you sure you want to overwrite the existing queries? This action cannot be undone. All existing queries will be deleted and replaced with the imported queries. If you are unsure, please choose the "{option}" option instead', + values: { option: 'Merge with existing queries' }, + })} +

+ + ); +}; diff --git a/src/plugins/console/public/application/containers/main/get_top_nav.ts b/src/plugins/console/public/application/containers/main/get_top_nav.ts index e7eba5c580ac..cd21321993bb 100644 --- a/src/plugins/console/public/application/containers/main/get_top_nav.ts +++ b/src/plugins/console/public/application/containers/main/get_top_nav.ts @@ -34,9 +34,17 @@ interface Props { onClickHistory: () => void; onClickSettings: () => void; onClickHelp: () => void; + onClickExport: () => void; + onClickImport: () => void; } -export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp }: Props) { +export function getTopNavConfig({ + onClickHistory, + onClickSettings, + onClickHelp, + onClickExport, + onClickImport, +}: Props) { return [ { id: 'history', @@ -77,5 +85,31 @@ export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp } }, testId: 'consoleHelpButton', }, + { + id: 'export', + label: i18n.translate('console.topNav.exportTabLabel', { + defaultMessage: 'Export', + }), + description: i18n.translate('console.topNav.exportTabDescription', { + defaultMessage: 'Export', + }), + onClick: () => { + onClickExport(); + }, + testId: 'consoleExportButton', + }, + { + id: 'import', + label: i18n.translate('console.topNav.importTabLabel', { + defaultMessage: 'Import', + }), + description: i18n.translate('console.topNav.importTabDescription', { + defaultMessage: 'Import', + }), + onClick: () => { + onClickImport(); + }, + testId: 'consoleImportButton', + }, ]; } diff --git a/src/plugins/console/public/application/containers/main/main.tsx b/src/plugins/console/public/application/containers/main/main.tsx index 1967c14615bb..bbe5bd9856eb 100644 --- a/src/plugins/console/public/application/containers/main/main.tsx +++ b/src/plugins/console/public/application/containers/main/main.tsx @@ -28,8 +28,10 @@ * under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@osd/i18n'; +// @ts-expect-error +import { saveAs } from '@elastic/filesaver'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPageContent } from '@elastic/eui'; import { ConsoleHistory } from '../console_history'; import { Editor } from '../editor'; @@ -41,6 +43,7 @@ import { HelpPanel, SomethingWentWrongCallout, NetworkRequestStatusBar, + ImportFlyout, } from '../../components'; import { useServicesContext, useEditorReadContext, useRequestReadContext } from '../../contexts'; @@ -54,7 +57,7 @@ interface MainProps { export function Main({ dataSourceId }: MainProps) { const { - services: { storage }, + services: { storage, objectStorageClient }, } = useServicesContext(); const { ready: editorsReady } = useEditorReadContext(); @@ -71,6 +74,14 @@ export function Main({ dataSourceId }: MainProps) { const [showingHistory, setShowHistory] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [showImportFlyout, setShowImportFlyout] = useState(false); + + const onExport = async () => { + const results = await objectStorageClient.text.findAll(); + const senseData = results.sort((a, b) => a.createdAt - b.createdAt)[0]; + const blob = new Blob([JSON.stringify(senseData || {})], { type: 'application/json' }); + saveAs(blob, 'sense.json'); + }; const renderConsoleHistory = () => { return editorsReady ? setShowHistory(false)} /> : null; @@ -111,6 +122,8 @@ export function Main({ dataSourceId }: MainProps) { onClickHistory: () => setShowHistory(!showingHistory), onClickSettings: () => setShowSettings(true), onClickHelp: () => setShowHelp(!showHelp), + onClickExport: () => onExport(), + onClickImport: () => setShowImportFlyout(!showImportFlyout), })} /> @@ -152,6 +165,10 @@ export function Main({ dataSourceId }: MainProps) { ) : null} {showHelp ? setShowHelp(false)} /> : null} + + {showImportFlyout ? ( + setShowImportFlyout(false)} /> + ) : null} ); } diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 5e39565aca8a..bf12961989bc 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -28,8 +28,11 @@ * under the License. */ -import { notificationServiceMock } from '../../../../../core/public/mocks'; -import { httpServiceMock } from '../../../../../core/public/mocks'; +import { + notificationServiceMock, + uiSettingsServiceMock, + httpServiceMock, +} from '../../../../../core/public/mocks'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; @@ -53,7 +56,9 @@ export const serviceContextMock = { settings: new SettingsMock(storage), history: new HistoryMock(storage), notifications: notificationServiceMock.createSetupContract(), + uiSettings: uiSettingsServiceMock.createSetupContract(), objectStorageClient: {} as any, + http, }, docLinkVersion: 'NA', }; diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index fc9ab157f783..0e8398ea8b83 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -29,7 +29,7 @@ */ import React, { createContext, useContext, useEffect } from 'react'; -import { HttpSetup, NotificationsSetup } from 'opensearch-dashboards/public'; +import { HttpSetup, IUiSettingsClient, NotificationsSetup } from 'opensearch-dashboards/public'; import { History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; @@ -44,6 +44,7 @@ interface ContextServices { trackUiMetric: MetricsTracker; opensearchHostService: OpenSearchHostService; http: HttpSetup; + uiSettings: IUiSettingsClient; } export interface ContextValue { diff --git a/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts b/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts index b9be1c56d912..9c6ee873806e 100644 --- a/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts +++ b/src/plugins/console/public/application/hooks/use_data_init/use_data_init.ts @@ -32,6 +32,13 @@ import { useCallback, useEffect, useState } from 'react'; import { migrateToTextObjects } from './data_migration'; import { useEditorActionContext, useServicesContext } from '../../contexts'; +const DEFAULT_INPUT_VALUE = `GET _search +{ + "query": { + "match_all": {} + } +}`; + export const useDataInit = () => { const [error, setError] = useState(null); const [done, setDone] = useState(false); @@ -58,7 +65,7 @@ export const useDataInit = () => { const newObject = await objectStorageClient.text.create({ createdAt: Date.now(), updatedAt: Date.now(), - text: '', + text: DEFAULT_INPUT_VALUE, }); dispatch({ type: 'setCurrentTextObject', payload: newObject }); } else { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx index cc7be7e444f0..8955972d27a0 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx +++ b/src/plugins/console/public/application/hooks/use_send_current_request_to_opensearch/use_send_current_request_to_opensearch.test.tsx @@ -74,7 +74,10 @@ describe('useSendCurrentRequestToOpenSearch', () => { const { result } = renderHook(() => useSendCurrentRequestToOpenSearch(), { wrapper: contexts }); await act(() => result.current()); - expect(sendRequestToOpenSearch).toHaveBeenCalledWith({ requests: ['test'] }); + expect(sendRequestToOpenSearch).toHaveBeenCalledWith({ + requests: ['test'], + http: mockContextValue.services.http, + }); // Second call should be the request success const [, [requestSucceededCall]] = (dispatch as jest.Mock).mock.calls; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index c1a107ac500a..ac32909735b2 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -30,7 +30,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { HttpSetup, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, IUiSettingsClient, NotificationsSetup } from 'src/core/public'; import { ServicesContextProvider, EditorContextProvider, RequestContextProvider } from './contexts'; import { Main } from './containers'; import { createStorage, createHistory, createSettings } from '../services'; @@ -47,6 +47,7 @@ export interface BootDependencies { usageCollection?: UsageCollectionSetup; element: HTMLElement; dataSourceId?: string; + uiSettings: IUiSettingsClient; } export function renderApp({ @@ -57,6 +58,7 @@ export function renderApp({ element, http, dataSourceId, + uiSettings, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -85,6 +87,7 @@ export function renderApp({ trackUiMetric, objectStorageClient, http, + uiSettings, }, }} > diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index 5e1478875ec6..300d57d4b75d 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -68,6 +68,7 @@ export class ConsoleUIPlugin implements Plugin