Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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',
singalType: 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