diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index 7574a6cb2..bea28c467 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -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); @@ -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(); @@ -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 () => { + 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); + }); + }); }); + diff --git a/src/datasources/work-items/WorkItemsDataSource.ts b/src/datasources/work-items/WorkItemsDataSource.ts index ffca89f73..24e2e0779 100644 --- a/src/datasources/work-items/WorkItemsDataSource.ts +++ b/src/datasources/work-items/WorkItemsDataSource.ts @@ -1,4 +1,5 @@ import { + AppEvents, DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, @@ -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 { constructor( @@ -42,15 +47,104 @@ export class WorkItemsDataSource extends DataSourceBase { 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): Promise { + async runQuery(query: WorkItemsQuery, options: DataQueryRequest): Promise { + 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 { + const body: QueryWorkItemsRequestBody = { + filter, + take: 0, + returnCount: true, + }; + const response = await this.queryWorkItems(body); + return response.totalCount ?? 0; + } + + async queryWorkItems(body: QueryWorkItemsRequestBody): Promise { + try { + return await this.post( + 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; } diff --git a/src/datasources/work-items/constants.ts b/src/datasources/work-items/constants.ts index 6bf454479..1cc0b278c 100644 --- a/src/datasources/work-items/constants.ts +++ b/src/datasources/work-items/constants.ts @@ -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.WorkOrders]: 'workorder', + [WorkItemTypeOptions.TestPlans]: 'testplan', + [WorkItemTypeOptions.Job]: 'job', + [WorkItemTypeOptions.Maintenance]: 'maintenance', + [WorkItemTypeOptions.Calibration]: 'calibration', + [WorkItemTypeOptions.Reservation]: 'reservation', + [WorkItemTypeOptions.TransportOrder]: 'transportorder', +}; diff --git a/src/datasources/work-items/types.ts b/src/datasources/work-items/types.ts index c63b1523d..1dc9aa48d 100644 --- a/src/datasources/work-items/types.ts +++ b/src/datasources/work-items/types.ts @@ -74,3 +74,18 @@ export enum WorkItemPropertiesGroup { RESOURCES = 'Resources', CUSTOM_PROPERTIES = 'Custom properties', } + +export interface WorkItemsResponse { + continuationToken?: string; + totalCount?: number; +} + +export interface QueryWorkItemsRequestBody { + filter?: string; + projection?: string[]; + orderBy?: string; + descending?: boolean; + take?: number; + returnCount?: boolean; + continuationToken?: string; +}