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

Data import plugin #9227

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions config/opensearch_dashboards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,10 @@

# Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin.
# Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards.
# The default config is [], and no one will be dashboard admin.
# The default config is [], and no one will be dashboard admin.
# If the user config is set to wildcard ["*"], anyone will be dashboard admin.
# opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"]
# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"]

# Set the value to true to enable the new UI for savedQueries in Discover
# data.savedQueriesNewUI.enabled: true
# data.savedQueriesNewUI.enabled: true
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- [Dashboard](../src/plugins/dashboard/README.md)
- [Data](../src/plugins/data/README.md)
- [Data_explorer](../src/plugins/data_explorer/README.md)
- [Data_importer](../src/plugins/data_importer/README.md)
- [Data_source](../src/plugins/data_source/README.md)
- [Data_source_management](../src/plugins/data_source_management/README.md)
- [Dev_tools](../src/plugins/dev_tools/README.md)
Expand Down
1 change: 0 additions & 1 deletion examples/search_examples/public/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ export const SearchExamplesApp = ({
});
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
// TODO: Make response error status clearer
notifications.toasts.addWarning('An error has occurred');
searchSubscription$.unsubscribe();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ const {
useContainer: useAppStateContainer,
} = createStateContainerReactHelpers<ReduxLikeStateContainer<AppState>>();

const App = ({ navigation, data, history, osdUrlStateStorage }: StateDemoAppDeps) => {
const App = ({
navigation,
data,
history,
osdUrlStateStorage,
notifications,
}: StateDemoAppDeps) => {
const appStateContainer = useAppStateContainer();
const appState = useAppState();

Expand All @@ -100,6 +106,10 @@ const App = ({ navigation, data, history, osdUrlStateStorage }: StateDemoAppDeps
if (!indexPattern)
return <div>No index pattern found. Please create an index patter before loading...</div>;

const handleSuccess = (message: string) => {
notifications.toasts.addSuccess(message);
};

// Render the application DOM.
// Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract.
return (
Expand Down Expand Up @@ -130,7 +140,10 @@ const App = ({ navigation, data, history, osdUrlStateStorage }: StateDemoAppDeps
<EuiFieldText
placeholder="Additional application state: My name is..."
value={appState.name}
onChange={(e) => appStateContainer.set({ ...appState, name: e.target.value })}
onChange={(e) => {
appStateContainer.set({ ...appState, name: e.target.value });
handleSuccess('Name updated successfully');
}}
aria-label="My name"
/>
</EuiPageContent>
Expand Down
7 changes: 7 additions & 0 deletions src/plugins/data_importer/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
root: true,
extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'],
rules: {
'@osd/eslint/require-license-header': 'off',
},
};
7 changes: 7 additions & 0 deletions src/plugins/data_importer/.i18nrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"prefix": "dataUploader",
"paths": {
"dataUploader": "."
},
"translations": ["translations/ja-JP.json"]
}
11 changes: 11 additions & 0 deletions src/plugins/data_importer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# dataUploader

A OpenSearch Dashboards plugin

---

## Development

See the [OpenSearch Dashboards contributing
guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions
setting up your development environment.
2 changes: 2 additions & 0 deletions src/plugins/data_importer/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const PLUGIN_ID = 'dataImporter';
export const PLUGIN_NAME = 'Data Importer';
9 changes: 9 additions & 0 deletions src/plugins/data_importer/opensearch_dashboards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "dataUploader",
"version": "1.0.0",
"opensearchDashboardsVersion": "opensearchDashboards",
"server": true,
"ui": true,
"requiredPlugins": ["navigation"],
"optionalPlugins": []
}
23 changes: 23 additions & 0 deletions src/plugins/data_importer/public/application.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreStart } from '../../../core/public';
import { AppPluginStartDependencies } from './types';
import { DataUploaderApp } from './components/app';

export const renderApp = (
{ notifications, http }: CoreStart,
{ navigation }: AppPluginStartDependencies,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(
<DataUploaderApp
basename={appBasePath}
notifications={notifications}
http={http}
navigation={navigation}
/>,
element
);

return () => ReactDOM.unmountComponentAtNode(element);
};
45 changes: 45 additions & 0 deletions src/plugins/data_importer/public/components/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiTitle,
} from '@elastic/eui';
import { NavigationPublicPluginStart } from 'src/plugins/navigation/public';
import { CoreStart } from '../../../../core/public';
import { UploadForm } from './upload_form';
import { PLUGIN_NAME } from '../../common';

interface DataUploaderAppDeps {
http: CoreStart['http'];
basename: string;
notifications: CoreStart['notifications'];
navigation: NavigationPublicPluginStart;
}

export const DataUploaderApp = ({
basename,
notifications,
http,
navigation,
}: DataUploaderAppDeps) => (
<Router basename={basename}>
<EuiPage>
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>{PLUGIN_NAME}</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<UploadForm http={http} notifications={notifications} />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</Router>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors
.customSearchBar .euiFormControlLayoutIcons {
height: 20px;
display: flex;
align-items: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import {
EuiText,
EuiTable,
EuiTableHeader,
EuiTableHeaderCell,
EuiTableBody,
EuiTableRow,
EuiTableRowCell,
EuiButton,
EuiFieldSearch,
} from '@elastic/eui';
import './preview_component.scss';

interface PreviewComponentProps {
previewData: any[];
visibleRows: number;
loadMoreRows: () => void;
}

export const PreviewComponent = ({
previewData,
visibleRows,
loadMoreRows,
}: PreviewComponentProps) => {
const [searchQuery, setSearchQuery] = useState('');
const totalRows = previewData.length;
const loadedRows = Math.min(visibleRows, totalRows);

const filteredData = previewData.filter((row) =>
Object.values(row).some(
(value) =>
typeof value === 'string' && value.toLowerCase().includes(searchQuery.toLowerCase())
)
);

return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<EuiText>
<h3>
Preview Data ({loadedRows}/{totalRows})
</h3>
</EuiText>
{totalRows > 0 && (
<EuiFieldSearch
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
isClearable
className="customSearchBar"
/>
)}
</div>
{totalRows > 0 ? (
<div style={{ height: 'calc(100% - 40px)', overflowY: 'auto' }}>
<EuiTable>
<EuiTableHeader>
<EuiTableHeaderCell>#</EuiTableHeaderCell>
{Object.keys(previewData[0]).map((key) => (
<EuiTableHeaderCell key={key}>{key}</EuiTableHeaderCell>
))}
</EuiTableHeader>
<EuiTableBody>
{filteredData.slice(0, loadedRows).map((row, rowIndex) => (
<EuiTableRow key={rowIndex}>
<EuiTableRowCell>{rowIndex + 1}</EuiTableRowCell>
{Object.keys(row).map((field, colIndex) => (
<EuiTableRowCell key={colIndex}>{row[field]}</EuiTableRowCell>
))}
</EuiTableRow>
))}
</EuiTableBody>
</EuiTable>
</div>
) : (
<EuiText>
<p>No data to display. Please upload a file to see the preview.</p>
</EuiText>
)}
{loadedRows < totalRows && (
<EuiButton onClick={loadMoreRows} style={{ marginTop: '20px' }}>
Click to See More
</EuiButton>
)}
</>
);
};
109 changes: 109 additions & 0 deletions src/plugins/data_importer/public/components/upload_form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from 'react';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import { UploadForm } from './upload_form';
import { HttpStart, NotificationsStart } from '../../../../core/public';
import { PreviewComponent } from './preview_component'; // Adjust the path as necessary

const mockHttp: HttpStart = {
get: jest.fn(),
post: jest.fn(),
// ...other methods
} as any;

const mockNotifications: NotificationsStart = {
toasts: {
addSuccess: jest.fn(),
addDanger: jest.fn(),
// ...other methods
},
} as any;

describe('UploadForm', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the form', () => {
const { getByText } = render(<UploadForm http={mockHttp} notifications={mockNotifications} />);
expect(getByText('Upload Form')).toBeInTheDocument();
});

it('handles file change', async () => {
const { getByLabelText } = render(
<UploadForm http={mockHttp} notifications={mockNotifications} />
);
const fileInput = getByLabelText('File') as HTMLInputElement;

const file = new File(['name,age\nJohn,30\nJane,25'], 'test.csv', { type: 'text/csv' });
fireEvent.change(fileInput, { target: { files: [file] } });

await waitFor(() => {
expect(fileInput.files?.[0]).toBe(file);
});
});

it('handles form submission', async () => {
(mockHttp.post as jest.Mock).mockResolvedValue({ documentsCount: 2, totalDocuments: 2 });

const { getByText, getByLabelText } = render(
<UploadForm http={mockHttp} notifications={mockNotifications} />
);
const fileInput = getByLabelText('File') as HTMLInputElement;
const indexNameInput = getByLabelText('Index Name') as HTMLInputElement;
const submitButton = getByText('Submit');

const file = new File(['name,age\nJohn,30\nJane,25'], 'test.csv', { type: 'text/csv' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.change(indexNameInput, { target: { value: 'test-index' } });
fireEvent.click(submitButton);

await waitFor(() => {
expect(mockHttp.post).toHaveBeenCalledWith('/api/data_importer/upload', expect.any(Object));
expect(mockNotifications.toasts.addSuccess).toHaveBeenCalledWith(
'Successfully indexed documents'
);
});
});

it('handles form submission error', async () => {
(mockHttp.post as jest.Mock).mockRejectedValue(new Error('Upload failed'));

const { getByText, getByLabelText } = render(
<UploadForm http={mockHttp} notifications={mockNotifications} />
);
const fileInput = getByLabelText('File') as HTMLInputElement;
const indexNameInput = getByLabelText('Index Name') as HTMLInputElement;
const submitButton = getByText('Submit');

const file = new File(['name,age\nJohn,30\nJane,25'], 'test.csv', { type: 'text/csv' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.change(indexNameInput, { target: { value: 'test-index' } });
fireEvent.click(submitButton);

await waitFor(() => {
expect(mockHttp.post).toHaveBeenCalledWith('/api/data_importer/upload', expect.any(Object));
expect(mockNotifications.toasts.addDanger).toHaveBeenCalledWith('Error: Upload failed');
});
});
});

describe('PreviewComponent', () => {
const props = {
previewData: [{ field1: 'value1', field2: 'value2' }],
visibleRows: 1,
loadMoreRows: jest.fn(),
};

it('renders preview data', () => {
render(<PreviewComponent {...props} />);
expect(screen.getByText(/Preview Data/i)).toBeInTheDocument();
expect(screen.getByText(/value1/i)).toBeInTheDocument();
expect(screen.getByText(/value2/i)).toBeInTheDocument();
});

it('calls loadMoreRows on button click', () => {
render(<PreviewComponent {...props} />);
fireEvent.click(screen.getByRole('button', { name: /Click to Add More/i }));
expect(props.loadMoreRows).toHaveBeenCalled();
});
});
Loading