Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bdcb741
Feat(work-items): Initial Boilerplate
shivanshu-ni Aug 12, 2026
769e5fb
Added workitems in example.yaml
shivanshu-ni Aug 12, 2026
a3b89df
feat(work-items): add SystemLink Work Items datasource configuration …
shivanshu-ni Aug 12, 2026
e28b279
feat(work-items): enhance WorkItemsDataSource and QueryEditor with de…
shivanshu-ni Aug 16, 2026
f287de3
feat(work-items): update datasource connection method to use POST for…
shivanshu-ni Aug 19, 2026
bb25723
refactor(work-items): remove unused WorkItemsConfigEditor and clean u…
shivanshu-ni Aug 19, 2026
fa68b82
refactor(work-items): remove Work Items datasource configuration from…
shivanshu-ni Aug 19, 2026
cd898db
Merge branch 'users/shivanshu/feat/workitem-initial' of https://githu…
shivanshu-ni Aug 19, 2026
efbfdfc
feat(work-items): refactor query editor and data source logic, add co…
shivanshu-ni Aug 19, 2026
ed599a2
feat(work-items): update take value handling in query editor and data…
shivanshu-ni Aug 19, 2026
ea014e8
feat(work-items): implement take value normalization and validation i…
shivanshu-ni Aug 19, 2026
7b0dd78
feat(work-items): enhance take value handling in query editor and dat…
shivanshu-ni Aug 19, 2026
08a6d2f
feat(docs): add README for SystemLink Work Items Data Source plugin
shivanshu-ni Aug 20, 2026
15cdb5f
feat(work-items): add initial README for SystemLink Work Items dataso…
shivanshu-ni Aug 24, 2026
29587bd
refactor(work-items): simplify WorkItemsDataSource and update query e…
shivanshu-ni Aug 24, 2026
10b0041
Merge branch 'users/shivanshu/feat/workitem-initial' of https://githu…
shivanshu-ni Aug 24, 2026
51cf0bf
refactor(work-items): remove unused import from WorkItemsDataSource
shivanshu-ni Aug 24, 2026
32294b0
refactor(work-items): extract query editor labels into constants file
shivanshu-ni Aug 27, 2026
20bbb26
Merge branch 'main' of https://github.com/ni/systemlink-grafana-plugi…
shivanshu-ni Aug 28, 2026
7c79ab4
refactor(tests): consolidate imports in WorkItemsQueryEditor test file
shivanshu-ni Aug 28, 2026
91938ef
feat(work-items): add properties selection to WorkItemsQueryEditor an…
shivanshu-ni Aug 28, 2026
1b41eb9
fix(work-items): disambiguate Properties label lookup in query editor…
shivanshu-ni Aug 28, 2026
e7494ab
Add work-items acceptance test placeholder
Copilot Aug 28, 2026
7c05a1a
fix(work-items): address review feedback
shivanshu-ni Aug 29, 2026
f8c782e
fix(work-items): enhance WorkItemsQueryEditor with validation and que…
shivanshu-ni Aug 31, 2026
713f5e7
fix(work-items): Group order by and decending in one group
shivanshu-ni Sep 1, 2026
9f11b72
fix(work-items): replace hardcoded take value with DEFAULT_TAKE const…
shivanshu-ni Sep 1, 2026
d05b387
feat(work-items): update default query properties (#738)
shivanshu-ni Sep 2, 2026
d0afbcb
fix(work-items): streamline WorkItemsQueryEditor layout and remove un…
shivanshu-ni Sep 2, 2026
785eea3
Merge branch 'users/shivanshu/feat/workitem-query-editor' of https://…
shivanshu-ni Sep 2, 2026
11f8c61
fix(tests): adjust offsetHeight mock and enhance properties output va…
shivanshu-ni Sep 2, 2026
7023866
fix(tests): update userEvent usage in WorkItemsQueryEditor tests for …
shivanshu-ni Sep 2, 2026
45adbd1
fix(tests): remove unnecessary typing in WorkItemsQueryEditor test fo…
shivanshu-ni Sep 2, 2026
3c0f23d
fix(tests): simplify user interactions in WorkItemsQueryEditor tests …
shivanshu-ni Sep 2, 2026
f6c46f4
fix(tests): update WorkItemsQueryEditor tests to use new combobox and…
shivanshu-ni Sep 2, 2026
85d20e8
fix(tests): update WorkItemsQueryEditor and WorkItemsDataSource tests…
shivanshu-ni Sep 2, 2026
f3b6a9d
fix(tests): enhance WorkItemsQueryEditor and WorkItemsDataSource test…
richie-ni Sep 2, 2026
c172b1a
fix: update query handling in WorkItemsQueryEditor for improved valid…
shivanshu-ni Sep 2, 2026
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
64 changes: 64 additions & 0 deletions src/datasources/work-items/WorkItemsDataSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { WorkItemsDataSource } from './WorkItemsDataSource';
import { setupDataSource } from 'test/fixtures';
import { OrderByOptions, OutputType, WorkItemTypeOptions } from './types';

describe('WorkItemsDataSource', () => {
it('applies expected default query values', () => {
const [ds] = setupDataSource(WorkItemsDataSource);

const query = ds.prepareQuery({ refId: 'A' });

expect(query.outputType).toBe(OutputType.Properties);
expect(query.types).toEqual([WorkItemTypeOptions.All]);
expect(query.orderBy).toBe(OrderByOptions.UPDATED_AT);
expect(query.descending).toBe(true);
expect(query.take).toBe(1000);
});

it('normalizes invalid types and preserves explicit take values', () => {
const [ds] = setupDataSource(WorkItemsDataSource);
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated

const query = ds.prepareQuery({
refId: 'A',
types: [],
take: 12000,
});

Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
expect(query.types).toEqual([WorkItemTypeOptions.All]);
expect(query.take).toBe(12000);
});

it('defaults take when it is negative or not finite', () => {
const [ds] = setupDataSource(WorkItemsDataSource);

const negativeTakeQuery = ds.prepareQuery({ refId: 'A', take: -1 });
const nanTakeQuery = ds.prepareQuery({ refId: 'A', take: Number.NaN });

expect(negativeTakeQuery.take).toBe(1000);
expect(nanTakeQuery.take).toBe(1000);
});

it('returns a placeholder frame', async () => {
const [ds] = setupDataSource(WorkItemsDataSource);

const result = await ds.runQuery({ refId: 'A' }, { scopedVars: {} } as any);
expect(result.fields[0].name).toBe('message');
});

it('tests datasource connection against the work-items service endpoint', async () => {
const [ds] = setupDataSource(WorkItemsDataSource);
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
const postSpy = jest.spyOn(ds, 'post').mockResolvedValue({} as any);

const result = await ds.testDatasource();

expect(postSpy).toHaveBeenCalledWith('/niworkitem/v1/query-workitems', { take: 1 });
expect(result.status).toBe('success');
});

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

await expect(ds.testDatasource()).rejects.toThrow('Failed');
});
});
90 changes: 90 additions & 0 deletions src/datasources/work-items/WorkItemsDataSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
DataFrameDTO,
DataQueryRequest,
DataSourceInstanceSettings,
FieldType,
MetricFindValue,
TestDataSourceResponse,
} from '@grafana/data';
import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
import { DataSourceBase } from 'core/DataSourceBase';
import {
OrderByOptions,
OutputType,
WorkItemsDataSourceOptions,
WorkItemsQuery,
WorkItemTypeOptions,
} from './types';

export class WorkItemsDataSource extends DataSourceBase<WorkItemsQuery, WorkItemsDataSourceOptions> {
constructor(
readonly instanceSettings: DataSourceInstanceSettings<WorkItemsDataSourceOptions>,
readonly backendSrv: BackendSrv = getBackendSrv(),
readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings, backendSrv, templateSrv);
}

baseUrl = `${this.instanceSettings.url}/niworkitem/v1`;
queryWorkItemsUrl = `${this.baseUrl}/query-workitems`;

defaultQuery = {
outputType: OutputType.Properties,
types: [WorkItemTypeOptions.All],
orderBy: OrderByOptions.UPDATED_AT,
descending: true,
take: 1000,
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
};

public prepareQuery(query: WorkItemsQuery): WorkItemsQuery {
const prepared = super.prepareQuery(query);
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated

return {
...prepared,
outputType: prepared.outputType ?? this.defaultQuery.outputType,
orderBy: prepared.orderBy ?? this.defaultQuery.orderBy,
descending: prepared.descending ?? this.defaultQuery.descending,
types: this.normalizeTypes(prepared.types),
take: this.normalizeTake(prepared.take),
};
}

isTypesValid(types?: WorkItemTypeOptions[]): boolean {
return Boolean(types && types.length > 0);
}

normalizeTypes(types?: WorkItemTypeOptions[]): WorkItemTypeOptions[] {
return this.isTypesValid(types) ? [...types!] : [...this.defaultQuery.types];
}

normalizeTake(take?: number): number {
return Number.isFinite(take) && (take as number) >= 0 ? (take as number) : this.defaultQuery.take;
}

async runQuery(query: WorkItemsQuery, _options: DataQueryRequest<WorkItemsQuery>): Promise<DataFrameDTO> {
return {
Comment thread
shivanshu-ni marked this conversation as resolved.
refId: query.refId,
name: query.refId,
fields: [
{
name: 'message',
type: FieldType.string,
values: ['Work Items datasource query implementation will be added in follow-up stories.'],
},
],
};
}

shouldRunQuery(query: WorkItemsQuery): boolean {
return !query.hide;
}

async testDatasource(): Promise<TestDataSourceResponse> {
await this.post(this.queryWorkItemsUrl, { take: 1 });
return { status: 'success', message: 'Data source connected and authentication successful!' };
}

async metricFindQuery(): Promise<MetricFindValue[]> {
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { fireEvent } from '@testing-library/react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupRenderer } from 'test/fixtures';
import { takeErrorMessages } from '../constants/QueryEditor.constants';
import { WorkItemsDataSource } from '../WorkItemsDataSource';
import { OutputType } from '../types';
import { WorkItemsQueryEditor } from './WorkItemsQueryEditor';

describe('WorkItemsQueryEditor', () => {
it('renders controls', () => {
const render = setupRenderer(WorkItemsQueryEditor, WorkItemsDataSource);

render({});

expect(screen.getByRole('radio', { name: OutputType.Properties })).toBeTruthy();
expect(screen.getByRole('radio', { name: OutputType.TotalCount })).toBeTruthy();
expect(screen.getByText('Type')).toBeTruthy();
expect(screen.getByText('OrderBy')).toBeTruthy();
expect(screen.getByText('Descending')).toBeTruthy();
expect(screen.getByText('Take')).toBeTruthy();
});

it('hides properties-only controls for total count output', async () => {
const render = setupRenderer(WorkItemsQueryEditor, WorkItemsDataSource);

render({});
await userEvent.click(screen.getByRole('radio', { name: OutputType.TotalCount }));

expect(screen.queryByText('OrderBy')).toBeNull();
expect(screen.queryByText('Descending')).toBeNull();
expect(screen.queryByText('Take')).toBeNull();
});

it('shows take validation error and suppresses query execution for invalid take input', () => {
const render = setupRenderer(WorkItemsQueryEditor, WorkItemsDataSource);

const [onChange, onRunQuery] = render({});
const takeInput = screen.getByRole('spinbutton');

fireEvent.change(takeInput, { target: { value: '-5' } });
fireEvent.blur(takeInput);

expect(screen.getByText(takeErrorMessages.greaterOrEqualToZero)).toBeTruthy();
expect(onChange).not.toHaveBeenCalled();
expect(onRunQuery).not.toHaveBeenCalled();
});
});
135 changes: 135 additions & 0 deletions src/datasources/work-items/components/WorkItemsQueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { useCallback, useMemo, useState } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { AutoSizeInput, Combobox, ComboboxOption, InlineSwitch, MultiCombobox, RadioButtonGroup, Stack } from '@grafana/ui';
import { InlineField } from 'core/components/InlineField';
import { validateNumericInput } from 'core/utils';
import { WorkItemsDataSource } from '../WorkItemsDataSource';
import { TAKE_LIMIT, takeErrorMessages, tooltips } from '../constants/QueryEditor.constants';
import {
OrderBy,
OrderByOptions,
OutputType,
WorkItemsQuery,
WorkItemTypeOptions,
WorkItemTypes,
} from '../types';

type Props = QueryEditorProps<WorkItemsDataSource, WorkItemsQuery>;

export function WorkItemsQueryEditor({ query, onChange, onRunQuery, datasource }: Props) {
query = datasource.prepareQuery(query);
const [takeInvalidMessage, setTakeInvalidMessage] = useState<string>('');
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated

const selectedTypes = useMemo(
() => (query.types ?? []).map((type) => ({ label: WorkItemTypes.find((option) => option.value === type)?.label ?? type, value: type })),
[query.types]
);

const typeOptions = useMemo(
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
() => WorkItemTypes.map((option) => ({ label: option.label, value: option.value })),
[]
);

const handleQueryChange = useCallback((newQuery: WorkItemsQuery, runQuery = true): void => {
onChange(newQuery);
if (runQuery) {
onRunQuery();
}
}, [onChange, onRunQuery]);

const onOutputTypeChange = (value: OutputType) => {
handleQueryChange({ ...query, outputType: value });
};

const onTypesChange = (items: Array<ComboboxOption<WorkItemTypeOptions>>) => {
const types = items.map((item) => item.value).filter(Boolean) as WorkItemTypeOptions[];
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
handleQueryChange({ ...query, types: datasource.normalizeTypes(types) });
};

const onOrderByChange = (item: SelectableValue<OrderByOptions>) => {
handleQueryChange({ ...query, orderBy: item.value as OrderByOptions });
};

const onDescendingChange = (isDescendingChecked: boolean) => {
handleQueryChange({ ...query, descending: isDescendingChecked });
};

const onTakeChange = (event: React.FormEvent<HTMLInputElement>) => {
const value = parseInt((event.target as HTMLInputElement).value, 10);
if (Number.isNaN(value) || value < 0) {
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
setTakeInvalidMessage(takeErrorMessages.greaterOrEqualToZero);
return;
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
}

if (value > TAKE_LIMIT) {
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
setTakeInvalidMessage(takeErrorMessages.lessOrEqualToTenThousand);
return;
}

setTakeInvalidMessage('');
handleQueryChange({ ...query, take: value });
};

return (
<Stack direction='column' gap={0}>
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
<InlineField label="Output" labelWidth={25} tooltip={tooltips.outputType}>
<RadioButtonGroup
options={Object.values(OutputType).map((value) => ({ label: value, value })) as SelectableValue[]}
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
onChange={onOutputTypeChange}
value={query.outputType}
/>
</InlineField>

Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
<InlineField label="Type" labelWidth={25} tooltip={tooltips.types}>
<MultiCombobox
placeholder="Select work item types"
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
options={typeOptions}
value={selectedTypes}
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
onChange={onTypesChange}
width='auto'
minWidth={65}
maxWidth={65}
/>
</InlineField>

{query.outputType === OutputType.Properties && (
<>
<InlineField label="OrderBy" labelWidth={25} tooltip={tooltips.orderBy}>
<Combobox
options={OrderBy}
placeholder="Select a field to set query order"
onChange={onOrderByChange}
value={query.orderBy}
width={26}
/>
</InlineField>

<InlineField label="Descending" labelWidth={25} tooltip={tooltips.descending}>
<InlineSwitch
onChange={event => onDescendingChange(event.currentTarget.checked)}
value={query.descending}
/>
</InlineField>

<InlineField
label="Take"
labelWidth={25}
Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
tooltip={tooltips.take}
invalid={!!takeInvalidMessage}
error={takeInvalidMessage}
>
<AutoSizeInput
minWidth={26}
maxWidth={26}
type='number'
defaultValue={query.take}
onBlur={onTakeChange}
placeholder="Enter record count"
onKeyDown={(event) => { validateNumericInput(event); }}
/>
</InlineField>
</>
)}
</Stack>
);
}
14 changes: 14 additions & 0 deletions src/datasources/work-items/constants/QueryEditor.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const TAKE_LIMIT = 10000;

Comment thread
shivanshu-ni marked this conversation as resolved.
Outdated
export const takeErrorMessages = {
greaterOrEqualToZero: 'Enter a value greater than or equal to 0',
lessOrEqualToTenThousand: 'Enter a value less than or equal to 10,000',
};

export const tooltips = {
outputType: 'Select whether to return work item properties or only total count.',
types: 'Choose one or more work item types to query.',
orderBy: 'Select which property to sort by for properties output.',
descending: 'Toggle descending sort order for properties output.',
take: 'Set the maximum number of work items to return. Maximum is 10,000.',
};
11 changes: 11 additions & 0 deletions src/datasources/work-items/img/logo-ni.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions src/datasources/work-items/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DataSourcePlugin } from '@grafana/data';
import { HttpConfigEditor } from 'core/components/HttpConfigEditor';
import { WorkItemsDataSource } from './WorkItemsDataSource';
import { WorkItemsQueryEditor } from './components/WorkItemsQueryEditor';

export const plugin = new DataSourcePlugin(WorkItemsDataSource)
.setConfigEditor(HttpConfigEditor)
.setQueryEditor(WorkItemsQueryEditor);
15 changes: 15 additions & 0 deletions src/datasources/work-items/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"type": "datasource",
"name": "SystemLink Work Items",
"id": "ni-slworkitems-datasource",
"metrics": true,
"info": {
"author": {
"name": "NI"
},
"logos": {
"small": "img/logo-ni.svg",
"large": "img/logo-ni.svg"
}
}
}
Loading