From 81f00948b726e13dde594b1523219cc3e25b1215 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Tue, 26 May 2026 20:34:05 +0530 Subject: [PATCH 1/8] Include inherited ServiceNow table fields in field lookup Walk the sys_db_object hierarchy and merge sys_dictionary entries so child tables like incident expose parent table fields in dropdowns. Co-authored-by: Cursor --- .../src/lib/servicenow/get-table-fields.ts | 134 ++++++++++++++++-- 1 file changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/openops/src/lib/servicenow/get-table-fields.ts b/packages/openops/src/lib/servicenow/get-table-fields.ts index e7a828d77c..9b7b21af3c 100644 --- a/packages/openops/src/lib/servicenow/get-table-fields.ts +++ b/packages/openops/src/lib/servicenow/get-table-fields.ts @@ -6,6 +6,7 @@ import { } from './auth'; export type ServiceNowTableField = { + name: string; column_label: string; element: string; internal_type?: { @@ -19,10 +20,130 @@ export type ServiceNowTableField = { reference?: string; }; +type ServiceNowDbObjectRecord = { + name: string; + 'super_class.name'?: string; + super_class?: { link?: string; value?: string; name?: string }; +}; + +async function getServiceNowParentTable( + auth: ServiceNowAuth, + tableName: string, +): Promise { + const response = await httpClient.sendRequest<{ + result: ServiceNowDbObjectRecord[]; + }>({ + method: HttpMethod.GET, + url: buildServiceNowApiUrl(auth, 'sys_db_object'), + headers: { + ...generateAuthHeader(auth), + Accept: 'application/json', + }, + queryParams: { + sysparm_query: `name=${tableName}`, + sysparm_fields: 'name,super_class.name,super_class', + sysparm_limit: '1', + }, + }); + + const record = response.body.result?.[0]; + if (!record) { + return undefined; + } + + if (record['super_class.name']) { + return record['super_class.name']; + } + + const superClass = record.super_class; + if (superClass?.name) { + return superClass.name; + } + + if (superClass?.value) { + const parentResponse = await httpClient.sendRequest<{ + result: Array<{ name: string }>; + }>({ + method: HttpMethod.GET, + url: buildServiceNowApiUrl(auth, 'sys_db_object'), + headers: { + ...generateAuthHeader(auth), + Accept: 'application/json', + }, + queryParams: { + sysparm_query: `sys_id=${superClass.value}`, + sysparm_fields: 'name', + sysparm_limit: '1', + }, + }); + + return parentResponse.body.result?.[0]?.name; + } + + return undefined; +} + +async function getServiceNowTableHierarchy( + auth: ServiceNowAuth, + tableName: string, +): Promise { + const tables: string[] = []; + let current: string | undefined = tableName; + + while (current) { + if (tables.includes(current)) { + break; + } + + tables.push(current); + current = await getServiceNowParentTable(auth, current); + } + + return tables; +} + +function mergeDictionaryFields( + fields: ServiceNowTableField[], + tableNames: string[], +): ServiceNowTableField[] { + const byElement = new Map(); + + for (const field of fields) { + if (!field.element?.trim()) { + continue; + } + + if (field.internal_type?.value === 'collection') { + continue; + } + + const tableIndex = tableNames.indexOf(field.name); + if (tableIndex === -1) { + continue; + } + + const existing = byElement.get(field.element); + if (!existing) { + byElement.set(field.element, field); + continue; + } + + const existingIndex = tableNames.indexOf(existing.name); + if (tableIndex < existingIndex) { + byElement.set(field.element, field); + } + } + + return [...byElement.values()]; +} + export async function getServiceNowTableFields( auth: ServiceNowAuth, tableName: string, ): Promise { + const tableNames = await getServiceNowTableHierarchy(auth, tableName); + const dictionaryQuery = tableNames.map((name) => `name=${name}`).join('^OR'); + const response = await httpClient.sendRequest<{ result: ServiceNowTableField[]; }>({ @@ -33,16 +154,13 @@ export async function getServiceNowTableFields( Accept: 'application/json', }, queryParams: { - sysparm_query: `name=${tableName}`, + sysparm_query: dictionaryQuery, sysparm_fields: - 'column_label,element,internal_type,max_length,mandatory,read_only,primary,choice,reference', - sysparm_limit: '1000', + 'name,column_label,element,internal_type,max_length,mandatory,read_only,primary,choice,reference', + sysparm_limit: '5000', }, }); - return ( - response.body.result?.filter( - (field) => field.element && field.element.trim() !== '', - ) || [] - ); + const fields = response.body.result ?? []; + return mergeDictionaryFields(fields, tableNames); } From 6c82000623605fe273d587fa5b57a6c1c6af72c5 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 27 May 2026 12:34:28 +0530 Subject: [PATCH 2/8] Fix copilot comments --- packages/openops/src/lib/servicenow/get-table-fields.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/openops/src/lib/servicenow/get-table-fields.ts b/packages/openops/src/lib/servicenow/get-table-fields.ts index 9b7b21af3c..9059802e05 100644 --- a/packages/openops/src/lib/servicenow/get-table-fields.ts +++ b/packages/openops/src/lib/servicenow/get-table-fields.ts @@ -6,7 +6,7 @@ import { } from './auth'; export type ServiceNowTableField = { - name: string; + name?: string; column_label: string; element: string; internal_type?: { @@ -113,7 +113,7 @@ function mergeDictionaryFields( continue; } - if (field.internal_type?.value === 'collection') { + if (!field.name) { continue; } @@ -128,6 +128,10 @@ function mergeDictionaryFields( continue; } + if (!existing.name) { + continue; + } + const existingIndex = tableNames.indexOf(existing.name); if (tableIndex < existingIndex) { byElement.set(field.element, field); From ee618de424021d72c18593152f0eaede68e896fa Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 27 May 2026 15:07:42 +0530 Subject: [PATCH 3/8] Revert the sysparm limit --- packages/openops/src/lib/servicenow/get-table-fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openops/src/lib/servicenow/get-table-fields.ts b/packages/openops/src/lib/servicenow/get-table-fields.ts index 9059802e05..49cfecf588 100644 --- a/packages/openops/src/lib/servicenow/get-table-fields.ts +++ b/packages/openops/src/lib/servicenow/get-table-fields.ts @@ -161,7 +161,7 @@ export async function getServiceNowTableFields( sysparm_query: dictionaryQuery, sysparm_fields: 'name,column_label,element,internal_type,max_length,mandatory,read_only,primary,choice,reference', - sysparm_limit: '5000', + sysparm_limit: '1000', }, }); From 80655f95ab6d278a1de09e532268a3d062a10bb6 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 29 May 2026 17:42:44 +0530 Subject: [PATCH 4/8] Add tests and improve UX --- .../src/actions/create-fields-properties.ts | 2 + .../src/actions/create-filters-properties.ts | 2 + .../src/lib/fields-dropdown-property.ts | 3 +- .../src/lib/table-dropdown-property.ts | 4 +- .../src/lib/servicenow/get-table-fields.ts | 9 +- .../test/servicenow-get-table-fields.test.ts | 206 ++++++++++++++++++ 6 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 packages/openops/test/servicenow-get-table-fields.test.ts diff --git a/packages/blocks/servicenow/src/actions/create-fields-properties.ts b/packages/blocks/servicenow/src/actions/create-fields-properties.ts index d01cf1c363..f8aff186b8 100644 --- a/packages/blocks/servicenow/src/actions/create-fields-properties.ts +++ b/packages/blocks/servicenow/src/actions/create-fields-properties.ts @@ -39,6 +39,8 @@ async function createFieldsProperties( properties: { fieldName: Property.StaticDropdown({ displayName: 'Field name', + description: + 'Includes inherited fields from parent tables if your account has read access to sys_db_object.', required: true, options: { options: mapFieldsToOptions(writableFields), diff --git a/packages/blocks/servicenow/src/actions/create-filters-properties.ts b/packages/blocks/servicenow/src/actions/create-filters-properties.ts index 0d6b56c5b5..5d908c3b38 100644 --- a/packages/blocks/servicenow/src/actions/create-filters-properties.ts +++ b/packages/blocks/servicenow/src/actions/create-filters-properties.ts @@ -33,6 +33,8 @@ export async function createFiltersProperties( }) : Property.StaticDropdown({ displayName: 'Field name', + description: + 'Includes inherited fields from parent tables if your account has read access to sys_db_object.', required: true, options: { options: tableFields.map((f) => ({ diff --git a/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts b/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts index cc6a61545f..9f55061a04 100644 --- a/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts +++ b/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts @@ -35,7 +35,8 @@ export function servicenowFieldsDropdownProperty() { props['selected'] = Property.StaticMultiSelectDropdown({ displayName: 'Fields', - description: 'Select the fields to return.', + description: + 'Select the fields to return. Includes inherited fields from parent tables if your account has read access to sys_db_object.', required: true, options: { disabled: false, diff --git a/packages/blocks/servicenow/src/lib/table-dropdown-property.ts b/packages/blocks/servicenow/src/lib/table-dropdown-property.ts index 172d022de3..fa921cbf91 100644 --- a/packages/blocks/servicenow/src/lib/table-dropdown-property.ts +++ b/packages/blocks/servicenow/src/lib/table-dropdown-property.ts @@ -20,9 +20,7 @@ export function servicenowTableDropdownProperty(): DropdownProperty< } try { - const tables = await getServiceNowTables(auth as ServiceNowAuth, { - query: 'nameSTARTSWITHx_', - }); + const tables = await getServiceNowTables(auth as ServiceNowAuth); if (tables.length === 0) { return { diff --git a/packages/openops/src/lib/servicenow/get-table-fields.ts b/packages/openops/src/lib/servicenow/get-table-fields.ts index 49cfecf588..40528bdf25 100644 --- a/packages/openops/src/lib/servicenow/get-table-fields.ts +++ b/packages/openops/src/lib/servicenow/get-table-fields.ts @@ -145,7 +145,14 @@ export async function getServiceNowTableFields( auth: ServiceNowAuth, tableName: string, ): Promise { - const tableNames = await getServiceNowTableHierarchy(auth, tableName); + let tableNames: string[]; + + try { + tableNames = await getServiceNowTableHierarchy(auth, tableName); + } catch (error) { + tableNames = [tableName]; + } + const dictionaryQuery = tableNames.map((name) => `name=${name}`).join('^OR'); const response = await httpClient.sendRequest<{ diff --git a/packages/openops/test/servicenow-get-table-fields.test.ts b/packages/openops/test/servicenow-get-table-fields.test.ts new file mode 100644 index 0000000000..7f7d587c67 --- /dev/null +++ b/packages/openops/test/servicenow-get-table-fields.test.ts @@ -0,0 +1,206 @@ +import { httpClient } from '@openops/blocks-common'; +import { ServiceNowAuth } from '../src/lib/servicenow/auth'; +import { + getServiceNowTableFields, + ServiceNowTableField, +} from '../src/lib/servicenow/get-table-fields'; + +jest.mock('@openops/blocks-common'); + +const mockHttpClient = httpClient as jest.Mocked; + +describe('getServiceNowTableFields', () => { + const mockAuth: ServiceNowAuth = { + username: 'testuser', + password: 'testpass', + instanceName: 'dev12345', + }; + + const mockTableHierarchy = (tableName: string, parentName?: string): void => { + mockHttpClient.sendRequest.mockResolvedValueOnce({ + body: { + result: [ + { + name: tableName, + 'super_class.name': parentName, + }, + ], + }, + } as any); + }; + + const mockTableFields = (fields: ServiceNowTableField[]): void => { + mockHttpClient.sendRequest.mockResolvedValueOnce({ + body: { + result: fields, + }, + } as any); + }; + + const createField = ( + tableName: string, + element: string, + label: string, + type = 'string', + ): ServiceNowTableField => ({ + name: tableName, + element, + column_label: label, + internal_type: { value: type }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('with table hierarchy permissions', () => { + it('should return fields from both parent and child tables', async () => { + mockTableHierarchy('incident', 'task'); + mockTableHierarchy('task', undefined); + + mockTableFields([ + createField('incident', 'impact', 'Impact', 'integer'), + createField('task', 'number', 'Number', 'string'), + createField('task', 'short_description', 'Short Description', 'string'), + ]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ element: 'impact', name: 'incident' }), + expect.objectContaining({ element: 'number', name: 'task' }), + expect.objectContaining({ + element: 'short_description', + name: 'task', + }), + ]), + ); + expect(mockHttpClient.sendRequest).toHaveBeenCalledTimes(3); + }); + + it('should deduplicate fields, prioritizing child table definitions', async () => { + mockTableHierarchy('incident', 'task'); + mockTableHierarchy('task', undefined); + + mockTableFields([ + createField('incident', 'state', 'Incident State', 'integer'), + createField('task', 'state', 'Task State', 'integer'), + ]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + element: 'state', + name: 'incident', + column_label: 'Incident State', + }), + ); + }); + + it('should filter out fields with empty elements', async () => { + mockTableHierarchy('incident', undefined); + + mockTableFields([ + createField('incident', 'valid_field', 'Valid Field'), + { ...createField('incident', '', 'Empty Element') }, + { ...createField('incident', ' ', 'Whitespace Element') }, + ]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toHaveLength(1); + expect(result[0].element).toBe('valid_field'); + }); + + it('should handle fields without name property', async () => { + mockTableHierarchy('incident', undefined); + + mockTableFields([ + createField('incident', 'field_with_name', 'Field With Name'), + { + element: 'field_without_name', + column_label: 'Field Without Name', + } as ServiceNowTableField, + ]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toHaveLength(1); + expect(result[0].element).toBe('field_with_name'); + }); + }); + + describe('without table hierarchy permissions (graceful fallback)', () => { + it('should fall back to querying only the requested table when sys_db_object fails', async () => { + mockHttpClient.sendRequest.mockRejectedValueOnce( + new Error('Insufficient permissions'), + ); + + mockTableFields([ + createField('incident', 'impact', 'Impact', 'integer'), + createField('incident', 'urgency', 'Urgency', 'integer'), + ]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ element: 'impact' }), + expect.objectContaining({ element: 'urgency' }), + ]), + ); + expect(mockHttpClient.sendRequest).toHaveBeenCalledTimes(2); + }); + }); + + it('should handle empty results from sys_dictionary', async () => { + mockTableHierarchy('incident', undefined); + mockTableFields([]); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toEqual([]); + }); + + it('should handle null result from sys_dictionary', async () => { + mockTableHierarchy('incident', undefined); + + mockHttpClient.sendRequest.mockResolvedValueOnce({ + body: { + result: null, + }, + } as any); + + const result = await getServiceNowTableFields(mockAuth, 'incident'); + + expect(result).toEqual([]); + }); + + it('should handle deep table hierarchies', async () => { + mockTableHierarchy('custom_incident', 'incident'); + mockTableHierarchy('incident', 'task'); + mockTableHierarchy('task', undefined); + + mockTableFields([ + createField('custom_incident', 'custom_field', 'Custom Field'), + createField('incident', 'impact', 'Impact'), + createField('task', 'number', 'Number'), + ]); + + const result = await getServiceNowTableFields(mockAuth, 'custom_incident'); + + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ element: 'custom_field' }), + expect.objectContaining({ element: 'impact' }), + expect.objectContaining({ element: 'number' }), + ]), + ); + }); +}); From c859cbd6ee3a7ac932e8dcc646e0d308849199a8 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 29 May 2026 18:09:52 +0530 Subject: [PATCH 5/8] Fix sonar --- .../test/servicenow-get-table-fields.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/openops/test/servicenow-get-table-fields.test.ts b/packages/openops/test/servicenow-get-table-fields.test.ts index 7f7d587c67..1a3ad73668 100644 --- a/packages/openops/test/servicenow-get-table-fields.test.ts +++ b/packages/openops/test/servicenow-get-table-fields.test.ts @@ -56,7 +56,7 @@ describe('getServiceNowTableFields', () => { describe('with table hierarchy permissions', () => { it('should return fields from both parent and child tables', async () => { mockTableHierarchy('incident', 'task'); - mockTableHierarchy('task', undefined); + mockTableHierarchy('task'); mockTableFields([ createField('incident', 'impact', 'Impact', 'integer'), @@ -82,7 +82,7 @@ describe('getServiceNowTableFields', () => { it('should deduplicate fields, prioritizing child table definitions', async () => { mockTableHierarchy('incident', 'task'); - mockTableHierarchy('task', undefined); + mockTableHierarchy('task'); mockTableFields([ createField('incident', 'state', 'Incident State', 'integer'), @@ -102,7 +102,7 @@ describe('getServiceNowTableFields', () => { }); it('should filter out fields with empty elements', async () => { - mockTableHierarchy('incident', undefined); + mockTableHierarchy('incident'); mockTableFields([ createField('incident', 'valid_field', 'Valid Field'), @@ -117,14 +117,15 @@ describe('getServiceNowTableFields', () => { }); it('should handle fields without name property', async () => { - mockTableHierarchy('incident', undefined); + mockTableHierarchy('incident'); mockTableFields([ createField('incident', 'field_with_name', 'Field With Name'), { element: 'field_without_name', column_label: 'Field Without Name', - } as ServiceNowTableField, + internal_type: { value: 'string' }, + }, ]); const result = await getServiceNowTableFields(mockAuth, 'incident'); @@ -159,7 +160,7 @@ describe('getServiceNowTableFields', () => { }); it('should handle empty results from sys_dictionary', async () => { - mockTableHierarchy('incident', undefined); + mockTableHierarchy('incident'); mockTableFields([]); const result = await getServiceNowTableFields(mockAuth, 'incident'); @@ -168,7 +169,7 @@ describe('getServiceNowTableFields', () => { }); it('should handle null result from sys_dictionary', async () => { - mockTableHierarchy('incident', undefined); + mockTableHierarchy('incident'); mockHttpClient.sendRequest.mockResolvedValueOnce({ body: { @@ -184,7 +185,7 @@ describe('getServiceNowTableFields', () => { it('should handle deep table hierarchies', async () => { mockTableHierarchy('custom_incident', 'incident'); mockTableHierarchy('incident', 'task'); - mockTableHierarchy('task', undefined); + mockTableHierarchy('task'); mockTableFields([ createField('custom_incident', 'custom_field', 'Custom Field'), From df77e4a8f792cf392952e91dc9ec5eb7036bf758 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 29 May 2026 18:13:17 +0530 Subject: [PATCH 6/8] Improve note --- .../blocks/servicenow/src/actions/create-fields-properties.ts | 2 +- .../blocks/servicenow/src/actions/create-filters-properties.ts | 2 +- packages/blocks/servicenow/src/lib/fields-dropdown-property.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/blocks/servicenow/src/actions/create-fields-properties.ts b/packages/blocks/servicenow/src/actions/create-fields-properties.ts index f8aff186b8..225be41c52 100644 --- a/packages/blocks/servicenow/src/actions/create-fields-properties.ts +++ b/packages/blocks/servicenow/src/actions/create-fields-properties.ts @@ -40,7 +40,7 @@ async function createFieldsProperties( fieldName: Property.StaticDropdown({ displayName: 'Field name', description: - 'Includes inherited fields from parent tables if your account has read access to sys_db_object.', + 'Includes inherited fields from parent tables if user has read access to sys_db_object.', required: true, options: { options: mapFieldsToOptions(writableFields), diff --git a/packages/blocks/servicenow/src/actions/create-filters-properties.ts b/packages/blocks/servicenow/src/actions/create-filters-properties.ts index 5d908c3b38..1bc73fe983 100644 --- a/packages/blocks/servicenow/src/actions/create-filters-properties.ts +++ b/packages/blocks/servicenow/src/actions/create-filters-properties.ts @@ -34,7 +34,7 @@ export async function createFiltersProperties( : Property.StaticDropdown({ displayName: 'Field name', description: - 'Includes inherited fields from parent tables if your account has read access to sys_db_object.', + 'Includes inherited fields from parent tables if user has read access to sys_db_object.', required: true, options: { options: tableFields.map((f) => ({ diff --git a/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts b/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts index 9f55061a04..bdebc10d6e 100644 --- a/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts +++ b/packages/blocks/servicenow/src/lib/fields-dropdown-property.ts @@ -36,7 +36,7 @@ export function servicenowFieldsDropdownProperty() { props['selected'] = Property.StaticMultiSelectDropdown({ displayName: 'Fields', description: - 'Select the fields to return. Includes inherited fields from parent tables if your account has read access to sys_db_object.', + 'Select the fields to return. Includes inherited fields from parent tables if user has read access to sys_db_object.', required: true, options: { disabled: false, From 99cdb2b51e21265ff8073427a3b27bb04d73b35f Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 29 May 2026 18:40:21 +0530 Subject: [PATCH 7/8] Log the fallback --- packages/openops/src/lib/servicenow/get-table-fields.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/openops/src/lib/servicenow/get-table-fields.ts b/packages/openops/src/lib/servicenow/get-table-fields.ts index 40528bdf25..f0a7b606b1 100644 --- a/packages/openops/src/lib/servicenow/get-table-fields.ts +++ b/packages/openops/src/lib/servicenow/get-table-fields.ts @@ -1,4 +1,5 @@ import { httpClient, HttpMethod } from '@openops/blocks-common'; +import { logger } from '@openops/server-shared'; import { buildServiceNowApiUrl, generateAuthHeader, @@ -150,6 +151,10 @@ export async function getServiceNowTableFields( try { tableNames = await getServiceNowTableHierarchy(auth, tableName); } catch (error) { + logger.debug( + `Unable to fetch table hierarchy for ${tableName}. Falling back to direct table query.`, + { error }, + ); tableNames = [tableName]; } From a710708788556da1888face8fba4ea2748486591 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 29 May 2026 16:25:15 +0530 Subject: [PATCH 8/8] Move ServiceNow choice values to shared and add state fields helper - Move getServiceNowChoiceValues / ServiceNowChoice from the ServiceNow block into @openops/common so it can be reused across packages. The blocks-level file is removed and its single consumer (create-field-value-property) now imports from @openops/common. - Add getServiceNowStateFields, a helper that returns the columns on a given table (including parent-table columns) that are usable as a status field (choice or integer with a layered sys_choice list). This is needed by upcoming campaign features that let users pick which ServiceNow field to read ticket state from. Stacked on top of #2312 because getServiceNowStateFields depends on the parent-hierarchy walk added there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/lib/create-field-value-property.ts | 7 +- .../lib/create-field-value-property.test.ts | 11 +- packages/openops/src/index.ts | 2 + .../src/lib/servicenow}/get-choice-values.ts | 2 +- .../src/lib/servicenow/get-state-fields.ts | 53 ++++++ .../servicenow-get-choice-values.test.ts} | 33 ++-- .../test/servicenow-get-state-fields.test.ts | 154 ++++++++++++++++++ 7 files changed, 236 insertions(+), 26 deletions(-) rename packages/{blocks/servicenow/src/lib => openops/src/lib/servicenow}/get-choice-values.ts (94%) create mode 100644 packages/openops/src/lib/servicenow/get-state-fields.ts rename packages/{blocks/servicenow/test/lib/get-choice-values.test.ts => openops/test/servicenow-get-choice-values.test.ts} (76%) create mode 100644 packages/openops/test/servicenow-get-state-fields.test.ts diff --git a/packages/blocks/servicenow/src/lib/create-field-value-property.ts b/packages/blocks/servicenow/src/lib/create-field-value-property.ts index 8116aec4b9..6e8aabb55a 100644 --- a/packages/blocks/servicenow/src/lib/create-field-value-property.ts +++ b/packages/blocks/servicenow/src/lib/create-field-value-property.ts @@ -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, diff --git a/packages/blocks/servicenow/test/lib/create-field-value-property.test.ts b/packages/blocks/servicenow/test/lib/create-field-value-property.test.ts index 52991136ed..8d5c5aa74b 100644 --- a/packages/blocks/servicenow/test/lib/create-field-value-property.test.ts +++ b/packages/blocks/servicenow/test/lib/create-field-value-property.test.ts @@ -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 = { diff --git a/packages/openops/src/index.ts b/packages/openops/src/index.ts index 9ff5c54ca3..af3a4c2614 100644 --- a/packages/openops/src/index.ts +++ b/packages/openops/src/index.ts @@ -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'; export * from './lib/servicenow/get-table-fields'; export * from './lib/servicenow/get-tables'; diff --git a/packages/blocks/servicenow/src/lib/get-choice-values.ts b/packages/openops/src/lib/servicenow/get-choice-values.ts similarity index 94% rename from packages/blocks/servicenow/src/lib/get-choice-values.ts rename to packages/openops/src/lib/servicenow/get-choice-values.ts index c150f09498..cc1ee9bc84 100644 --- a/packages/blocks/servicenow/src/lib/get-choice-values.ts +++ b/packages/openops/src/lib/servicenow/get-choice-values.ts @@ -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; diff --git a/packages/openops/src/lib/servicenow/get-state-fields.ts b/packages/openops/src/lib/servicenow/get-state-fields.ts new file mode 100644 index 0000000000..85c2dea3da --- /dev/null +++ b/packages/openops/src/lib/servicenow/get-state-fields.ts @@ -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( + auth: ServiceNowAuth, + tableName: string, +): Promise { + 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 []; + } +} diff --git a/packages/blocks/servicenow/test/lib/get-choice-values.test.ts b/packages/openops/test/servicenow-get-choice-values.test.ts similarity index 76% rename from packages/blocks/servicenow/test/lib/get-choice-values.test.ts rename to packages/openops/test/servicenow-get-choice-values.test.ts index db93373cb0..1e6b9f6682 100644 --- a/packages/blocks/servicenow/test/lib/get-choice-values.test.ts +++ b/packages/openops/test/servicenow-get-choice-values.test.ts @@ -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: { @@ -11,7 +12,7 @@ jest.mock('@openops/blocks-common', () => ({ })); describe('getServiceNowChoiceValues', () => { - const mockAuth = { + const mockAuth: ServiceNowAuth = { username: 'testuser', password: 'testpass', instanceName: 'dev12345', @@ -22,7 +23,7 @@ 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' }, @@ -30,9 +31,7 @@ describe('getServiceNowChoiceValues', () => { { label: 'Option C', value: 'c' }, ], }, - }; - - (httpClient.sendRequest as jest.Mock).mockResolvedValue(mockResponse); + }); const choices = await getServiceNowChoiceValues( mockAuth, @@ -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, @@ -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'), ); @@ -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, diff --git a/packages/openops/test/servicenow-get-state-fields.test.ts b/packages/openops/test/servicenow-get-state-fields.test.ts new file mode 100644 index 0000000000..052d168969 --- /dev/null +++ b/packages/openops/test/servicenow-get-state-fields.test.ts @@ -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 => ({ + 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([]); + }); +});