Skip to content
Draft
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
208 changes: 204 additions & 4 deletions src/datasources/work-items/WorkItemsDataSource.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { WorkItemsDataSource } from './WorkItemsDataSource';
import { setupDataSource } from 'test/fixtures';
import { DataQueryRequest } from '@grafana/data';
import { OrderByOptions, OutputType, WorkItemPropertiesOptions, WorkItemTypeOptions } from './types';

describe('WorkItemsDataSource', () => {
it('should apply expected default query values', () => {
const [datasource] = setupDataSource(WorkItemsDataSource);
let datasource: WorkItemsDataSource;

beforeEach(() => {
[datasource] = setupDataSource(WorkItemsDataSource);
});

it('should apply expected default query values', () => {
const query = datasource.prepareQuery({ refId: 'A' });

expect(query.outputType).toBe(OutputType.Properties);
Expand All @@ -23,7 +28,6 @@ describe('WorkItemsDataSource', () => {
});

it('should test datasource connection against the work-items service endpoint', async () => {
const [datasource] = setupDataSource(WorkItemsDataSource);
const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({} as any);

const result = await datasource.testDatasource();
Expand All @@ -33,9 +37,205 @@ describe('WorkItemsDataSource', () => {
});

it('should bubble up exception when datasource connectivity check fails', async () => {
const [datasource] = setupDataSource(WorkItemsDataSource);
jest.spyOn(datasource, 'post').mockRejectedValue(new Error('Failed'));

await expect(datasource.testDatasource()).rejects.toThrow('Failed');
});

describe('runQuery', () => {
it('should return an empty data frame without querying when no types are selected', async () => {
const postSpy = jest.spyOn(datasource, 'post');
const query = { refId: 'A', outputType: OutputType.TotalCount, types: [] };

const result = await datasource.runQuery(query, {} as DataQueryRequest);

expect(result).toEqual({ refId: 'A', name: 'A', fields: [] });
expect(postSpy).not.toHaveBeenCalled();
});

it('should combine the type filter and the queryBy filter', async () => {
Comment thread
Ahalya-ni marked this conversation as resolved.
const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 });
const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders, WorkItemTypeOptions.TestPlans],
filter: 'state = "NEW"',
};

await datasource.runQuery(query, { scopedVars: {} } as DataQueryRequest);

expect(postSpy).toHaveBeenCalledWith(
'/niworkitem/v1/query-workitems',
{
filter: '(type = "workorder" || type = "testplan") && (state = "NEW")',
take: 0,
returnCount: true,
},
{ showErrorAlert: false }
);
});

it('should group each filter when one type is selected', async () => {
const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 });
const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders],
filter: 'state = "NEW"',
};

await datasource.runQuery(query, {} as DataQueryRequest);

expect(postSpy).toHaveBeenCalledWith(
'/niworkitem/v1/query-workitems',
{ filter: '(type = "workorder") && (state = "NEW")', take: 0, returnCount: true },
{ showErrorAlert: false }
);
});

it('should omit the type filter when all types are selected', async () => {
const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 });
const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: Object.values(WorkItemTypeOptions),
};

await datasource.runQuery(query, {} as DataQueryRequest);

expect(postSpy).toHaveBeenCalledWith(
'/niworkitem/v1/query-workitems',
{ filter: undefined, take: 0, returnCount: true },
{ showErrorAlert: false }
);
});

describe('total count output type', () => {
it('should return the total count when outputType is TotalCount', async () => {
jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 42 });

const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders],
};
const result = await datasource.runQuery(query, {} as DataQueryRequest);

expect(result).toEqual({
refId: 'A',
name: 'A',
fields: [{ name: 'A', values: [42] }],
});
});

it('should return 0 as total count when the API returns no totalCount', async () => {
jest.spyOn(datasource, 'post').mockResolvedValue({});
const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders],
};

const result = await datasource.runQuery(query, {} as DataQueryRequest);

expect(result.fields).toEqual([{ name: 'A', values: [0] }]);
});
});

describe('properties output type', () => {
it('should return an empty fields array when outputType is Properties', async () => {
const query = { refId: 'A', outputType: OutputType.Properties };
const result = await datasource.runQuery(query, {} as DataQueryRequest);

expect(result).toEqual({ refId: 'A', name: 'A', fields: [] });
});
});

describe('error handling', () => {
const errorCases = [
{
description: 'an unknown status code',
rejectedError: 'Request failed',
expectedMessage: 'The query failed due to an unknown error.',
},
{
description: 'status code 404',
rejectedError: 'Request failed with status code: 404',
expectedMessage:
'The query to fetch work items failed because the requested resource was not found. Please check the query parameters and try again.',
},
{
description: 'status code 429',
rejectedError: 'Request failed with status code: 429',
expectedMessage: 'The query to fetch work items failed due to too many requests. Please try again later.',
},
{
description: 'status code 504',
rejectedError: 'Request failed with status code: 504',
expectedMessage:
'The query to fetch work items experienced a timeout error. Narrow your query with a more specific filter and try again.',
},
{
description: 'an unhandled status code',
rejectedError: 'Request failed with status code: 500 Error message: Internal error',
expectedMessage: 'The query failed due to the following error: (status 500) Internal error.',
},
];

it.each(errorCases)(
'should display an error message when the request fails with $description',
async ({ rejectedError, expectedMessage }) => {
jest.spyOn(datasource, 'post').mockRejectedValue(new Error(rejectedError));

const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders],
};

await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow(expectedMessage);
}
);

it('should publish an alertError event when the request fails', async () => {
const publishMock = jest.fn();
(datasource as any).appEvents = { publish: publishMock };
jest.spyOn(datasource, 'post').mockRejectedValue(new Error('Request failed with status code: 404'));

const query = {
refId: 'A',
outputType: OutputType.TotalCount,
types: [WorkItemTypeOptions.WorkOrders],
};

await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow();
expect(publishMock).toHaveBeenCalledWith({
type: 'alert-error',
payload: [
'Error during work items query',
'The query to fetch work items failed because the requested resource was not found. Please check the query parameters and try again.',
],
});
});
});
});

describe('shouldRunQuery', () => {
it('should return true when the query is not hidden', () => {
const query = { refId: 'A', hide: false };

const shouldRunQueryResult = datasource.shouldRunQuery(query);

expect(shouldRunQueryResult).toBe(true);
});

it('should return false when the query is hidden', () => {
const query = { refId: 'A', hide: true };

const shouldRunQueryResult = datasource.shouldRunQuery(query);

expect(shouldRunQueryResult).toBe(false);
});
});
});

104 changes: 99 additions & 5 deletions src/datasources/work-items/WorkItemsDataSource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AppEvents,
DataFrameDTO,
DataQueryRequest,
DataSourceInstanceSettings,
Expand All @@ -9,11 +10,15 @@ import { DataSourceBase } from 'core/DataSourceBase';
import {
OrderByOptions,
OutputType,
QueryWorkItemsRequestBody,
WorkItemPropertiesOptions,
WorkItemsQuery,
WorkItemsResponse,
WorkItemTypeOptions,
} from './types';
import { DEFAULT_TAKE } from './constants';
import { DEFAULT_TAKE, WORK_ITEM_TYPE_FILTER_VALUES } from './constants';
import { extractErrorInfo } from 'core/errors';
import { isTypesNonEmpty } from './utils';

export class WorkItemsDataSource extends DataSourceBase<WorkItemsQuery> {
constructor(
Expand Down Expand Up @@ -42,15 +47,104 @@ export class WorkItemsDataSource extends DataSourceBase<WorkItemsQuery> {
take: DEFAULT_TAKE,
};

// TODO: AB#3923375 - Query work items and return the requested properties instead of an empty frame.
async runQuery(query: WorkItemsQuery, _options: DataQueryRequest<WorkItemsQuery>): Promise<DataFrameDTO> {
async runQuery(query: WorkItemsQuery, options: DataQueryRequest<WorkItemsQuery>): Promise<DataFrameDTO> {
if (!isTypesNonEmpty(query.types)) {
return this.getEmptyDataFrameDTO(query.refId);
}

const typeFilter = this.buildTypeFilter(query.types!);
const queryFilter = query.filter?.trim();
const filter = this.buildQueryFilter(
typeFilter ? `(${typeFilter})` : undefined,
queryFilter ? `(${queryFilter})` : undefined
);

if (query.outputType === OutputType.TotalCount) {
const totalCount = await this.queryWorkItemsCount(filter);
return {
refId: query.refId,
name: query.refId,
fields: [{ name: query.refId, values: [totalCount] }],
};
}

if (query.outputType === OutputType.Properties) {
return this.getEmptyDataFrameDTO(query.refId);
}

return this.getEmptyDataFrameDTO(query.refId);
}

async queryWorkItemsCount(filter?: string): Promise<number> {
const body: QueryWorkItemsRequestBody = {
filter,
take: 0,
returnCount: true,
};
const response = await this.queryWorkItems(body);
return response.totalCount ?? 0;
}

async queryWorkItems(body: QueryWorkItemsRequestBody): Promise<WorkItemsResponse> {
try {
return await this.post<WorkItemsResponse>(
this.queryWorkItemsUrl,
body,
{ showErrorAlert: false } // suppress default error alert since we handle errors manually
);
} catch (error) {
const errorDetails = extractErrorInfo((error as Error).message);
let errorMessage: string;
switch (errorDetails.statusCode) {
case '':
errorMessage = 'The query failed due to an unknown error.';
break;
case '404':
errorMessage = 'The query to fetch work items failed because the requested resource was not found. Please check the query parameters and try again.';
break;
case '429':
errorMessage = 'The query to fetch work items failed due to too many requests. Please try again later.';
break;
case '504':
errorMessage = 'The query to fetch work items experienced a timeout error. Narrow your query with a more specific filter and try again.';
break;
default:
errorMessage = `The query failed due to the following error: (status ${errorDetails.statusCode}) ${errorDetails.message}.`;
break;
}

this.appEvents?.publish?.({
type: AppEvents.alertError.name,
payload: ['Error during work items query', errorMessage],
});

throw new Error(errorMessage);
}
}

protected buildQueryFilter(filterA?: string, filterB?: string): string | undefined {
const filters = [filterA, filterB].filter(Boolean);
return filters.length > 0 ? filters.join(' && ') : undefined;
}

private getEmptyDataFrameDTO(refId: string): DataFrameDTO {
return {
refId: query.refId,
name: query.refId,
refId: refId,
name: refId,
fields: [],
};
}

private buildTypeFilter(types: WorkItemTypeOptions[]): string | undefined {
const allTypesAreSelected = Object.values(WorkItemTypeOptions).every(type => types.includes(type));
if (allTypesAreSelected) {
return undefined;
}

const typeValues = types.map(type => WORK_ITEM_TYPE_FILTER_VALUES[type]);
return typeValues.map(value => `type = "${value}"`).join(' || ');
}

shouldRunQuery(query: WorkItemsQuery): boolean {
return !query.hide;
}
Expand Down
13 changes: 13 additions & 0 deletions src/datasources/work-items/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
import { WorkItemTypeOptions } from './types';

export const TAKE_LIMIT = 10000;
export const DEFAULT_TAKE = 1000;

// Maps each work item type option to the backend's `type` filter value.
export const WORK_ITEM_TYPE_FILTER_VALUES: Record<WorkItemTypeOptions, string> = {
[WorkItemTypeOptions.WorkOrders]: 'workorder',
[WorkItemTypeOptions.TestPlans]: 'testplan',
[WorkItemTypeOptions.Job]: 'job',
[WorkItemTypeOptions.Maintenance]: 'maintenance',
[WorkItemTypeOptions.Calibration]: 'calibration',
[WorkItemTypeOptions.Reservation]: 'reservation',
[WorkItemTypeOptions.TransportOrder]: 'transportorder',
};
Loading
Loading