import { CallExpressionShape, ObjectShape, Plugin, type Record, unloadBuilder } from '@servicenow/sdk-build-core' import type { Element } from 'xml-js' import { CHOICE_SET_VERSION, generateChoices, getChoiceUpdateName, LEGACY_CHOICE_SET_VERSION, writeChoiceElement, writeChoiceSetElement, writeDeleteMultipleElement, DEFAULT_INACTIVE, DEFAULT_INACTIVE_ON_UPDATE, } from './choice-set-utils' const GLIDE_VARIABLE_VIRTUAL_TABLE_PREFIX = 'var__m_' export const ChoiceSetPlugin = Plugin.create({ name: 'ChoiceSetPlugin', records: { sys_choice_set: { composite: true, coalesce: ['name', 'element'], relationships: { sys_choice: { via: { name: 'name', element: 'element' }, descendant: true, }, }, getUpdateName: (record) => ({ success: true, value: getChoiceUpdateName(record), }), toFile: async (choiceSet, { config, database, descendants, transform }) => { const tableName = choiceSet.get('name').asString().getValue() const elementName = choiceSet.get('element').asString().getValue() const choices = descendants.query('sys_choice') // these are not actual tables on the platform and are used by flows/playbook to denote input/outputs // and have their own choice handling const choiceNonDelete = choices.some((r) => r.getAction() !== 'DELETE') if ( tableName.startsWith(GLIDE_VARIABLE_VIRTUAL_TABLE_PREFIX) && choiceSet.getAction() === 'DELETE' && choiceNonDelete ) { return { success: true, value: [], } } const isLegacy = config.legacyChoices === true const wrapperName = isLegacy ? 'sys_choice_set' : 'sys_choice_v2' const version = isLegacy ? LEGACY_CHOICE_SET_VERSION : CHOICE_SET_VERSION const builder = unloadBuilder() const wrapperElements: Element[] = [] builder.recordUpdateElements.push({ type: 'element', name: wrapperName, attributes: { action: 'INSERT_OR_UPDATE', field: elementName, table: tableName, version, apply_defaults: 'true', }, elements: wrapperElements, }) writeChoiceSetElement(wrapperElements, choiceSet, tableName, elementName, config) if (!isLegacy) { writeDeleteMultipleElement(wrapperElements, choices, database, tableName, elementName) } choices.forEach((choice) => { writeChoiceElement(wrapperElements, choice, tableName, elementName) }) return { success: true, value: { source: choiceSet, name: `${await transform.getUpdateName(choiceSet)}.xml`, category: 'author_elective_update', content: builder.end(), }, } }, async inspect(record, { database, diagnostics, config, self }) { if (record.getCreator()?.getName() !== self.getName()) { return } const tableName = record.get('name').asString().getValue() const element = record.get('element').asString().getValue() const scopeName = config.scope if (!tableName || !element) { return } const isGlobalScope = scopeName === 'global' const tableRecord = database.query('sys_db_object', { name: tableName })[0] const fieldRecord = database.query('sys_dictionary', { name: tableName, element: element })[0] // If the field is defined in this project (via Table or Table with augments), // the developer should use inline choices in the Table API instead of ChoiceSet if (fieldRecord) { diagnostics.error( record, `Field '${element}' is defined on '${tableName}' which is owned by this project. ` + `Use inline choices in the Table API instead of ChoiceSet.` ) return } if (!isGlobalScope && !tableRecord) { diagnostics.error( record, `ChoiceSet on table '${tableName}' is not allowed from scoped app '${scopeName}'. ` + `Only tables defined in your scope are allowed. ` + `Create a table in your scope that extends '${tableName}' and add choices to it.` ) } }, async toShape(record, { descendants, database, config }) { const tableName = record.get('name').ifString()?.getValue() const element = record.get('element').ifString()?.getValue() if (!tableName || !element) { return { success: false } } // these are not actual tables on the platform and are used by flows/playbook to denote input/outputs // and have their own choice handling if (tableName.startsWith(GLIDE_VARIABLE_VIRTUAL_TABLE_PREFIX)) { return { success: false } } // If the field is defined in this project, the Table/Column plugin handles choices // inline — skip here so we don't produce a duplicate ChoiceSet() shape. const fieldRecord = database.query('sys_dictionary', { name: tableName, element })[0] if (fieldRecord) { return { success: false } } // Query descendant sys_choice records const choiceRecords = descendants .query('sys_choice') .filter( (choice) => choice.get('name').ifString()?.getValue() === tableName && choice.get('element').ifString()?.getValue() === element ) return { success: true, value: new CallExpressionShape({ source: record, callee: 'ChoiceSet', args: [ record.transform(({ $ }) => ({ table: $.from('name'), field: $.from('element'), choices: $.val( new ObjectShape({ source: record, properties: generateChoices(choiceRecords, config.defaultLanguage), }) ), })), ], }), } }, }, sys_choice: { coalesce: ['name', 'element', 'value', 'language', 'dependent_value'], getUpdateName: (record) => ({ success: true, value: getChoiceUpdateName(record), }), async inspect(record, { diagnostics }) { if (record.getAction() !== 'DELETE') { return } const tableName = record.get('name').ifString()?.getValue() const element = record.get('element').ifString()?.getValue() if (!tableName || !element) { diagnostics.error( record, `Deleted sys_choice record is missing required 'name' or 'element' fields. ` ) } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics, compiler, config }) { if (callExpression.getCallee() !== 'ChoiceSet') { return { success: false } } const arg = callExpression.getArgument(0).asObject() const tableShape = arg.get('table') const fieldShape = arg.get('field') const tableName = tableShape.asString().getValue() const element = fieldShape.asString().getValue() if (!tableName || !element) { return { success: false } } // Create the sys_choice_set record const choiceSetRecord = await factory.createRecord({ source: callExpression, table: 'sys_choice_set', properties: { name: tableName, element, }, }) // Create sys_choice records for each choice entry const choiceRecords: Record[] = [] const choicesShape = arg.get('choices') const choiceValues: (string | number)[] = [] if (choicesShape.isDefined() && choicesShape.isObject()) { const choicesObj = choicesShape.asObject() let sequenceIdx = 0 for (const [choiceValue, choiceConfig] of choicesObj.entries()) { sequenceIdx++ choiceValues.push(choiceValue) if (choiceConfig.isString()) { // String shorthand: { value: 'Label' } const choiceRecord = await factory.createRecord({ source: choiceConfig, table: 'sys_choice', properties: { name: tableName, element: element, value: choiceValue, label: choiceConfig, sequence: sequenceIdx, inactive: DEFAULT_INACTIVE, inactive_on_update: DEFAULT_INACTIVE_ON_UPDATE, language: config.defaultLanguage, dependent_value: '', synonyms: '', hint: '', }, }) choiceRecords.push(choiceRecord) continue } const choiceObjArr = choiceConfig.isArray() ? choiceConfig.getElements() : [choiceConfig.asObject()] for (const choiceElement of choiceObjArr) { const choiceObj = choiceElement.asObject() const label = choiceObj.get('label') if (!label.isDefined() || !label.isString()) { diagnostics.error( choiceConfig, `Choice '${choiceValue}' is missing the required 'label' property.` ) } const choiceRecord = await factory.createRecord({ source: choiceConfig, table: 'sys_choice', properties: choiceObj.transform(({ $: t }) => ({ name: t.val(tableName), element: t.val(element), value: t.val(choiceValue), label: t, sequence: t, inactive: t.def(DEFAULT_INACTIVE), inactive_on_update: t.from('inactiveOnUpdate').def(DEFAULT_INACTIVE_ON_UPDATE), language: t.def(config.defaultLanguage), dependent_value: t.from('dependentValue').def(''), synonyms: t.map((s) => { if (s.isArray()) { return s .asArray() .getElements() .map((e) => e.ifString()?.getValue() ?? '') .join(',') } return '' }), hint: t.def(''), })), }) choiceRecords.push(choiceRecord) } } } if (choiceValues.length > 0) { compiler.addChoiceSetValues(tableName, element, choiceValues) } return { success: true, value: choiceSetRecord.with(...choiceRecords), } }, }, ], })