import { NowIncludeShape } from '../now-include-plugin' import { type Record, Shape, type RecordId, type Diagnostics, type Factory, CallExpressionShape, isGUID, isSNScope, } from '@servicenow/sdk-build-core' import { noThrow, reverseObject } from '../utils' import { WidgetCategories } from '@servicenow/sdk-core/runtime/service-portal' import { NowIdShape } from '../now-id-plugin' function convertOptionSchemaKeys(optionSchema: unknown, convertKey: (key: string) => string): unknown { if (!Array.isArray(optionSchema)) { return optionSchema } return optionSchema.map((option: globalThis.Record) => Object.fromEntries(Object.entries(option).map(([k, v]) => [convertKey(k), v])) ) } const toCamelCase = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) /** * Shared toShape implementation for SPWidget and SPHeaderFooter */ export async function createWidgetToShape( record: Record, descendants: { query: (table: string) => Record[] }, callee: 'SPWidget' | 'SPHeaderFooter', tablePrefix: string = 'sp_widget', includeStaticField: boolean = false ) { const folderName = record.get('id')?.asString()?.getValue()?.replace(/[\s-]/g, '_') || record.get('name')?.asString()?.getValue()?.toLowerCase().replace(/[\s-]/g, '_') const dependencies = descendants.query('m2m_sp_widget_dependency') const angularProviders = descendants.query('m2m_sp_ng_pro_sp_widget') const templates = descendants.query('sp_ng_template').map((template) => { return { $id: template.getId(), id: template.get('id'), htmlTemplate: template.get('template'), } }) const category = reverseObject(WidgetCategories)[record.get('category').ifString()?.getValue() || ''] const serverScript = await createIncludeShape( record, record.get('script'), 'server_script', 'js', folderName, tablePrefix ) const clientScript = await createIncludeShape( record, record.get('client_script'), 'client_script', 'js', folderName, tablePrefix ) const css = await createIncludeShape(record, record.get('css'), 'style', 'scss', folderName, tablePrefix) const htmlTemplate = await createIncludeShape( record, record.get('template'), 'template', 'html', folderName, tablePrefix ) const linkScript = await createIncludeShape( record, record.get('link'), 'link-script', 'js', folderName, tablePrefix ) return { success: true, value: new CallExpressionShape({ source: record, callee, args: [ record.transform(({ $ }) => { const baseTransform = { $id: $.val(NowIdShape.from(record)), name: $, category: $.val(category).def('custom'), clientScript: $.val(clientScript).def(''), serverScript: $.val(serverScript).def(''), controllerAs: $.from('controller_as').def('c'), htmlTemplate: $.val(htmlTemplate).def(''), customCss: $.val(css).def(''), dataTable: $.from('data_table') .map((v: Shape) => { const val = v.ifString()?.getValue() return val && val.trim() !== '' ? val : 'sp_instance' }) .def('sp_instance'), demoData: $.from('demo_data') .map((d: Shape) => { const json = d.ifString()?.getValue() const parsed = noThrow(() => json && JSON.parse(json)) return parsed instanceof Error ? json : parsed }) .def(''), description: $.def(''), docs: $.def(''), fields: $.from('field_list') .map((fields: Shape) => { const fieldStr = fields.ifString()?.getValue() const fieldArray = fieldStr?.split(',').filter((f: string) => f.trim()) return fieldArray && fieldArray.length > 0 ? fieldArray : undefined }) .def(undefined), hasPreview: $.from('has_preview').toBoolean().def(false), id: $.def(''), internal: $.toBoolean().def(false), linkScript: $.val(linkScript).def(''), roles: $.map((role: Shape) => { const roleStr = role.ifString()?.getValue() const roleArray = roleStr?.split(',').filter((r: string) => r.trim()) return roleArray && roleArray.length > 0 ? roleArray : undefined }).def(undefined), servicenow: $.toBoolean().def(false), optionSchema: $.from('option_schema') .map((v: Shape) => { const json = v.ifString()?.getValue() const parsed = noThrow(() => json && JSON.parse(json)) const result = parsed instanceof Error ? json : parsed return convertOptionSchemaKeys(result, toCamelCase) }) .def(''), public: $.toBoolean().def(false), dependencies: $.val( dependencies.length > 0 ? dependencies.map((dep) => dep.get('sp_dependency')) : undefined ), angularProviders: $.val( angularProviders.length > 0 ? angularProviders.map((ap) => ap.get('sp_angular_provider')) : undefined ), templates: $.val(templates.length > 0 ? templates : undefined), } return includeStaticField ? { ...baseTransform, static: $.from('static').toBoolean().def(false) } : baseTransform }), ], }), } } /** * Creates a NowIncludeShape with a custom file path suffix and extension */ export async function createIncludeShape( record: Record, content: string | Shape, suffix: string, extension: 'js' | 'html' | 'scss', folderName: string, tablePrefix: string = 'sp_widget' ): Promise { const baseName = `${tablePrefix}_${folderName}` const includedText = content instanceof Shape ? content.toString().getValue() : content return new NowIncludeShape({ source: record, path: `./${baseName}/${suffix}.${extension}`, includedText, }) } export const DEFAULT_ORDER = 100 /** * Shared toRecord implementation for SPWidget and SPHeaderFooter */ export async function createWidgetToRecord( callExpression: CallExpressionShape, { diagnostics, factory, config, }: { diagnostics: Diagnostics factory: Factory config: { scope: string } }, options: { callee: 'SPWidget' | 'SPHeaderFooter' table: 'sp_widget' | 'sp_header_footer' includeStaticField?: boolean getDefaultClientScript: (controller: string) => string defaultServerScript: string defaultLinkScript: string defaultHtmlTemplate: string } ) { const { table, includeStaticField = false } = options const widget = callExpression.getArgument(0).asObject() const dependencies = widget .get('dependencies') .ifArray() ?.map((dep) => (dep.isString() ? dep.getValue() : dep.ifRecord()?.getId())) .filter((dep) => dep) ?? [] const angularProviders = widget .get('angularProviders') .ifArray() ?.map((ap) => (ap.isString() ? ap.getValue() : ap.ifRecord()?.getId())) .filter((ap) => ap) ?? [] const templates: Array<{ $id: string; id: unknown; htmlTemplate: unknown }> = widget .get('templates') .ifArray() ?.map((shp) => shp.getValue() as { $id: string; id: unknown; htmlTemplate: unknown }) ?? [] const widgetId = widget.get('id').ifString() if (widgetId && !/^[a-zA-Z0-9_-]+$/g.test(widgetId.getValue())) { diagnostics.error( widgetId.getOriginalNode(), `Invalid value: must contain only alphanumeric, -, or _ characters` ) } const clientScript = widget.get('clientScript') const clientScriptValue = clientScript instanceof NowIncludeShape ? clientScript.getValue() : clientScript.ifString()?.getValue() if (clientScriptValue && clientScriptValue.trim().length > 0) { const clientScriptPattern = /^(function|api\.controller\s?=\s?function)\s?([$a-z_][$0-9a-z_]*)?\s?\(.*\)\s?\n?{/i if (!clientScriptPattern.test(clientScriptValue.trim())) { diagnostics.error( clientScript.getOriginalNode(), `Client controller must contain a JavaScript function. Example: api.controller = function($scope) { ... }` ) } } const htmlTemplate = widget.get('htmlTemplate') const htmlTemplateValue = htmlTemplate instanceof NowIncludeShape ? htmlTemplate.getValue() : htmlTemplate.ifString()?.getValue() if (htmlTemplateValue && htmlTemplateValue.length > 0 && htmlTemplateValue.indexOf('href="#"') > 0) { diagnostics.error( htmlTemplate.getOriginalNode(), `Do not use href="#" in the Service Portal, use href="javascript:void(0)" instead` ) } const roles = widget .get('roles') .ifArray() ?.map((role) => { if (role.isString()) { return role } if (role.isRecord()) { return role.get('name') } return undefined }) .filter((role) => role) as Shape[] | undefined if (roles) { roles .filter((role) => isGUID(role.getValue() as string)) .forEach((role) => diagnostics.error( role!.getOriginalNode(), `expecting role names or role records created by the Role or Record plugins, not sys_ids` ) ) } const controller = widget.get('controllerAs').ifString()?.getValue() || 'c' const servicenow = (widget.get('servicenow').ifBoolean()?.getValue() && isSNScope(config.scope)) || false const widgetRecord = await factory.createRecord({ source: callExpression, table, explicitId: widget.get('$id'), properties: widget.transform(({ $ }) => { const baseTransform = { name: $, category: $.map((v) => { const catKey = v.ifString()?.getValue() || '' return WidgetCategories[catKey as keyof typeof WidgetCategories] }).def('custom'), client_script: $.from('clientScript').def(options.getDefaultClientScript(controller)), script: $.from('serverScript').def(options.defaultServerScript), controller_as: $.from('controllerAs').def('c'), template: $.from('htmlTemplate').def(options.defaultHtmlTemplate), css: $.from('customCss').def(''), data_table: $.from('dataTable').def('sp_instance'), demo_data: $.from('demoData') .map((v) => (v.ifString() || v instanceof NowIncludeShape ? v : JSON.stringify(v.getValue()))) .def(''), description: $.def(''), docs: $.map((v) => (v.isString() ? v : v.ifRecord()?.getId())), field_list: $.from('fields') .map((v) => v.getValue()?.toString()) .def(''), has_preview: $.from('hasPreview').toBoolean().def(false), id: $.def(''), internal: $.def(false), servicenow: $.val(servicenow), link: $.from('linkScript').def(options.defaultLinkScript), roles: $.val(roles?.map((r) => r.getValue()).toString()).def(''), option_schema: $.from('optionSchema') .map((v) => { const json = v.getValue() const converted = convertOptionSchemaKeys(json, (key: string) => key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`) ) return converted && JSON.stringify(converted) }) .def(''), public: $.def(false), } return includeStaticField ? { ...baseTransform, static: $.from('static').toBoolean().def(false) } : baseTransform }), }) return { success: true, value: widgetRecord.with( ...(await Promise.all( templates.map(async (template) => { return await factory.createRecord({ source: callExpression, table: 'sp_ng_template', explicitId: template.$id, properties: { id: template.id, sp_widget: widgetRecord.getId(), template: template.htmlTemplate, }, }) }) )), ...(await Promise.all( dependencies.map(async (dep) => { return await factory.createRecord({ source: callExpression, table: 'm2m_sp_widget_dependency', properties: { sp_dependency: dep!, sp_widget: widgetRecord.getId(), }, }) }) )), ...(await Promise.all( angularProviders.map(async (ap) => { return await factory.createRecord({ source: callExpression, table: 'm2m_sp_ng_pro_sp_widget', properties: { sp_angular_provider: ap!, sp_widget: widgetRecord.getId(), }, }) }) )) ), } } /** * Converts a roles Shape (array of strings, Role() expressions, or Record references) * to a deduplicated comma-separated string of role names. * @param rolesShape - Shape containing the roles array * @param diagnostics - Optional diagnostics for reporting invalid role entries * @returns Comma-separated string of unique role names, or empty string if no roles */ export function getRolesString(rolesShape: Shape, diagnostics?: Diagnostics): string { const roles = rolesShape .ifArray() ?.getElements() .map((role) => { if (role.isString()) { return role.getValue() } if (role instanceof CallExpressionShape) { const name = role.getArgument(0).asObject().get('name') if (name.isString()) { return name.getValue() } } if (role.isRecord()) { const name = role.get('name') if (name?.isString()) { return name.getValue() } } if (diagnostics) { diagnostics.error(role, 'roles must be strings or role records') } return undefined }) .filter((r): r is string => r !== undefined && r.trim() !== '') if (!roles || roles.length === 0) { return '' } return [...new Set(roles)].join(',') } export async function getIncludeRecords( shape: Shape, parentId: Shape, factory: Factory, m2mTable: string, includeTable: string, parentTable: string, diagnostics: Diagnostics ) { return ( await Promise.all( shape?.ifArray()?.map(async (include) => { const includeProps = include.ifObject()?.get('include') let includeId: string | RecordId if (includeProps?.isString()) { includeId = includeProps.toString().getValue() } else if (includeProps?.isRecord()) { includeId = includeProps.getId() } else { const necessaryPlugin = includeTable === 'sp_js_include' ? 'JsInclude' : 'CssInclude' diagnostics.error( includeProps!, `include must contain a valid ${necessaryPlugin}, Record<'${includeTable}'> or sys_id'` ) return undefined } const m2mProps = { [includeTable]: includeId, [parentTable]: parentId, } return await factory.createRecord({ source: shape, table: m2mTable, properties: { order: include.ifObject()?.get('order').asNumber().getValue() || DEFAULT_ORDER, ...m2mProps, }, }) }) ?? [] ) ).filter((rec) => rec) as Record[] } /** * Value transformer for wrapping widget parameters. * - 'stringify': Converts all values to strings (matches sp_instance platform behavior) * - 'native': Preserves native types (matches sp_instance_menu platform behavior) */ type ValueMode = 'stringify' | 'native' /** * Wraps a flat key-value object into the {value, displayValue} format. * @param obj - The flat key-value object to wrap * @param mode - 'stringify' for sp_instance (all strings), 'native' for sp_instance_menu (preserve types) */ function wrapWidgetParamValues(obj: globalThis.Record, mode: ValueMode): string { const wrapped = Object.fromEntries( Object.entries(obj).map(([k, val]) => [ k, { value: mode === 'stringify' ? String(val ?? '') : (val ?? null), displayValue: String(val ?? ''), }, ]) ) return JSON.stringify(wrapped) } /** * Checks whether a parsed widget_parameters object is already in the * {value, displayValue} wrapped format (every value is an object with both "value" and "displayValue" keys). */ function isAlreadyWrapped(obj: globalThis.Record): boolean { const entries = Object.values(obj) return ( entries.length > 0 && entries.every((val) => val !== null && typeof val === 'object' && 'value' in val && 'displayValue' in val) ) } /** * Core serialization logic for widgetParameters. * Handles all input cases and delegates to wrapWidgetParamValues with the appropriate mode. */ function serializeWidgetParameters(v: Shape, mode: ValueMode): Shape | string { if (v.is(NowIncludeShape)) { return v } if (v.isObject()) { return wrapWidgetParamValues(v.getValue() as globalThis.Record, mode) } const str = v.ifString() if (str) { const parsed = noThrow(() => str.parseJson()) const obj = parsed instanceof Error ? undefined : parsed.ifObject()?.getValue() if (obj) { return isAlreadyWrapped(obj) ? v : wrapWidgetParamValues(obj, mode) } return v } return v } /** * Serializes widgetParameters for sp_instance (page widget instances). * Platform behavior: SP Designer stores ALL values as strings (booleans as "true"/"false"). */ export function serializeWidgetParametersForPage(v: Shape): Shape | string { return serializeWidgetParameters(v, 'stringify') } /** * Serializes widgetParameters for sp_instance_menu (menu widget instances). * Platform behavior: Menu widget parameters preserve native types (booleans as true/false). */ export function serializeWidgetParametersForMenu(v: Shape): Shape | string { return serializeWidgetParameters(v, 'native') }