Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Property } from '@openops/blocks-framework';
import { ServiceNowAuth, ServiceNowTableField } from '@openops/common';
import { getServiceNowChoiceValues } from './get-choice-values';
import {
getServiceNowChoiceValues,
ServiceNowAuth,
ServiceNowTableField,
} from '@openops/common';

export async function createFieldValueProperty(
field: ServiceNowTableField,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { ServiceNowTableField } from '@openops/common';
import {
getServiceNowChoiceValues,
ServiceNowTableField,
} from '@openops/common';
import { createFieldValueProperty } from '../../src/lib/create-field-value-property';
import { getServiceNowChoiceValues } from '../../src/lib/get-choice-values';

jest.mock('../../src/lib/get-choice-values');
jest.mock('@openops/common', () => ({
...jest.requireActual('@openops/common'),
getServiceNowChoiceValues: jest.fn(),
}));

describe('createFieldValueProperty', () => {
const mockAuth = {
Expand Down
2 changes: 2 additions & 0 deletions packages/openops/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,7 @@ export * from './lib/microsoft/get-microsoft-graph-client';
export * from './lib/cloudability/recommendation-types';

export * from './lib/servicenow/auth';
export * from './lib/servicenow/get-choice-values';
export * from './lib/servicenow/get-state-fields';
Comment thread
ravikiranvm marked this conversation as resolved.
export * from './lib/servicenow/get-table-fields';
export * from './lib/servicenow/get-tables';
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { httpClient, HttpMethod } from '@openops/blocks-common';
import { generateAuthHeader, ServiceNowAuth } from '@openops/common';
import { logger } from '@openops/server-shared';
import { generateAuthHeader, ServiceNowAuth } from './auth';

export interface ServiceNowChoice {
label: string;
Expand Down
53 changes: 53 additions & 0 deletions packages/openops/src/lib/servicenow/get-state-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { logger } from '@openops/server-shared';
import { ServiceNowAuth } from './auth';
import { getServiceNowTableFields } from './get-table-fields';

export interface ServiceNowStateField {
element: string;
column_label: string;
internal_type: string;
}

/**
* Returns the columns on the given table (including parent-table columns)
* that are usable as a status field — i.e. those whose values come from a
* fixed choice list, so we can show them in a dropdown and reliably map
* polled values back to OpenOps statuses.
*
* Picks columns whose `internal_type` is `choice`, or whose sys_dictionary
* `choice` flag is `'1'` (dropdown) or `'3'` (dropdown without --None--).
* `'2'` (suggestion) is excluded because it allows free-text values.
*/
export async function getServiceNowStateFields(
Copy link
Copy Markdown
Contributor Author

@ravikiranvm ravikiranvm Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this new function to let user pick a column in the table which will be used for mapping OpenOps opportunity state with ticket state in ServiceNow.

Screenshot 2026-06-01 at 2 53 51 PM

auth: ServiceNowAuth,
tableName: string,
): Promise<ServiceNowStateField[]> {
try {
const fields = await getServiceNowTableFields(auth, tableName);

return fields
.filter((f) => {
if (!f.element?.trim()) {
return false;
}
const internalType =
typeof f.internal_type === 'string'
? f.internal_type
: f.internal_type?.value ?? '';
return (
internalType === 'choice' || f.choice === '1' || f.choice === '3'
);
})
.map((f) => ({
element: f.element,
column_label: f.column_label || f.element,
internal_type:
typeof f.internal_type === 'string'
? f.internal_type
: f.internal_type?.value ?? '',
}));
} catch (error) {
logger.warn('Error fetching ServiceNow state fields', { error });
return [];
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { httpClient, HttpMethod } from '@openops/blocks-common';
import { getServiceNowChoiceValues } from '../../src/lib/get-choice-values';
import { ServiceNowAuth } from '../src/lib/servicenow/auth';
import { getServiceNowChoiceValues } from '../src/lib/servicenow/get-choice-values';

jest.mock('@openops/blocks-common', () => ({
httpClient: {
Expand All @@ -11,7 +12,7 @@ jest.mock('@openops/blocks-common', () => ({
}));

describe('getServiceNowChoiceValues', () => {
const mockAuth = {
const mockAuth: ServiceNowAuth = {
username: 'testuser',
password: 'testpass',
instanceName: 'dev12345',
Expand All @@ -22,17 +23,15 @@ describe('getServiceNowChoiceValues', () => {
});

test('should fetch choice values for a field', async () => {
const mockResponse = {
(httpClient.sendRequest as jest.Mock).mockResolvedValue({
body: {
result: [
{ label: 'Option A', value: 'a' },
{ label: 'Option B', value: 'b' },
{ label: 'Option C', value: 'c' },
],
},
};

(httpClient.sendRequest as jest.Mock).mockResolvedValue(mockResponse);
});

const choices = await getServiceNowChoiceValues(
mockAuth,
Expand Down Expand Up @@ -62,17 +61,15 @@ describe('getServiceNowChoiceValues', () => {
]);
});

test('should handle choices without labels', async () => {
const mockResponse = {
test('should fall back to value when label is missing', async () => {
(httpClient.sendRequest as jest.Mock).mockResolvedValue({
body: {
result: [
{ label: '', value: 'a' },
{ label: null, value: 'b' },
],
},
};

(httpClient.sendRequest as jest.Mock).mockResolvedValue(mockResponse);
});

const choices = await getServiceNowChoiceValues(
mockAuth,
Expand All @@ -86,7 +83,7 @@ describe('getServiceNowChoiceValues', () => {
]);
});

test('should return empty array on API error', async () => {
test('should return an empty array on API error', async () => {
(httpClient.sendRequest as jest.Mock).mockRejectedValue(
new Error('API Error'),
);
Expand All @@ -100,14 +97,10 @@ describe('getServiceNowChoiceValues', () => {
expect(choices).toEqual([]);
});

test('should return empty array when no choices found', async () => {
const mockResponse = {
body: {
result: [],
},
};

(httpClient.sendRequest as jest.Mock).mockResolvedValue(mockResponse);
test('should return an empty array when no choices are found', async () => {
(httpClient.sendRequest as jest.Mock).mockResolvedValue({
body: { result: [] },
});

const choices = await getServiceNowChoiceValues(
mockAuth,
Expand Down
154 changes: 154 additions & 0 deletions packages/openops/test/servicenow-get-state-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { ServiceNowAuth } from '../src/lib/servicenow/auth';
import { getServiceNowStateFields } from '../src/lib/servicenow/get-state-fields';
import {
getServiceNowTableFields,
ServiceNowTableField,
} from '../src/lib/servicenow/get-table-fields';

jest.mock('../src/lib/servicenow/get-table-fields');

const mockGetTableFields = getServiceNowTableFields as jest.Mock;

describe('getServiceNowStateFields', () => {
const mockAuth: ServiceNowAuth = {
username: 'testuser',
password: 'testpass',
instanceName: 'dev12345',
};

const field = (
overrides: Partial<ServiceNowTableField>,
): ServiceNowTableField => ({
element: 'state',
column_label: 'State',
internal_type: { value: 'integer' },
choice: '1',
...overrides,
});

beforeEach(() => {
jest.clearAllMocks();
});

test('should include columns whose internal_type is choice', async () => {
mockGetTableFields.mockResolvedValue([
field({
element: 'category',
column_label: 'Category',
internal_type: { value: 'choice' },
choice: '0',
}),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([
{
element: 'category',
column_label: 'Category',
internal_type: 'choice',
},
]);
});

test("should include columns with choice flag '1' (dropdown)", async () => {
mockGetTableFields.mockResolvedValue([
field({ element: 'state', choice: '1' }),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([
{ element: 'state', column_label: 'State', internal_type: 'integer' },
]);
});

test("should include columns with choice flag '3' (dropdown without --None--)", async () => {
mockGetTableFields.mockResolvedValue([
field({ element: 'priority', column_label: 'Priority', choice: '3' }),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([
{
element: 'priority',
column_label: 'Priority',
internal_type: 'integer',
},
]);
});

test('should exclude plain integer columns without a choice flag', async () => {
mockGetTableFields.mockResolvedValue([
field({ element: 'sys_mod_count', column_label: 'Updates', choice: '0' }),
field({
element: 'reopen_count',
column_label: 'Reopen count',
choice: undefined,
}),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([]);
});

test("should exclude columns with choice flag '2' (suggestion)", async () => {
mockGetTableFields.mockResolvedValue([
field({ element: 'category', column_label: 'Category', choice: '2' }),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([]);
});

test('should exclude columns with a blank element', async () => {
mockGetTableFields.mockResolvedValue([
field({ element: '', choice: '1' }),
field({ element: ' ', choice: '1' }),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([]);
});

test('should fall back to element when column_label is missing', async () => {
mockGetTableFields.mockResolvedValue([
field({ element: 'state', column_label: '', choice: '1' }),
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([
{ element: 'state', column_label: 'state', internal_type: 'integer' },
]);
});

test('should handle internal_type provided as a plain string', async () => {
mockGetTableFields.mockResolvedValue([
{
element: 'state',
column_label: 'State',
internal_type: 'choice' as unknown as { value: string },
choice: '0',
},
]);

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([
{ element: 'state', column_label: 'State', internal_type: 'choice' },
]);
});

test('should return an empty array when the underlying fetch throws', async () => {
mockGetTableFields.mockRejectedValue(new Error('boom'));

const result = await getServiceNowStateFields(mockAuth, 'incident');

expect(result).toEqual([]);
});
});
Loading