import { type Compiler, type Diagnostics, CallExpressionShape, Plugin, isSNScope, isGUID, type Record, type Shape, } from '@servicenow/sdk-build-core' import { validateClientSideScript } from './utils' import { NowIdShape } from './now-id-plugin' // Define table names as constants since they're not exported from the core tables const SYS_UI_POLICY = 'sys_ui_policy' const SYS_UI_POLICY_ACTION = 'sys_ui_policy_action' const SYS_UI_POLICY_RL_ACTION = 'sys_ui_policy_rl_action' const DEFAULT_SCRIPT = 'function onCondition() {\n\n}' // UI Type constant for string-to-number mapping const UiTypeMapping = { desktop: 0, 'mobile-or-service-portal': 1, all: 10 } const getUITypeFromId = (id: number): string => { const entry = Object.entries(UiTypeMapping).find(([_, v]) => v === id) if (!entry) { throw Error(`Invalid UI Type encountered: ${id}, check XML data before transforming again.`) } return entry[0] } const getUITypeId = (value: keyof typeof UiTypeMapping): number => { const entry = UiTypeMapping[value] if (entry === undefined) { throw Error(`Invalid ui_type found: ${value}`) } return entry } /** * Converts ServiceNow string format ('true'/'false'/'ignore') to boolean | 'ignore' * Used when reading from database (toShape) */ const stringToBoolean = (value?: string): boolean | 'ignore' | undefined => { switch (value) { case 'true': return true case 'false': return false case 'ignore': return 'ignore' default: return undefined } } /** * Converts boolean | 'ignore' to ServiceNow string format ('true'/'false'/'ignore') * Used when writing to database (toRecord) */ const booleanToString = (value: Shape): string => { if (value?.isBoolean()) { return value.getValue() ? 'true' : 'false' } return 'ignore' } /** * Checks if a value is a valid action property (boolean or 'ignore') * Used for validation in toRecord */ const isValidActionValue = (value: Shape): boolean => { return value?.isBoolean() || (value?.isString() && value.getValue() === 'ignore') } // Helper to determine if a script should be included in generated Fluent code const isValidScript = (scriptValue: Shape) => scriptValue?.isString() && scriptValue.getValue() !== '' && scriptValue.getValue() !== DEFAULT_SCRIPT // Helper function to validate a script property for import/require statements const validateScriptProperty = ( scriptProperty: Shape | undefined, compiler: Compiler, diagnostics: Diagnostics ): void => { if (scriptProperty?.isString()) { const scriptContent = scriptProperty.getValue() if (scriptContent && !validateClientSideScript(scriptContent, compiler)) { diagnostics.error( scriptProperty, 'UI Policy scripts cannot import or require modules. Scripts run client-side in the browser.' ) } } } export const UiPolicyPlugin = Plugin.create({ name: 'UiPolicyPlugin', records: { [SYS_UI_POLICY]: { coalesce: ['table', 'short_description'], relationships: { [SYS_UI_POLICY_ACTION]: { via: 'ui_policy', descendant: true, }, [SYS_UI_POLICY_RL_ACTION]: { via: 'ui_policy', descendant: true, }, }, toShape(record, { descendants }) { const actions = descendants.query(SYS_UI_POLICY_ACTION).map((action) => { return action.transform(({ $ }) => ({ field: $, visible: $.map((v) => stringToBoolean(v?.asString()?.getValue())).def('ignore'), readOnly: $.from('disabled') .map((v) => stringToBoolean(v?.asString()?.getValue())) .def('ignore'), mandatory: $.map((v) => stringToBoolean(v?.asString()?.getValue())).def('ignore'), cleared: $.toBoolean().def(false), table: $.def(''), value: $.def(''), fieldMessage: $.from('field_message').def(''), fieldMessageType: $.from('field_message_type').def('none'), valueAction: $.from('value_action').def('ignore'), })) }) const relatedListActions = descendants.query(SYS_UI_POLICY_RL_ACTION).map((rlAction) => rlAction.transform(({ $ }) => ({ list: $.map((v) => { if (v?.ifString()?.ifDefined()) { const listVal = v.asString().getValue() return listVal.startsWith('REL:') ? listVal.substring(4) : listVal } return '' }).def(''), visible: $.map((v) => stringToBoolean(v?.asString()?.getValue())).def('ignore'), })) ) return { success: true, value: new CallExpressionShape({ source: record, callee: 'UiPolicy', args: [ record.transform(({ $ }) => { const scriptTrueValue = record.get('script_true') const scriptFalseValue = record.get('script_false') const runScriptsValue = record.get('run_scripts')?.toBoolean().getValue() ?? false const uiTypeValue = record.get('ui_type') const includeScriptTrue = runScriptsValue || isValidScript(scriptTrueValue) const includeScriptFalse = runScriptsValue || isValidScript(scriptFalseValue) return { $id: $.val(NowIdShape.from(record)), table: $.from('table').def(''), shortDescription: $.from('short_description'), active: $.toBoolean().def(true), global: $.toBoolean().def(true), onLoad: $.from('on_load').toBoolean().def(true), reverseIfFalse: $.from('reverse_if_false').toBoolean().def(true), inherit: $.toBoolean().def(false), isolateScript: $.from('isolate_script').toBoolean().def(false), conditions: $.def(''), runScripts: $.from('run_scripts').toBoolean().def(false), scriptTrue: $.val( includeScriptTrue ? (scriptTrueValue?.getValue() ?? DEFAULT_SCRIPT) : undefined ), scriptFalse: $.val( includeScriptFalse ? (scriptFalseValue?.getValue() ?? DEFAULT_SCRIPT) : undefined ), // uiType is REQUIRED when runScripts is true, should be omitted when false uiType: $.val( runScriptsValue ? getUITypeFromId(uiTypeValue?.toNumber().getValue() ?? 0) : undefined ), description: $.from('description').def(''), modelId: $.from('model_id').def(''), modelTable: $.from('model_table').def(''), order: $.from('order').toNumber().def(100), setValues: $.from('set_values').def(''), view: $.from('view').def(''), actions: $.val(actions).def([]), relatedListActions: $.val( relatedListActions.length > 0 ? relatedListActions : undefined ), } }), ], }), } }, }, [SYS_UI_POLICY_ACTION]: { coalesce: ['ui_policy', 'field'], }, [SYS_UI_POLICY_RL_ACTION]: { coalesce: ['ui_policy', 'list'], }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { config, factory, diagnostics, compiler }) { if (callExpression.getCallee()?.toString() !== 'UiPolicy') { return { success: false } } const arg = callExpression.getArgument(0) if (!arg?.isObject()) { return { success: false } } const shortDescription = arg.get('shortDescription') if (shortDescription?.isString() && shortDescription.getValue().trim() === '') { diagnostics.error(shortDescription, 'shortDescription cannot be empty or contain only whitespace') return { success: false } } // Validate table scope prefix for scoped apps const tableValue = arg.get('table') if (tableValue?.isString() && config?.scope) { const tableName = tableValue.getValue() const scopeName = config.scope if (!tableName.startsWith(`${scopeName}_`) && !isSNScope(scopeName) && scopeName !== 'global') { diagnostics.error( arg.get('table'), `'table' property should start with scope prefix '${scopeName}_'. UI Policies in scoped apps can only be created for tables within the same scope.` ) } } // Validate scripts for import statements const scriptTrue = arg.get('scriptTrue') const scriptFalse = arg.get('scriptFalse') validateScriptProperty(scriptTrue, compiler, diagnostics) validateScriptProperty(scriptFalse, compiler, diagnostics) const runScripts = arg.get('runScripts') if (runScripts.isUndefined() || runScripts.ifBoolean()?.equals(false)) { ;[scriptTrue, scriptFalse] .filter((shape) => shape.isDefined() && !shape.equals(DEFAULT_SCRIPT)) .forEach((shape) => diagnostics.info(shape, `This script will not execute unless runScripts is set to true`) ) } // Convert uiType value to ID (TypeScript enforces valid values at compile time) const uiTypeValue = arg.get('uiType') const uiTypeId = uiTypeValue?.isString() ? getUITypeId(uiTypeValue.getValue() as keyof typeof UiTypeMapping) : UiTypeMapping.desktop // default // Create the UI Policy record const policyRecord = await factory.createRecord({ source: callExpression, table: SYS_UI_POLICY, explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ table: $.from('table').def(''), short_description: $.from('shortDescription'), active: $.from('active').def(true), global: $.from('global').def(true), on_load: $.from('onLoad').def(true), reverse_if_false: $.from('reverseIfFalse').def(true), inherit: $.from('inherit').def(false), isolate_script: $.from('isolateScript').def(false), conditions: $.from('conditions').def(''), run_scripts: $.from('runScripts').def(false), script_true: $.from('scriptTrue').toCdata().def(DEFAULT_SCRIPT), script_false: $.from('scriptFalse').toCdata().def(DEFAULT_SCRIPT), description: $.from('description').def(''), model_id: $.from('modelId').def(''), model_table: $.from('modelTable').def(''), order: $.from('order').toNumber().def(100), set_values: $.from('setValues').def(''), ui_type: $.val(uiTypeId), view: $.from('view').def(''), })), }) // Process actions if they exist const actions = arg.get('actions') const actionRecords: Record[] = [] if (actions?.isArray()) { const elements = actions.getElements() // Process each action in the array for (let i = 0; i < elements.length; i++) { const action = elements[i] if (!action?.isObject()) { continue } const field = action.get('field') // Validate at least one action property exists const hasVisibleProp = isValidActionValue(action.get('visible')) const hasReadOnlyProp = isValidActionValue(action.get('readOnly')) const hasMandatoryProp = isValidActionValue(action.get('mandatory')) const hasClearedProp = action.get('cleared')?.isBoolean() || false if (!hasVisibleProp && !hasReadOnlyProp && !hasMandatoryProp && !hasClearedProp) { diagnostics.hint( action, `Action at index ${i} has no effect — consider specifying at least one of: visible, readOnly, mandatory, or cleared` ) } const actionRecord = await factory.createRecord({ source: action, // Use the action as source instead of callExpression table: SYS_UI_POLICY_ACTION, properties: action.transform(({ $ }) => ({ ui_policy: $.val(policyRecord.getId()), table: $.from('table').def(arg.get('table')?.getValue() || ''), field: $.val(field.getValue()), visible: $.val(booleanToString(action.get('visible'))), disabled: $.val(booleanToString(action.get('readOnly'))), mandatory: $.val(booleanToString(action.get('mandatory'))), cleared: $.from('cleared').toBoolean().def(false), field_message: $.from('fieldMessage').def(''), field_message_type: $.from('fieldMessageType').def('none'), value: $.from('value').def(''), value_action: $.from('valueAction').def('ignore'), })), }) actionRecords.push(actionRecord) } } // Create related list action records (optional) - AST approach const relatedListActionRecords: Record[] = [] const relatedListActions = arg.get('relatedListActions') if (relatedListActions?.isArray()) { const rlElements = relatedListActions.getElements() // Process each related list action in the array (similar to actions) for (let i = 0; i < rlElements.length; i++) { const rlAction = rlElements[i] if (!rlAction?.isObject()) { continue } // Validate and process the list property const list = rlAction.get('list') let listValue = '' if (list) { if (list.isRecord()) { listValue = `REL:${list.getId().getValue()}` } else if (list.isString()) { const listStr = list.getValue() if (isGUID(listStr)) { listValue = `REL:${listStr}` } else if (listStr.includes('.')) { const [table, field] = listStr.split('.') if (!table || !field || listStr.split('.').length !== 2) { diagnostics.error( list, `Related list action at index ${i}: 'list' property must be in 'table.field' format (e.g., 'incident.caller_id')` ) continue } listValue = listStr } else { diagnostics.error( list, `Related list action at index ${i}: 'list' property must be either a Record<'sys_relationship'>, a GUID, or in 'table.field' format (e.g., 'incident.caller_id')` ) continue } } } const hasVisibleProp = isValidActionValue(rlAction.get('visible')) if (!hasVisibleProp && !listValue) { diagnostics.hint( rlAction, `Related list action at index ${i} has no effect — consider specifying at least one of: list or visible` ) } const rlActionRecord = await factory.createRecord({ source: rlAction, table: SYS_UI_POLICY_RL_ACTION, properties: rlAction.transform(({ $ }) => ({ list: $.val(listValue), ui_policy: $.val(policyRecord.getId()), visible: $.val(booleanToString(rlAction.get('visible'))), })), }) relatedListActionRecords.push(rlActionRecord) } } return { success: true, value: policyRecord.with(...actionRecords, ...relatedListActionRecords) } }, }, ], })