Skip to content
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
2 changes: 2 additions & 0 deletions changelogs/fragments/10690.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Allow saving dataset from dataset configurator ([#10690](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10690))
2 changes: 2 additions & 0 deletions src/plugins/data/common/data_views/data_views/data_views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,8 @@ export class DataViewsService {
title: dataView.title,
type: dataView.type || DEFAULT_DATA.SET_TYPES.INDEX_PATTERN,
timeFieldName: dataView.timeFieldName,
displayName: dataView.displayName,
description: dataView.description,
...(dataView.dataSourceRef?.id && {
dataSource: {
id: dataView.dataSourceRef.id,
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/data/common/datasets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ export interface Dataset extends BaseDataset {
};
/** Optional parameter to indicate if the dataset is from a remote cluster(Cross Cluster search) */
isRemoteDataset?: boolean;
displayName?: string;
description?: string;
}

export interface DatasetField {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,87 @@ describe('DatasetService', () => {
expect(indexPatterns.saveToCache).toHaveBeenCalledTimes(0);
});

test('cacheDataset passes signalType to index pattern spec', async () => {
const mockDataset = {
id: 'test-dataset',
title: 'Test Dataset',
type: mockType.id,
} as Dataset;
service.registerType(mockType);

await service.cacheDataset(mockDataset, mockDataPluginServices, true, 'logs');
expect(indexPatterns.create).toHaveBeenCalledWith(
expect.objectContaining({
signalType: 'logs',
}),
true
);
});

test('saveDataset creates and saves a new dataset', async () => {
const mockDataset = {
id: 'test-dataset',
title: 'Test Dataset',
displayName: 'My Dataset',
description: 'Test description',
type: mockType.id,
timeFieldName: 'timestamp',
} as Dataset;

const mockDataViews = {
createAndSave: jest.fn().mockResolvedValue({}),
};

const servicesWithDataViews = {
...mockDataPluginServices,
data: {
...dataPluginMock.createStartContract(),
dataViews: mockDataViews as any,
},
};

service.registerType(mockType);
await service.saveDataset(mockDataset, servicesWithDataViews, 'metrics');

expect(mockDataViews.createAndSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-dataset',
title: 'Test Dataset',
displayName: 'My Dataset',
description: 'Test description',
timeFieldName: 'timestamp',
signalType: 'metrics',
}),
undefined,
false
);
});

test('saveDataset does not save index pattern datasets', async () => {
const mockDataset = {
id: 'test-index-pattern',
title: 'Test Index Pattern',
type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN,
} as Dataset;

const mockDataViews = {
createAndSave: jest.fn(),
};

const servicesWithDataViews = {
...mockDataPluginServices,
data: {
...dataPluginMock.createStartContract(),
dataViews: mockDataViews as any,
},
};

service.registerType(indexPatternTypeConfig);
await service.saveDataset(mockDataset, servicesWithDataViews);

expect(mockDataViews.createAndSave).not.toHaveBeenCalled();
});

test('addRecentDataset adds a dataset', () => {
const mockDataset1: Dataset = {
id: 'dataset1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ export class DatasetService {
public async cacheDataset(
dataset: Dataset,
services: Partial<IDataPluginServices>,
defaultCache: boolean = true
defaultCache: boolean = true,
signalType?: string
): Promise<void> {
const type = this.getType(dataset?.type);
try {
Expand All @@ -112,6 +113,7 @@ export class DatasetService {
timeFieldName: dataset.timeFieldName,
fields: fetchedFields,
fieldsLoading: asyncType,
signalType,
dataSourceRef: dataset.dataSource
? {
id: dataset.dataSource.id!,
Expand Down Expand Up @@ -171,6 +173,50 @@ export class DatasetService {
}
}

public async saveDataset(
dataset: Dataset,
services: Partial<IDataPluginServices>,
signalType?: string
): Promise<void> {
const type = this.getType(dataset?.type);
try {
const asyncType = type?.meta.isFieldLoadAsync ?? false;
if (dataset && dataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN) {
const fetchedFields = asyncType
? ({} as IndexPatternFieldMap)
: await type?.fetchFields(dataset, services);
const spec = {
id: dataset.id,
displayName: dataset.displayName,
title: dataset.title,
timeFieldName: dataset.timeFieldName,
description: dataset.description,
signalType,
fields: fetchedFields,
fieldsLoading: asyncType,
dataSourceRef: dataset.dataSource
? {
id: dataset.dataSource.id!,
name: dataset.dataSource.title,
type: dataset.dataSource.type,
}
: undefined,
} as IndexPatternSpec;

// TODO: For async field loading (when asyncType is true), the data view is created
// with skipFetchFields=true, meaning fields will be empty initially. The fields will
// be loaded asynchronously when the data view is first accessed. However, this means
// the saved data view object won't have field metadata until it's loaded.
// Consider fetching fields after createAndSave and updating the saved object:
// const dataView = await createAndSave(...);
// if (asyncType) { await type.fetchFields(...); await dataViews.updateSavedObject(dataView); }
await services.data?.dataViews.createAndSave(spec, undefined, asyncType);
}
} catch (error) {
throw new Error(`Failed to save dataset: ${dataset?.id}`);
}
}

public async fetchOptions(
services: IDataPluginServices,
path: DataStructure[],
Expand Down
110 changes: 107 additions & 3 deletions src/plugins/data/public/ui/dataset_select/dataset_select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ jest.mock('../../services', () => ({
getQueryService: jest.fn(),
}));

// TODO: Enable this test. skipping due to it hanging
describe.skip('DatasetSelect', () => {
describe('DatasetSelect', () => {
const mockOnSelect = jest.fn();
const mockQuery = {
dataset: {
Expand All @@ -45,6 +44,7 @@ describe.skip('DatasetSelect', () => {
icon: {
type: 'database',
},
supportedAppNames: undefined, // undefined means supported by all apps
},
}),
cacheDataset: jest.fn(),
Expand Down Expand Up @@ -77,6 +77,7 @@ describe.skip('DatasetSelect', () => {
type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN,
});
}),
clearCache: jest.fn(),
};

// Create services for the component
Expand All @@ -100,7 +101,7 @@ describe.skip('DatasetSelect', () => {

const defaultProps: DatasetSelectProps = {
onSelect: mockOnSelect,
appName: 'testApp',
signalType: null,
};

const renderWithContext = (props: DatasetSelectProps = defaultProps) => {
Expand Down Expand Up @@ -204,4 +205,107 @@ describe.skip('DatasetSelect', () => {
expect(mockOnSelect).toHaveBeenCalled();
});
});

it('filters datasets by supportedAppNames', async () => {
// Create a dataset type that only supports 'otherApp'
const mockGetTypeRestricted = jest.fn().mockReturnValue({
id: 'restricted-type',
title: 'Restricted Type',
meta: {
icon: { type: 'database' },
supportedAppNames: ['otherApp'], // Does not include 'testApp'
},
});

mockQueryService.queryString.getDatasetService = jest.fn().mockReturnValue({
getType: mockGetTypeRestricted,
cacheDataset: jest.fn(),
});

// Mock a dataset with the restricted type
mockDataViews.getIds = jest.fn().mockResolvedValue(['restricted-id']);
mockDataViews.get = jest.fn().mockResolvedValue({
id: 'restricted-id',
title: 'Restricted Dataset',
displayName: 'Restricted Dataset',
type: 'restricted-type',
});
mockDataViews.convertToDataset = jest.fn().mockResolvedValue({
id: 'restricted-id',
title: 'Restricted Dataset',
type: 'restricted-type',
});

renderWithContext();

await waitFor(() => {
expect(mockDataViews.getIds).toHaveBeenCalled();
});

// The dataset should be filtered out since it doesn't support 'testApp'
const button = screen.getByTestId('datasetSelectButton');
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});

// The restricted dataset should not appear in the list
expect(screen.queryByText('Restricted Dataset')).not.toBeInTheDocument();
});

it('includes datasets when supportedAppNames is undefined', async () => {
// Dataset type with undefined supportedAppNames (supports all apps)
const mockGetTypeAll = jest.fn().mockReturnValue({
id: 'all-apps-type',
title: 'All Apps Type',
meta: {
icon: { type: 'database' },
supportedAppNames: undefined,
},
});

mockQueryService.queryString.getDatasetService = jest.fn().mockReturnValue({
getType: mockGetTypeAll,
cacheDataset: jest.fn(),
});

mockDataViews.getIds = jest.fn().mockResolvedValue(['all-apps-id']);
mockDataViews.get = jest.fn().mockResolvedValue({
id: 'all-apps-id',
title: 'all-apps-dataset',
displayName: 'All Apps Dataset',
type: 'all-apps-type',
});
mockDataViews.convertToDataset = jest.fn().mockResolvedValue({
id: 'all-apps-id',
title: 'all-apps-dataset',
type: 'all-apps-type',
});
mockQueryService.queryString.getQuery = jest.fn().mockReturnValue({
dataset: {
id: 'all-apps-id',
title: 'all-apps-dataset',
type: 'all-apps-type',
},
});

renderWithContext();

await waitFor(() => {
expect(mockDataViews.getIds).toHaveBeenCalled();
expect(mockDataViews.get).toHaveBeenCalled();
});

const button = screen.getByTestId('datasetSelectButton');
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});

// The dataset should appear since supportedAppNames is undefined (checking by display name)
const allAppsElements = screen.getAllByText('All Apps Dataset');
expect(allAppsElements.length).toBeGreaterThan(0);
});
});
Loading
Loading