From 38a29bfcc7d211da37a4313896ae5d27f23d6dbe Mon Sep 17 00:00:00 2001 From: Ahalya Radhakrishnan Date: Fri, 4 Sep 2026 15:42:35 +0530 Subject: [PATCH 1/5] initial commit Signed-off-by: Ahalya Radhakrishnan --- .../work-items/WorkItemsDataSource.test.ts | 115 +++++++++++++++++- .../work-items/WorkItemsDataSource.ts | 98 ++++++++++++++- src/datasources/work-items/constants.ts | 13 ++ src/datasources/work-items/types.ts | 15 +++ 4 files changed, 234 insertions(+), 7 deletions(-) diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index 7574a6cb2..75e4f90b0 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -1,11 +1,17 @@ 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; + let templateSrv: any; + + beforeEach(() => { + [datasource,, templateSrv] = setupDataSource(WorkItemsDataSource); + }); + it('should apply expected default query values', () => { const query = datasource.prepareQuery({ refId: 'A' }); expect(query.outputType).toBe(OutputType.Properties); @@ -23,7 +29,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 +38,111 @@ 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 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 } + ); + }); + + 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 }; + 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 }; + + 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: [] }); + }); + }); + + it('should replace template variables in the queryBy filter', async () => { + jest.spyOn(templateSrv, 'replace').mockReturnValue('state = "NEW"'); + const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 }); + + const query = { refId: 'A', outputType: OutputType.TotalCount, filter: 'state = "$state"' }; + const scopedVars = { state: { text: 'NEW', value: 'NEW' } }; + await datasource.runQuery(query, { scopedVars } as unknown as DataQueryRequest); + + expect(templateSrv.replace).toHaveBeenCalledWith('state = "$state"', scopedVars); + expect(postSpy).toHaveBeenCalledWith( + '/niworkitem/v1/query-workitems', + { filter: 'state = "NEW"', take: 0, returnCount: true }, + { showErrorAlert: false } + ); + }); + + + it('should surface an error message when the total count request fails', async () => { + jest.spyOn(datasource, 'post').mockRejectedValue(new Error('Request failed with status code: 404')); + + const query = { refId: 'A', outputType: OutputType.TotalCount }; + + await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow( + '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..dba92a5eb 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,14 @@ 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'; export class WorkItemsDataSource extends DataSourceBase { constructor( @@ -26,6 +30,8 @@ export class WorkItemsDataSource extends DataSourceBase { baseUrl = `${this.instanceSettings.url}/niworkitem/v1`; queryWorkItemsUrl = `${this.baseUrl}/query-workitems`; + errorTitle = ''; + errorDescription = ''; defaultQuery = { outputType: OutputType.Properties, @@ -42,8 +48,29 @@ 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 { + const filter = this.buildQueryFilter( + this.buildTypeFilter(query.types), + query.filter ? this.templateSrv.replace(query.filter, options.scopedVars) : 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 { + refId: query.refId, + name: query.refId, + fields: [], + }; + } + return { refId: query.refId, name: query.refId, @@ -51,6 +78,71 @@ export class WorkItemsDataSource extends DataSourceBase { }; } + 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); + } + } + + /** + * Combines two filter strings into a single query filter using the '&&' operator. + * Filters that are undefined or empty are excluded from the final query. + */ + protected buildQueryFilter(filterA?: string, filterB?: string): string | undefined { + const filters = [filterA, filterB].filter(Boolean); + return filters.length > 0 ? filters.join(' && ') : undefined; + } + + private buildTypeFilter(types?: WorkItemTypeOptions[]): string | undefined { + if (!types || types.length === 0) { + 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; +} From fc0342ee1c8a19bb47aa43510d5b849bcf08e011 Mon Sep 17 00:00:00 2001 From: Ahalya Radhakrishnan Date: Fri, 4 Sep 2026 16:09:40 +0530 Subject: [PATCH 2/5] add unit test for error handling Signed-off-by: Ahalya Radhakrishnan --- .../work-items/WorkItemsDataSource.test.ts | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index 75e4f90b0..3e068fb85 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -66,7 +66,7 @@ describe('WorkItemsDataSource', () => { ); }); - describe('Total count output type', () => { + describe('total count output type', () => { it('should return the total count when outputType is TotalCount', async () => { jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 42 }); @@ -90,7 +90,7 @@ describe('WorkItemsDataSource', () => { }); }); - describe('Properties output type', () => { + 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); @@ -116,14 +116,64 @@ describe('WorkItemsDataSource', () => { }); - it('should surface an error message when the total count request fails', async () => { - jest.spyOn(datasource, 'post').mockRejectedValue(new Error('Request failed with status code: 404')); + 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 when the request fails with $description', + async ({ rejectedError, expectedMessage }) => { + jest.spyOn(datasource, 'post').mockRejectedValue(new Error(rejectedError)); - const query = { refId: 'A', outputType: OutputType.TotalCount }; + const query = { refId: 'A', outputType: OutputType.TotalCount }; - await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow( - 'The query to fetch work items failed because the requested resource was not found. Please check the query parameters and try again.' + 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 }; + + 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.', + ], + }); + }); }); }); From 13563abf610683d10105c2479052091c68a15957 Mon Sep 17 00:00:00 2001 From: Ahalya Radhakrishnan Date: Fri, 4 Sep 2026 16:35:43 +0530 Subject: [PATCH 3/5] resolve comments Signed-off-by: Ahalya Radhakrishnan --- .../work-items/WorkItemsDataSource.test.ts | 48 +++++++++++++++---- .../work-items/WorkItemsDataSource.ts | 36 +++++++------- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index 3e068fb85..9288a9403 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -44,6 +44,16 @@ describe('WorkItemsDataSource', () => { }); 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 = { @@ -58,7 +68,7 @@ describe('WorkItemsDataSource', () => { expect(postSpy).toHaveBeenCalledWith( '/niworkitem/v1/query-workitems', { - filter: 'type = "workorder" || type = "testplan" && state = "NEW"', + filter: '(type = "workorder" || type = "testplan") && state = "NEW"', take: 0, returnCount: true, }, @@ -70,7 +80,11 @@ describe('WorkItemsDataSource', () => { 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 }; + const query = { + refId: 'A', + outputType: OutputType.TotalCount, + types: [WorkItemTypeOptions.WorkOrders], + }; const result = await datasource.runQuery(query, {} as DataQueryRequest); expect(result).toEqual({ @@ -82,7 +96,11 @@ describe('WorkItemsDataSource', () => { 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 }; + const query = { + refId: 'A', + outputType: OutputType.TotalCount, + types: [WorkItemTypeOptions.WorkOrders], + }; const result = await datasource.runQuery(query, {} as DataQueryRequest); @@ -103,19 +121,23 @@ describe('WorkItemsDataSource', () => { jest.spyOn(templateSrv, 'replace').mockReturnValue('state = "NEW"'); const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 }); - const query = { refId: 'A', outputType: OutputType.TotalCount, filter: 'state = "$state"' }; + const query = { + refId: 'A', + outputType: OutputType.TotalCount, + types: [WorkItemTypeOptions.WorkOrders], + filter: 'state = "$state"', + }; const scopedVars = { state: { text: 'NEW', value: 'NEW' } }; await datasource.runQuery(query, { scopedVars } as unknown as DataQueryRequest); expect(templateSrv.replace).toHaveBeenCalledWith('state = "$state"', scopedVars); expect(postSpy).toHaveBeenCalledWith( '/niworkitem/v1/query-workitems', - { filter: 'state = "NEW"', take: 0, returnCount: true }, + { filter: 'type = "workorder" && state = "NEW"', take: 0, returnCount: true }, { showErrorAlert: false } ); }); - describe('error handling', () => { const errorCases = [ { @@ -148,11 +170,15 @@ describe('WorkItemsDataSource', () => { ]; it.each(errorCases)( - 'should display when the request fails with $description', + '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 }; + const query = { + refId: 'A', + outputType: OutputType.TotalCount, + types: [WorkItemTypeOptions.WorkOrders], + }; await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow(expectedMessage); } @@ -163,7 +189,11 @@ describe('WorkItemsDataSource', () => { (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 }; + const query = { + refId: 'A', + outputType: OutputType.TotalCount, + types: [WorkItemTypeOptions.WorkOrders], + }; await expect(datasource.runQuery(query, {} as DataQueryRequest)).rejects.toThrow(); expect(publishMock).toHaveBeenCalledWith({ diff --git a/src/datasources/work-items/WorkItemsDataSource.ts b/src/datasources/work-items/WorkItemsDataSource.ts index dba92a5eb..091518fed 100644 --- a/src/datasources/work-items/WorkItemsDataSource.ts +++ b/src/datasources/work-items/WorkItemsDataSource.ts @@ -18,6 +18,7 @@ import { } from './types'; 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( @@ -30,8 +31,6 @@ export class WorkItemsDataSource extends DataSourceBase { baseUrl = `${this.instanceSettings.url}/niworkitem/v1`; queryWorkItemsUrl = `${this.baseUrl}/query-workitems`; - errorTitle = ''; - errorDescription = ''; defaultQuery = { outputType: OutputType.Properties, @@ -49,8 +48,12 @@ export class WorkItemsDataSource extends DataSourceBase { }; async runQuery(query: WorkItemsQuery, options: DataQueryRequest): Promise { + if (!isTypesNonEmpty(query.types)) { + return this.getEmptyDataFrameDTO(query.refId); + } + const filter = this.buildQueryFilter( - this.buildTypeFilter(query.types), + this.buildTypeFilter(query.types!), query.filter ? this.templateSrv.replace(query.filter, options.scopedVars) : undefined ); @@ -64,18 +67,10 @@ export class WorkItemsDataSource extends DataSourceBase { } if (query.outputType === OutputType.Properties) { - return { - refId: query.refId, - name: query.refId, - fields: [], - }; + return this.getEmptyDataFrameDTO(query.refId); } - return { - refId: query.refId, - name: query.refId, - fields: [], - }; + return this.getEmptyDataFrameDTO(query.refId); } async queryWorkItemsCount(filter?: string): Promise { @@ -134,13 +129,18 @@ export class WorkItemsDataSource extends DataSourceBase { return filters.length > 0 ? filters.join(' && ') : undefined; } - private buildTypeFilter(types?: WorkItemTypeOptions[]): string | undefined { - if (!types || types.length === 0) { - return undefined; - } + private getEmptyDataFrameDTO(refId: string): DataFrameDTO { + return { + refId: refId, + name: refId, + fields: [], + }; + } + private buildTypeFilter(types: WorkItemTypeOptions[]): string | undefined { const typeValues = types.map(type => WORK_ITEM_TYPE_FILTER_VALUES[type]); - return typeValues.map(value => `type = "${value}"`).join(' || '); + const typeFilter = typeValues.map(value => `type = "${value}"`).join(' || '); + return typeValues.length > 1 ? `(${typeFilter})` : typeFilter; } shouldRunQuery(query: WorkItemsQuery): boolean { From 09be5389bdea6a0f1134687f96819ce70082a8bf Mon Sep 17 00:00:00 2001 From: Ahalya Radhakrishnan Date: Fri, 4 Sep 2026 16:45:12 +0530 Subject: [PATCH 4/5] remove template replace handling Signed-off-by: Ahalya Radhakrishnan --- .../work-items/WorkItemsDataSource.test.ts | 24 +------------------ .../work-items/WorkItemsDataSource.ts | 2 +- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index 9288a9403..f83c3383f 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -5,10 +5,9 @@ import { OrderByOptions, OutputType, WorkItemPropertiesOptions, WorkItemTypeOpti describe('WorkItemsDataSource', () => { let datasource: WorkItemsDataSource; - let templateSrv: any; beforeEach(() => { - [datasource,, templateSrv] = setupDataSource(WorkItemsDataSource); + [datasource] = setupDataSource(WorkItemsDataSource); }); it('should apply expected default query values', () => { @@ -116,27 +115,6 @@ describe('WorkItemsDataSource', () => { expect(result).toEqual({ refId: 'A', name: 'A', fields: [] }); }); }); - - it('should replace template variables in the queryBy filter', async () => { - jest.spyOn(templateSrv, 'replace').mockReturnValue('state = "NEW"'); - const postSpy = jest.spyOn(datasource, 'post').mockResolvedValue({ totalCount: 1 }); - - const query = { - refId: 'A', - outputType: OutputType.TotalCount, - types: [WorkItemTypeOptions.WorkOrders], - filter: 'state = "$state"', - }; - const scopedVars = { state: { text: 'NEW', value: 'NEW' } }; - await datasource.runQuery(query, { scopedVars } as unknown as DataQueryRequest); - - expect(templateSrv.replace).toHaveBeenCalledWith('state = "$state"', scopedVars); - expect(postSpy).toHaveBeenCalledWith( - '/niworkitem/v1/query-workitems', - { filter: 'type = "workorder" && state = "NEW"', take: 0, returnCount: true }, - { showErrorAlert: false } - ); - }); describe('error handling', () => { const errorCases = [ diff --git a/src/datasources/work-items/WorkItemsDataSource.ts b/src/datasources/work-items/WorkItemsDataSource.ts index 091518fed..89c4d551e 100644 --- a/src/datasources/work-items/WorkItemsDataSource.ts +++ b/src/datasources/work-items/WorkItemsDataSource.ts @@ -54,7 +54,7 @@ export class WorkItemsDataSource extends DataSourceBase { const filter = this.buildQueryFilter( this.buildTypeFilter(query.types!), - query.filter ? this.templateSrv.replace(query.filter, options.scopedVars) : undefined + query.filter ? query.filter : undefined ); if (query.outputType === OutputType.TotalCount) { From 923e82627fd6b53aa8e1326fc6320b95888da8d9 Mon Sep 17 00:00:00 2001 From: Ahalya Radhakrishnan Date: Fri, 4 Sep 2026 17:14:32 +0530 Subject: [PATCH 5/5] update grouping Signed-off-by: Ahalya Radhakrishnan --- .../work-items/WorkItemsDataSource.test.ts | 37 ++++++++++++++++++- .../work-items/WorkItemsDataSource.ts | 18 +++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/datasources/work-items/WorkItemsDataSource.test.ts b/src/datasources/work-items/WorkItemsDataSource.test.ts index f83c3383f..bea28c467 100644 --- a/src/datasources/work-items/WorkItemsDataSource.test.ts +++ b/src/datasources/work-items/WorkItemsDataSource.test.ts @@ -67,7 +67,7 @@ describe('WorkItemsDataSource', () => { expect(postSpy).toHaveBeenCalledWith( '/niworkitem/v1/query-workitems', { - filter: '(type = "workorder" || type = "testplan") && state = "NEW"', + filter: '(type = "workorder" || type = "testplan") && (state = "NEW")', take: 0, returnCount: true, }, @@ -75,6 +75,41 @@ describe('WorkItemsDataSource', () => { ); }); + 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 }); diff --git a/src/datasources/work-items/WorkItemsDataSource.ts b/src/datasources/work-items/WorkItemsDataSource.ts index 89c4d551e..24e2e0779 100644 --- a/src/datasources/work-items/WorkItemsDataSource.ts +++ b/src/datasources/work-items/WorkItemsDataSource.ts @@ -52,9 +52,11 @@ export class WorkItemsDataSource extends DataSourceBase { return this.getEmptyDataFrameDTO(query.refId); } + const typeFilter = this.buildTypeFilter(query.types!); + const queryFilter = query.filter?.trim(); const filter = this.buildQueryFilter( - this.buildTypeFilter(query.types!), - query.filter ? query.filter : undefined + typeFilter ? `(${typeFilter})` : undefined, + queryFilter ? `(${queryFilter})` : undefined ); if (query.outputType === OutputType.TotalCount) { @@ -120,10 +122,6 @@ export class WorkItemsDataSource extends DataSourceBase { } } - /** - * Combines two filter strings into a single query filter using the '&&' operator. - * Filters that are undefined or empty are excluded from the final query. - */ protected buildQueryFilter(filterA?: string, filterB?: string): string | undefined { const filters = [filterA, filterB].filter(Boolean); return filters.length > 0 ? filters.join(' && ') : undefined; @@ -138,9 +136,13 @@ export class WorkItemsDataSource extends DataSourceBase { } 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]); - const typeFilter = typeValues.map(value => `type = "${value}"`).join(' || '); - return typeValues.length > 1 ? `(${typeFilter})` : typeFilter; + return typeValues.map(value => `type = "${value}"`).join(' || '); } shouldRunQuery(query: WorkItemsQuery): boolean {