import { CallExpressionShape, Plugin, IdentifierShape, ElementAccessExpressionShape, RecordId, type Factory, type Diagnostics, type ObjectShape, type Record, type Shape, Database, isGUID, } from '@servicenow/sdk-build-core' import { create } from 'xmlbuilder2' import { showGuidFieldDiagnostic } from './utils' import { NowIdShape } from './now-id-plugin' import { AnnotationType, Formatter } from '@servicenow/sdk-core/runtime/ui' import { DEFAULT_VIEW } from './common/constants' const DEFAULT_ANNOTATION_TYPE = '' const FORM_XML_TABLES = ['sys_ui_form', 'sys_ui_form_section'] // Derive sys_id → key name maps from the source-of-truth constants function buildReverseMap(ns: { [key: string]: string }): Map { const map = new Map() for (const [key, value] of Object.entries(ns)) { map.set(value, key) } return map } const ANNOTATION_TYPE_MAP = buildReverseMap(AnnotationType) const FORMATTER_MAP = buildReverseMap(Formatter) // Maps Formatter sys_id → element name (the `formatter` column on sys_ui_formatter) const FORMATTER_ELEMENT_MAP = new Map([ ['444ea5c6bf310100e628555b3f0739d6', 'activity.xml'], ['cfa76e850a0a0b1f01446f67c8538d00', 'attached_knowledge'], ]) // Split type constants for form layout const SPLIT_TYPE = { BEGIN: '.begin_split', MIDDLE: '.split', END: '.end_split', } as const const SPLIT_TYPES = [SPLIT_TYPE.BEGIN, SPLIT_TYPE.MIDDLE, SPLIT_TYPE.END] as const function sortByPosition(records: Record[]): Record[] { return records.sort( (a, b) => (a.get('position')?.toNumber()?.getValue() ?? 0) - (b.get('position')?.toNumber()?.getValue() ?? 0) ) } /** * Converts a UI element record to a FormElement for shape generation. * Returns null for split marker elements (they are handled by groupElementsIntoLayoutBlocks). */ function convertElementToField(element: Record, descendants?: Database): FormElement | null { const elementValue = element.get('element').asString().getValue() const type = getOptionalString(element, 'type') const formatter = getOptionalString(element, 'sys_ui_formatter') // Split markers are handled by groupElementsIntoLayoutBlocks, not emitted as elements if (SPLIT_TYPES.includes(type as (typeof SPLIT_TYPES)[number])) { return null } if (type === 'annotation' && descendants) { // element field of sys_ui_element contains the sys_id of the sys_ui_annotation record const annotation = descendants.query('sys_ui_annotation').find((a) => a.getId().getValue() === elementValue) if (annotation) { const isPlainText = annotation.get('is_plain_text').toBoolean()?.getValue() ?? true const annotationTypeSysId = getOptionalString(annotation, 'type') const annotationType = ANNOTATION_TYPE_MAP.get(annotationTypeSysId) ?? (annotationTypeSysId || undefined) return { type: 'annotation', annotationId: NowIdShape.from(annotation), text: getOptionalString(annotation, 'text'), isPlainText: isPlainText, ...(annotationType !== undefined && { annotationType }), } } return { type: 'annotation', annotationId: new NowIdShape({ source: element, id: elementValue }), text: '', } } if (type === 'formatter') { const formatterKey = FORMATTER_MAP.get(formatter) const derivableName = FORMATTER_ELEMENT_MAP.get(formatter) return { type: 'formatter', // Only emit formatterName when it differs from the derivable value ...(elementValue !== derivableName ? { formatterName: elementValue } : {}), formatterRef: formatterKey ?? formatter, } } if (type === 'list') { // Parse the encoded element string to extract listType and list components const parsed = parseListElement(elementValue) if (parsed.listType === '12M' || parsed.listType === 'M2M') { return { type: 'list', listType: parsed.listType, listRef: `${parsed.listTable}.${parsed.listColumn}`, } } if (parsed.listType === 'custom') { return { type: 'list', listType: 'custom', listRef: parsed.relationship, } } return null } // Default: table field element return { field: elementValue, type: 'table_field', } } type ParsedListElement = | { listType: '12M' | 'M2M'; listTable: string; listColumn: string } | { listType: 'custom'; relationship: string } /** * Parses an encoded list element string into its components. * Formats: * - '12M...' → { listType: '12M', listTable: '', listColumn: '' } * - 'M2M...' → { listType: 'M2M', listTable: '', listColumn: '' } * - 'REL..REL:' → { listType: 'custom', relationship: '' } */ function parseListElement(elementValue: string): ParsedListElement { if (elementValue.startsWith('12M.')) { const parts = elementValue.substring(4).split('.') // parts: [parent_table, child_table, ref_column] return { listType: '12M', listTable: parts[1] ?? '', listColumn: parts[2] ?? '' } } if (elementValue.startsWith('M2M.')) { const parts = elementValue.substring(4).split('.') // parts: [parent_table, join_table, ref_column] return { listType: 'M2M', listTable: parts[1] ?? '', listColumn: parts[2] ?? '' } } if (elementValue.startsWith('REL.')) { // Format: REL..REL: const relMatch = elementValue.match(/^REL\.[^.]+\.REL:(.+)$/) return { listType: 'custom', relationship: relMatch?.[1] ?? elementValue } } // Fallback return { listType: 'custom', relationship: elementValue } } /** * Groups a flat array of sorted sys_ui_element records into LayoutBlock[] for the content array. * Recognizes .begin_split / .split / .end_split sequences and wraps them into two-column blocks. * Consecutive non-split elements are grouped into one-column blocks. */ function groupElementsIntoLayoutBlocks(elements: Record[], descendants?: Database): LayoutBlock[] { const blocks: LayoutBlock[] = [] let currentOneColumnElements: FormElement[] = [] // Flush accumulated one-column elements into a block. The length check handles // edge cases where flushOneColumn is called with no pending elements, e.g. when // a .begin_split is the first element or two split blocks are adjacent. const flushOneColumn = () => { if (currentOneColumnElements.length > 0) { blocks.push({ layout: 'one-column', elements: currentOneColumnElements }) currentOneColumnElements = [] } } let i = 0 while (i < elements.length) { const elem = elements[i]! const type = getOptionalString(elem, 'type') if (type === SPLIT_TYPE.BEGIN) { // Flush any pending one-column elements flushOneColumn() // Collect left and right elements until .end_split const leftElements: FormElement[] = [] const rightElements: FormElement[] = [] let side: 'left' | 'right' = 'left' i++ // skip .begin_split while (i < elements.length) { const innerElem = elements[i]! const innerType = getOptionalString(innerElem, 'type') if (innerType === SPLIT_TYPE.END) { i++ // skip .end_split break } if (innerType === SPLIT_TYPE.MIDDLE) { side = 'right' i++ continue } const converted = convertElementToField(innerElem, descendants) if (converted) { if (side === 'left') { leftElements.push(converted) } else { rightElements.push(converted) } } i++ } blocks.push({ layout: 'two-column', leftElements, rightElements }) } else if (type === SPLIT_TYPE.MIDDLE) { // Handle lone .split without .begin_split / .end_split. // ServiceNow can produce this when fields are dragged across columns. // Treat accumulated one-column elements as leftElements, // and everything after .split until the next split marker or end as rightElements. const leftElements: FormElement[] = [...currentOneColumnElements] currentOneColumnElements = [] const rightElements: FormElement[] = [] i++ // skip .split while (i < elements.length) { const innerElem = elements[i]! const innerType = getOptionalString(innerElem, 'type') if (innerType === SPLIT_TYPE.END || innerType === SPLIT_TYPE.MIDDLE || innerType === SPLIT_TYPE.BEGIN) { break } const converted = convertElementToField(innerElem, descendants) if (converted) { rightElements.push(converted) } i++ } blocks.push({ layout: 'two-column', leftElements, rightElements }) } else { const converted = convertElementToField(elem, descendants) if (converted) { currentOneColumnElements.push(converted) } i++ } } // Flush remaining one-column elements flushOneColumn() return blocks } /** * Gets an optional string value from a record field with a default fallback */ function getOptionalString(record: Record, field: string, defaultValue = ''): string { return record.get(field).ifString()?.getValue() ?? defaultValue } /** * Resolves a view value to its sys_id and view name for XML output. * The name is needed as an attribute on the XML element so the parser * can create a RecordId with proper coalesce keys when re-reading the XML. */ function resolveView( view: ReturnType, database: Database ): { sysId: string; name: string; displayValue: string } { if (view.isRecordId() || view.isRecord()) { const viewId = view.isRecordId() ? view : view.getId() const sysId = viewId.getValue() // Try to resolve the view record to get its name and title const viewRecord = database.resolve(viewId) if (viewRecord) { const name = viewRecord.get('name').ifString()?.getValue() ?? '' const displayValue = viewRecord.get('title').ifString()?.getValue() ?? name return { sysId, name, displayValue } } // Fall back to primary key (view name) if available if (view.isRecordId() && view.asRecordId().hasPrimaryKey()) { const pk = view.asRecordId().getPrimaryKey() if (pk && !isGUID(pk)) { return { sysId, name: pk, displayValue: pk } } } return { sysId, name: '', displayValue: '' } } const viewValue = view.getValue() if (viewValue === 'NULL' || viewValue === DEFAULT_VIEW) { return { sysId: DEFAULT_VIEW, name: DEFAULT_VIEW, displayValue: DEFAULT_VIEW } } // View is a string - try to resolve to record ID if it exists in database const viewNameStr = view.ifString()?.getValue() ?? '' if (!viewNameStr) { return { sysId: '', name: '', displayValue: '' } } const viewRecord = database.query('sys_ui_view').find((v) => v.get('name').ifString()?.getValue() === viewNameStr) if (viewRecord) { const displayValue = viewRecord.get('title').ifString()?.getValue() ?? viewNameStr return { sysId: viewRecord.getId().getValue(), name: viewNameStr, displayValue } } return { sysId: viewNameStr, name: isGUID(viewNameStr) ? '' : viewNameStr, displayValue: isGUID(viewNameStr) ? '' : viewNameStr, } } type FormElement = | { field: string; type: 'table_field' } | { type: 'annotation' annotationId: NowIdShape text: string isPlainText?: boolean annotationType?: string } | { type: 'formatter'; formatterName?: string; formatterRef: string } | { type: 'list'; listType: '12M' | 'M2M'; listRef: string } | { type: 'list'; listType: 'custom'; listRef: string } type LayoutBlock = | { layout: 'one-column'; elements: FormElement[] } | { layout: 'two-column'; leftElements: FormElement[]; rightElements: FormElement[] } function serializeKeys(id: RecordId): globalThis.Record | undefined { const keys = id.getKeys() return keys ? Object.fromEntries(Object.entries(keys).map(([k, v]) => [k, v instanceof RecordId ? v.getValue() : v])) : undefined } export const FormPlugin = Plugin.create({ name: 'FormPlugin', records: { sys_ui_section: { composite: true, coalesce: ['name', 'caption', 'view', 'sys_domain'], relationships: { sys_ui_element: { via: 'sys_ui_section', descendant: true, relationships: { sys_ui_annotation: { // sys_ui_element.element field holds the sys_id of sys_ui_annotation via: 'element', inverse: true, descendant: true, }, }, }, sys_ui_view: { via: 'view', inverse: true, }, }, toFile(section, { config, descendants, database }) { // For DELETE records, output a simple delete XML if (section.getAction() === 'DELETE') { const xml = create().ele('record_update', { table: 'sys_ui_section' }) const sectionWrapper = xml.ele('sys_ui_section', { action: 'DELETE', section_id: section.getId().getValue(), table: section.get('name').getValue(), }) const child = sectionWrapper.ele('sys_ui_section', { action: 'DELETE' }) child.ele('sys_id').txt(section.getId().getValue()) child.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId) child.ele('sys_update_name').txt(`sys_ui_section_${section.getId().getValue()}`) section .entries() .sort(([a], [b]) => a.localeCompare(b)) .forEach(([prop, shape]) => { if (['sys_id', 'sys_scope', 'sys_update_name'].includes(prop)) { return } if (shape instanceof RecordId) { child.ele(prop, serializeKeys(shape)).txt(shape.getValue()) } else { child.ele(prop).txt(shape.toString().getValue()) } }) return { success: true, value: { source: section, name: `sys_ui_section_${section.getId().getValue()}.xml`, category: section.getInstallCategory(), content: xml.end({ prettyPrint: true }), }, } } const sectionName = section.get('name').asString().getValue() const caption = getOptionalString(section, 'caption') const sysDomain = getOptionalString(section, 'sys_domain', 'global') const { sysId: viewSysId, name: viewName, displayValue: viewDisplayValue, } = resolveView(section.get('view'), database) const xml = create().ele('record_update') const root = xml.ele('sys_ui_section', { caption, section_id: section.getId().getValue(), sys_domain: sysDomain, table: sectionName, view: viewSysId === DEFAULT_VIEW ? '' : viewName, }) // Add all sys_ui_annotation records for this section (excluding deleted ones) const annotations = descendants .query('sys_ui_annotation') .filter((annotation) => annotation.getAction() !== 'DELETE') for (const annotation of annotations) { const annotationChild = root.ele('sys_ui_annotation', { action: annotation.getAction(), apply_defaults: 'true', }) const isPlainTextVal = annotation.get('is_plain_text').ifBoolean()?.getValue() ?? true annotationChild.ele('is_plain_text').txt(String(isPlainTextVal)) annotationChild.ele('name').txt(getOptionalString(annotation, 'name')) annotationChild.ele('sys_id').txt(annotation.getId().getValue()) annotationChild.ele('text').txt(getOptionalString(annotation, 'text')) const annotationType = annotation.get('type') if (annotationType?.isRecordId() || annotationType?.isRecord()) { const typeId = annotationType.isRecordId() ? annotationType : annotationType.getId() const typeDisplayValue = getOptionalString(annotation, 'type_display_value') annotationChild.ele('type', { display_value: typeDisplayValue }).txt(typeId.getValue()) } else { annotationChild.ele('type').txt(getOptionalString(annotation, 'type')) } } // Add all sys_ui_element records for this section (excluding deleted ones) const elements = sortByPosition( descendants.query('sys_ui_element').filter((element) => element.getAction() !== 'DELETE') ) for (const element of elements) { const child = root.ele('sys_ui_element', { action: element.getAction(), apply_defaults: 'true' }) child.ele('element').txt(element.get('element').asString().getValue()) child.ele('position').txt(element.get('position').toNumber().getValue().toString()) child.ele('sys_id').txt(element.getId().getValue()) child.ele('sys_ui_formatter').txt(getOptionalString(element, 'sys_ui_formatter')) child .ele('sys_ui_section', { caption, display_value: caption, name: sectionName, sys_domain: sysDomain, view: viewSysId, }) .txt(section.getId().getValue()) child.ele('sys_user') child.ele('type').txt(getOptionalString(element, 'type')) } // Add the sys_ui_section record itself const sectionChild = root.ele('sys_ui_section', { action: section.getAction(), apply_defaults: 'true' }) sectionChild.ele('caption').txt(caption) sectionChild.ele('header').txt(String(section.get('header').ifBoolean()?.getValue() ?? false)) sectionChild.ele('name').txt(sectionName) sectionChild.ele('roles').txt(getOptionalString(section, 'roles')) sectionChild.ele('sys_domain').txt(sysDomain) sectionChild.ele('sys_id').txt(section.getId().getValue()) sectionChild.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId) sectionChild.ele('sys_user') sectionChild.ele('title').txt(String(section.get('title').ifBoolean()?.getValue() ?? false)) sectionChild.ele('view_name') const isDefaultView = viewSysId === DEFAULT_VIEW const sectionViewAttrs = isDefaultView ? { name: 'NULL', display_value: DEFAULT_VIEW } : viewName ? { name: viewName, display_value: viewDisplayValue || viewName } : {} sectionChild.ele('view', sectionViewAttrs).txt(viewSysId) return { success: true, value: { source: section, name: `sys_ui_section_${section.getId().getValue()}.xml`, category: section.getInstallCategory(), content: xml.end({ prettyPrint: true }), }, } }, async diff(existing, incoming, _, { factory }) { // If either database is empty, return the incoming as-is or empty database if (incoming.query().length === 0 || existing.query().length === 0) { return { success: true, value: incoming.query().length === 0 ? new Database() : new Database(incoming.query()), } } const changeDatabase = new Database() let hasChanges = false const existingSection = existing.query('sys_ui_section')[0] const incomingSection = incoming.query('sys_ui_section')[0] const existingElements = existing.query('sys_ui_element') const incomingElements = incoming.query('sys_ui_element') const existingAnnotations = existing.query('sys_ui_annotation') const incomingAnnotations = incoming.query('sys_ui_annotation') // 1. Compare and merge the main form record if (incomingSection && existingSection) { if (!existingSection.strictEquals(incomingSection)) { changeDatabase.insert(existingSection.merge(incomingSection)) hasChanges = true } } else if (incomingSection) { changeDatabase.insert(incomingSection) hasChanges = true } // 2. Compare and merge sys_ui_annotation records const markAnnotationsForRemoval: Record[] = [] for (const annotation of existingAnnotations) { const match = incoming.resolve(annotation.getId()) if (!match) { hasChanges = true markAnnotationsForRemoval.push(annotation) } else { hasChanges = hasChanges || !annotation.strictEquals(match) changeDatabase.insert(annotation.merge(match)) } } // Add new annotations incomingAnnotations.forEach((annotation) => { const match = changeDatabase.resolve(annotation.getId()) if (!match) { changeDatabase.insert(annotation) hasChanges = true } }) // Delete removed annotations for (const annotation of markAnnotationsForRemoval) { const deleteRecord = await factory.createRecord({ source: annotation.getSource(), table: 'sys_ui_annotation', explicitId: annotation.getId(), properties: annotation.properties(), action: 'DELETE', }) changeDatabase.insert(deleteRecord) } // 3. Compare and merge sys_ui_element records const markElementsForRemoval: Record[] = [] for (const element of existingElements) { const match = incoming.resolve(element.getId()) if (!match) { hasChanges = true markElementsForRemoval.push(element) } else { hasChanges = hasChanges || !element.strictEquals(match) changeDatabase.insert(element.merge(match)) } } // Add new elements incomingElements.forEach((element) => { const match = changeDatabase.resolve(element.getId()) if (!match) { changeDatabase.insert(element) hasChanges = true } }) // Delete removed elements for (const element of markElementsForRemoval) { const deleteRecord = await factory.createRecord({ source: element.getSource(), table: 'sys_ui_element', explicitId: element.getId(), properties: element.properties(), action: 'DELETE', }) changeDatabase.insert(deleteRecord) } return { success: true, value: hasChanges ? changeDatabase : new Database(), } }, }, sys_ui_element: { coalesce: ['sys_ui_section', 'element', 'position'], }, sys_ui_form_section: { coalesce: ['sys_ui_form', 'sys_ui_section'], }, sys_ui_form: { coalesce: ['name', 'view', 'sys_domain'], relationships: { sys_ui_form_section: { via: 'sys_ui_form', // Reference column name on this table descendant: true, relationships: { sys_ui_section: { via: 'sys_ui_section', inverse: true, // Indicates the parent refers to this table descendant: true, relationships: { sys_ui_element: { via: 'sys_ui_section', descendant: true, relationships: { sys_ui_annotation: { via: 'element', inverse: true, descendant: true, }, }, }, sys_ui_view: { via: 'view', inverse: true, }, }, }, }, }, sys_ui_view: { via: 'view', inverse: true, }, }, toShape(record, { descendants, database }) { const sections = sortByPosition(descendants.query('sys_ui_form_section')) .map((formSection) => { // Get section ID from form_section record const sectionId = formSection.get('sys_ui_section') // Find the section record const section = descendants.query('sys_ui_section').find((s) => s.getId().equals(sectionId)) if (!section) { return null } // Get section caption const caption = section.get('caption').ifString()?.getValue() ?? '' const header = section.get('header')?.ifBoolean()?.getValue() ?? false const title = section.get('title')?.ifBoolean()?.getValue() ?? false // Get elements for this section, sorted by position const sortedElements = sortByPosition( descendants .query('sys_ui_element') .filter((elem) => elem.get('sys_ui_section').equals(sectionId)) ) // Group flat elements into layout blocks (one-column / two-column) const content = groupElementsIntoLayoutBlocks(sortedElements, descendants) return { caption, content, ...(header && { header }), ...(title && { title }), } }) .filter(Boolean) // Remove any nulls return { success: true, value: new CallExpressionShape({ source: record, callee: 'Form', args: [ record.transform(({ $ }) => ({ table: $.from('name'), view: $.map((v) => { if (v.equals(DEFAULT_VIEW)) { return new IdentifierShape({ source: record, name: 'default_view' }) } // Reuse resolveView to resolve the view value to its name const resolved = resolveView(v, database) if (resolved.name && resolved.name !== DEFAULT_VIEW) { return resolved.name } return v.ifString() ?? v.toRecordId() }), user: $.from('sys_user').def(''), roles: $.from('roles') .map((v) => { return v.isString() && !v.isEmpty() ? v .asString() .getValue() .split(',') .map((role) => role.trim()) : [] }) .def([]), sections: $.val(sections), })), ], }), } }, toFile(form, { config, descendants, database }) { if (!form.has('name')) { return { success: false } } // For DELETE records, output a simple delete XML if (form.getAction() === 'DELETE') { const xml = create().ele('record_update', { table: 'sys_ui_form' }) const child = xml.ele('sys_ui_form', { action: 'DELETE' }) child.ele('sys_id').txt(form.getId().getValue()) child.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId) child.ele('sys_update_name').txt(`sys_ui_form_sections_${form.getId().getValue()}`) return { success: true, value: { source: form, name: `sys_ui_form_sections_${form.getId().getValue()}.xml`, category: form.getInstallCategory(), content: xml.end({ prettyPrint: true }), }, } } const formName = form.get('name').asString().getValue() const formSysDomain = getOptionalString(form, 'sys_domain', 'global') const { sysId: formViewSysId, name: formViewName, displayValue: formViewDisplayValue, } = resolveView(form.get('view'), database) const xml = create().ele('record_update') const root = xml.ele('sys_ui_form_sections', { form_id: form.getId().getValue(), sys_domain: formSysDomain, table: formName, }) // Add all sys_ui_form_section records (excluding deleted ones) const formSections = sortByPosition( descendants .query('sys_ui_form_section') .filter((formSection) => formSection.getAction() !== 'DELETE') ) for (const formSection of formSections) { const child = root.ele('sys_ui_form_section', { action: 'INSERT_OR_UPDATE', apply_defaults: 'true', }) child.ele('position').txt(formSection.get('position').toNumber().getValue().toString()) child.ele('sys_id').txt(formSection.getId().getValue()) child .ele('sys_ui_form', { display_value: formName, name: formName, sys_domain: formSysDomain, view: formViewSysId, }) .txt(form.getId().getValue()) // Add section reference const sectionId = formSection.get('sys_ui_section') const section = descendants.query('sys_ui_section').find((s) => s.getId().equals(sectionId)) if (section) { const sectionCaption = getOptionalString(section, 'caption') const { sysId: sectionViewSysId } = resolveView(section.get('view'), database) child .ele('sys_ui_section', { caption: sectionCaption, display_value: sectionCaption, name: formName, sys_domain: getOptionalString(section, 'sys_domain', 'global'), view: sectionViewSysId, }) .txt(section.getId().getValue()) } } // Add the sys_ui_form record itself const formChild = root.ele('sys_ui_form', { action: 'INSERT_OR_UPDATE', apply_defaults: 'true' }) formChild.ele('name').txt(formName) formChild.ele('roles').txt(getOptionalString(form, 'roles')) formChild.ele('sys_id').txt(form.getId().getValue()) formChild.ele('sys_scope', { display_value: config.scope }).txt(config.scopeId) formChild.ele('sys_user').txt(getOptionalString(form, 'sys_user')) const isDefaultFormView = formViewSysId === DEFAULT_VIEW const formViewAttrs = isDefaultFormView ? { name: 'NULL', display_value: DEFAULT_VIEW } : formViewName ? { name: formViewName, display_value: formViewDisplayValue || formViewName } : {} formChild.ele('view', formViewAttrs).txt(formViewSysId) formChild.ele('view_name') return { success: true, value: { source: form, name: `sys_ui_form_sections_${form.getId().getValue()}.xml`, category: form.getInstallCategory(), content: xml.end({ prettyPrint: true }), }, } }, async diff(existingDB, incomingDB, _, { factory }) { /** * Exclude sys_ui_section and sys_ui_element records from this diff to handle incremental updates correctly. * * Why: If a user modifies only a UI section during incremental transformation, we don't want that change * to trigger a diff in sys_ui_form that would incorrectly remove form_sections. Instead, sys_ui_section * and sys_ui_element records are compared separately using their own dedicated diff function. */ const incoming = incomingDB.query().filter((r) => FORM_XML_TABLES.includes(r.getTable())) const existing = existingDB.query().filter((r) => FORM_XML_TABLES.includes(r.getTable())) // If either database is empty, return the incoming as-is or empty database if (incoming.length === 0 || existing.length === 0) { return { success: true, value: incoming.length === 0 ? new Database() : new Database(incoming), } } const changeDatabase = new Database() let hasChanges = false // Get the main records const existingForm = existing.filter((r) => r.getTable() === 'sys_ui_form')[0] const incomingForm = incoming.filter((r) => r.getTable() === 'sys_ui_form')[0] const existingFormSections = existing.filter((r) => r.getTable() === 'sys_ui_form_section') const incomingFormSections = incoming.filter((r) => r.getTable() === 'sys_ui_form_section') // 1. Compare and merge the main form record if (incomingForm && existingForm) { if (!existingForm.strictEquals(incomingForm)) { changeDatabase.insert(existingForm.merge(incomingForm)) hasChanges = true } } else if (incomingForm) { changeDatabase.insert(incomingForm) hasChanges = true } // 2. Compare and merge sys_ui_form_section records (join table) const markFormSectionsForRemoval: Record[] = [] for (const formSection of existingFormSections) { const match = incomingDB.resolve(formSection.getId()) if (!match) { hasChanges = true markFormSectionsForRemoval.push(formSection) } else { hasChanges = hasChanges || !formSection.strictEquals(match) changeDatabase.insert(formSection.merge(match)) } } incomingFormSections.forEach((formSection) => { const match = changeDatabase.resolve(formSection.getId()) if (!match) { changeDatabase.insert(formSection) hasChanges = true } }) // Delete removed form sections for (const formSection of markFormSectionsForRemoval) { const deleteRecord = await factory.createRecord({ source: formSection.getSource(), table: 'sys_ui_form_section', explicitId: formSection.getId(), properties: formSection.properties(), action: 'DELETE', }) changeDatabase.insert(deleteRecord) } return { success: true, value: hasChanges ? changeDatabase : new Database(), } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics }) { if (callExpression.getCallee() !== 'Form') { return { success: false } } const arg = callExpression.getArgument(0).asObject() const tableArg = arg.get('table') const tableName = tableArg.ifString() // View handling: supports Record, RecordId, and string (aligned with ListPlugin) const viewArg = arg.get('view') let viewReference: Record | RecordId | string if (viewArg.isRecord()) { viewReference = viewArg.asRecord() } else if (viewArg.isRecordId()) { viewReference = viewArg.asRecordId() } else if (viewArg.isString()) { const stringValue = viewArg.asString().getValue() // Diagnostic 1: 'Default view' string literal footgun if (stringValue === DEFAULT_VIEW) { diagnostics.error( viewArg, `Do not use the hard-coded string '${DEFAULT_VIEW}' as the view. ` + `Use the exported 'default_view' identifier instead: view: default_view` ) return { success: false } } viewReference = await factory.createReference({ source: callExpression, table: 'sys_ui_view', keys: { name: stringValue }, }) } else { // Default for any other case viewReference = DEFAULT_VIEW } // Process roles if they exist as an array let rolesValue = '' const rolesArray = arg.get('roles').ifArray()?.getElements() ?? [] if (rolesArray.length > 0) { rolesValue = rolesArray .map((role) => { if (role.isString()) { return role.asString().getValue() } else if (role.isObject()) { return role.get('name')?.ifString()?.getValue() ?? '' } return '' }) .filter((name) => name !== '') .join(',') } // Create the main form record const form = await factory.createRecord({ source: callExpression, table: 'sys_ui_form', properties: arg.transform(({ $ }) => ({ name: $.val(tableName), view: $.val(viewReference), sys_user: $.from('user'), sys_domain: $.val('global'), roles: $.val(rolesValue).def(''), })), }) // Process all sections const sections = arg.get('sections').ifArray()?.getElements() || [] if (sections.length === 0) { diagnostics.error(arg.get('sections'), `Form does not have any sections. Add at least one section.`) } const sectionRecords: Record[] = [] const formSectionRecords: Record[] = [] const elementRecords: Record[] = [] const seenCaptions = new Map() const globalSeenFields = new Set() for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { const sectionObj = sections[sectionIndex]?.asObject() if (!sectionObj) { continue } const caption = sectionObj.get('caption').ifString()?.getValue() ?? '' // Diagnostic 2: Empty section caption if (!caption.trim()) { diagnostics.warn(sectionObj.get('caption'), `Section caption cannot be empty.`) } // Diagnostic 3: Duplicate section captions // Note: When two sections share the same caption, fluent merges them into // a single section on the instance due to coalesce keys. This may be unexpected if captions collide by accident. if (caption.trim()) { const prevIndex = seenCaptions.get(caption) if (prevIndex !== undefined) { diagnostics.error( sectionObj.get('caption'), `Duplicate section caption '${caption}'. Each section must have a unique caption.` ) } else { seenCaptions.set(caption, sectionIndex) } } // Create the section record const section = await factory.createRecord({ source: callExpression, table: 'sys_ui_section', properties: sectionObj.transform(({ $ }) => ({ name: $.val(tableName), caption: $.val(caption), title: $.def(false), header: $.def(false), view: $.val(viewReference), sys_domain: $.val('global'), })), }) sectionRecords.push(section) // Create the form-section linking record const formSection = await factory.createRecord({ source: callExpression, table: 'sys_ui_form_section', properties: arg.transform(({ $ }) => ({ sys_ui_form: $.val(form), sys_ui_section: $.val(section.getId()), position: $.val(sectionIndex), })), }) formSectionRecords.push(formSection) // Process content layout blocks await processContentBlocks( arg, sectionObj, callExpression, factory, section, elementRecords, diagnostics, caption, globalSeenFields ) } return { success: true, value: form.with(...sectionRecords, ...formSectionRecords, ...elementRecords), } }, }, ], }) /** * Creates a sys_ui_element record */ async function createUiElement( arg: ObjectShape, callExpression: CallExpressionShape, factory: Factory, section: Record, element: string, position: number, type: string = '', formatter: string = '' ) { return await factory.createRecord({ source: callExpression, table: 'sys_ui_element', properties: arg.transform(({ $ }) => ({ sys_ui_section: $.val(section.getId()), element: $.val(element), position: $.val(position), type: $.val(type), sys_ui_formatter: $.val(formatter), })), }) } /** * Processes the content layout blocks in a form section and creates UI element records. * Reads the `content` array of layout blocks (one-column / two-column) and flattens them * into sys_ui_element records with proper positioning and split markers. */ async function processContentBlocks( arg: ObjectShape, sectionObj: ObjectShape, callExpression: CallExpressionShape, factory: Factory, section: Record, elementRecords: Record[], diagnostics: Diagnostics, caption: string, globalSeenFields: Set ) { const contentBlocks = sectionObj.get('content').ifArray()?.getElements() || [] let position = 0 const seenFields = new Set() // Diagnostic 6: Empty content blocks if (contentBlocks.length === 0) { diagnostics.warn( sectionObj.get('content'), `Section '${caption}' has no content blocks. The section will be empty on the form.` ) } /** Processes an array of element shapes, reporting errors for non-object entries. */ async function processElements( elements: ReturnType>['getElements']>, context: string ) { for (const elem of elements) { if (!elem || !elem.isObject()) { diagnostics.error( elem ?? sectionObj, `Invalid element in ${context} of section '${caption}'. Each element must be an object.` ) continue } position = await processFormElement( arg, callExpression, factory, section, elementRecords, elem.asObject(), position, diagnostics, seenFields, caption, globalSeenFields ) } } for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) { const block = contentBlocks[blockIdx] if (!block || !block.isObject()) { diagnostics.error( block ?? sectionObj, `Content block at index ${blockIdx} in section '${caption}' must be an object.` ) continue } const blockObj = block.asObject() const layout = blockObj.get('layout').ifString()?.getValue() if (!layout || (layout !== 'one-column' && layout !== 'two-column')) { diagnostics.error( blockObj.get('layout'), `Layout block requires 'layout' property set to 'one-column' or 'two-column'.` ) continue } if (layout === 'one-column') { await processElements(blockObj.get('elements').ifArray()?.getElements() || [], 'one-column block') } else { // Diagnostic 5: Empty two-column sides const leftElements = blockObj.get('leftElements').ifArray()?.getElements() || [] const rightElements = blockObj.get('rightElements').ifArray()?.getElements() || [] if (leftElements.length === 0) { diagnostics.warn( blockObj.get('leftElements'), `Two-column layout block at index ${blockIdx} in section '${caption}' has empty 'leftElements'. Consider using a one-column layout instead.` ) } if (rightElements.length === 0) { diagnostics.warn( blockObj.get('rightElements'), `Two-column layout block at index ${blockIdx} in section '${caption}' has empty 'rightElements'. Consider using a one-column layout instead.` ) } // two-column: emit .begin_split, left elements, .split, right elements, .end_split elementRecords.push( await createUiElement( arg, callExpression, factory, section, SPLIT_TYPE.BEGIN, position++, SPLIT_TYPE.BEGIN ) ) await processElements(leftElements, 'two-column leftElements') elementRecords.push( await createUiElement( arg, callExpression, factory, section, SPLIT_TYPE.MIDDLE, position++, SPLIT_TYPE.MIDDLE ) ) await processElements(rightElements, 'two-column rightElements') elementRecords.push( await createUiElement(arg, callExpression, factory, section, SPLIT_TYPE.END, position++, SPLIT_TYPE.END) ) } } } /** * Processes a single form element object and creates the appropriate record(s). * Handles table_field, annotation, formatter, and list element types. * Returns the next position index. */ async function processFormElement( arg: ObjectShape, callExpression: CallExpressionShape, factory: Factory, section: Record, elementRecords: Record[], field: ObjectShape, position: number, diagnostics: Diagnostics, seenFields: Set, caption: string, globalSeenFields: Set ): Promise { const typeField = field.get('type').ifString()?.getValue() ?? '' const validTypes = ['table_field', 'annotation', 'formatter', 'list'] if (!validTypes.includes(typeField)) { diagnostics.error( field.get('type'), `Invalid type '${typeField}'. Valid types are: ${validTypes.map((t) => `'${t}'`).join(', ')}` ) return position } // ── table_field ── if (typeField === 'table_field') { const fieldName = field.get('field').ifString()?.getValue() ?? '' if (!fieldName) { diagnostics.error(field.get('field'), `Table field element requires a 'field' property.`) return position } // Duplicate field check within the same section if (seenFields.has(fieldName)) { diagnostics.error( field, `Duplicate field '${fieldName}' in section '${caption}'. Each field should only appear once per section.` ) } seenFields.add(fieldName) // Diagnostic 4: Duplicate field across sections if (globalSeenFields.has(fieldName)) { diagnostics.warn( field, `Field '${fieldName}' already appears in another section. Duplicate fields across sections may cause unexpected behavior on the form.` ) } globalSeenFields.add(fieldName) elementRecords.push(await createUiElement(arg, callExpression, factory, section, fieldName, position)) return position + 1 } // ── annotation ── if (typeField === 'annotation') { const annotationIdField = field.get('annotationId') if ( !(annotationIdField instanceof ElementAccessExpressionShape && annotationIdField.getCallee() === 'Now.ID') ) { diagnostics.error(annotationIdField, `'annotationId' must be a Now.ID['...'] reference.`) return position } const explicitId: Shape = annotationIdField const textValue = field.get('text').ifString()?.getValue() ?? '' const isPlainText = field.get('isPlainText').ifBoolean()?.getValue() ?? true // Handle annotationType - can be AnnotationType record or string GUID const annotationTypeArg = field.get('annotationType') let annotationTypeValue = DEFAULT_ANNOTATION_TYPE if (annotationTypeArg) { if (annotationTypeArg.isRecord()) { annotationTypeValue = annotationTypeArg.asRecord().getId().getValue() } else if (annotationTypeArg.isString()) { const annotationTypeStr = annotationTypeArg.asString().getValue() if (annotationTypeStr in AnnotationType) { annotationTypeValue = AnnotationType[annotationTypeStr as keyof typeof AnnotationType] } else if (isGUID(annotationTypeStr)) { annotationTypeValue = annotationTypeStr } else { showGuidFieldDiagnostic(annotationTypeArg, 'annotationType', 'sys_ui_annotation_type', diagnostics) } } } // Create sys_ui_annotation record const annotationRecord = await factory.createRecord({ source: callExpression, table: 'sys_ui_annotation', explicitId: explicitId, properties: arg.transform(({ $ }) => ({ text: $.val(textValue), is_plain_text: $.val(isPlainText).def(true), type: $.val(annotationTypeValue).def(''), })), }) elementRecords.push(annotationRecord) // Create sys_ui_element with element = annotation sys_id elementRecords.push( await createUiElement( arg, callExpression, factory, section, annotationRecord.getId().getValue(), position, 'annotation' ) ) return position + 1 } // ── formatter ── if (typeField === 'formatter') { const formatterRefArg = field.get('formatterRef') let formatterRefValue = '' let recordFormatterField = '' if (formatterRefArg) { if (formatterRefArg.isRecord()) { const record = formatterRefArg.asRecord() formatterRefValue = record.getId().getValue() // Try to read the `formatter` column from the Record data recordFormatterField = record.get('formatter')?.ifString()?.getValue() ?? '' } else if (formatterRefArg.isString()) { const formatterStr = formatterRefArg.asString().getValue() if (formatterStr in Formatter) { formatterRefValue = Formatter[formatterStr as keyof typeof Formatter] } else if (isGUID(formatterStr)) { formatterRefValue = formatterStr } else { showGuidFieldDiagnostic(formatterRefArg, 'formatterRef', 'sys_ui_formatter', diagnostics) } } } // formatterName is optional — derive in order: explicit > Record `formatter` field > FORMATTER_ELEMENT_MAP > empty let formatterName = field.get('formatterName').ifString()?.getValue() ?? '' if (!formatterName && recordFormatterField) { formatterName = recordFormatterField } if (!formatterName && formatterRefValue) { formatterName = FORMATTER_ELEMENT_MAP.get(formatterRefValue) ?? '' } elementRecords.push( await createUiElement( arg, callExpression, factory, section, formatterName, position, 'formatter', formatterRefValue ) ) return position + 1 } // ── list ── if (typeField === 'list') { const listType = field.get('listType').ifString()?.getValue() ?? '' if (!listType || !['12M', 'M2M', 'custom'].includes(listType)) { diagnostics.error( field.get('listType'), `List element requires 'listType' set to '12M', 'M2M', or 'custom'.` ) return position } let elementValue = '' const tableName = arg.get('table').asString().getValue() const listRefArg = field.get('listRef') if (listType === 'custom') { // Custom lists use Record<'sys_relationship'> reference or string GUID via 'listRef' key if (listRefArg.isRecord()) { const relSysId = listRefArg.asRecord().getId().getValue() elementValue = `REL.${tableName}.REL:${relSysId}` } else if (listRefArg.isString()) { const relStr = listRefArg.asString().getValue() if (isGUID(relStr)) { elementValue = `REL.${tableName}.REL:${relStr}` } else { showGuidFieldDiagnostic(listRefArg, 'listRef', 'sys_relationship', diagnostics) return position } } else { diagnostics.error( listRefArg, `Custom list requires 'listRef' with a Record<'sys_relationship'> reference or a sys_relationship sys_id string (GUID).` ) return position } } else { // 12M and M2M use listRef as 'table.column' dot-notation string if (!listRefArg.isString()) { diagnostics.error( field, `List element requires 'listRef' as a dot-notation string '.' for '${listType}' type.` ) return position } const listRefStr = listRefArg.asString().getValue() const parts = listRefStr.split('.') if (parts.length !== 2 || !parts[0] || !parts[1]) { diagnostics.error( listRefArg, `Invalid 'listRef' format '${listRefStr}'. Expected '.' (e.g., 'task_sla.task').` ) return position } const [listTable, listColumn] = parts elementValue = `${listType}.${tableName}.${listTable}.${listColumn}` } elementRecords.push( await createUiElement(arg, callExpression, factory, section, elementValue, position, 'list') ) return position + 1 } return position }