import { Plugin, CallExpressionShape, type Factory, type Record, type Shape, type ObjectShape, Database, } from '@servicenow/sdk-build-core' import { NowIdShape } from './now-id-plugin' import { parsePagePropChromeTabJson, createChromeTabJsonStringForPageProp } from './workspace-plugin/chrome-tab' import { createPage } from './workspace-plugin/page' import { getExplicitIdGenerator, buildRecordKey, doesActualRecordMatchGeneratedRecord, serializeActualRecordAsOverrideToTheDefault, } from './workspace-plugin/fluent-utils' const PAGE_PROP_NAME = { CHROME_TAB: 'chrome_tab', CHROME_TOOLBAR: 'chrome_toolbar', LISTCONFIGID: 'listConfigId', VIEW: 'view', WAPPLICABILITYCONFIGID: 'wbApplicabilityConfigId', CHROME_HEADER: 'chrome_header', CHROME_FOOTER: 'chrome_footer', } const EXPERIENCE_CATEGORIES = { WORKSPACE: 'afb4e3e173322010f0ca1e666bf6a726', } const DEFAULT_LANDING_PATH = 'home' const DEFAULT_LISTCONFIGID = 'd305c0a873601010a0a79329faf6a7cb' type RecordKeyToRecord = { [recordKey: string]: Record } type DefaultRecordOverrides = { [recordKey: string]: { [fieldName: string]: string } } // This "other" Transform class is not exported, need to grab it like this. type InnerTransformFromObjectShape = Parameters[0]>[0]['$'] export const WorkspacePlugin = Plugin.create({ name: 'WorkspacePlugin', records: { sys_ux_page_registry: { relationships: { sys_ux_registry_m2m_category: { via: 'page_registry', descendant: true, }, sys_ux_page_property: { via: 'page', descendant: true, }, sys_ux_app_config: { via: 'admin_panel', inverse: true, descendant: true, relationships: { sys_ux_screen: { via: 'app_config', descendant: true, relationships: { sys_ux_macroponent: { via: 'macroponent', inverse: true, descendant: true, }, }, }, sys_ux_app_route: { via: 'app_config', descendant: true, relationships: { sys_ux_screen_type: { via: 'screen_type', inverse: true, descendant: true, }, }, }, }, }, }, async toShape(pageRegRecord, { factory, descendants: descendantsDB }) { if (pageRegRecord.getCreator()?.getName() !== 'WorkspacePlugin') { return { success: false } } const excludedRecords: Record[] = [] const supportedRecords: Record[] = [] const partiallySupportedTables = new Set([ 'sys_ux_registry_m2m_category', 'sys_ux_page_property', 'sys_ux_app_config', 'sys_ux_screen', 'sys_ux_macroponent', 'sys_ux_app_route', 'sys_ux_screen_type', ]) descendantsDB.query().forEach((descendant) => { if ( partiallySupportedTables.has(descendant.getTable()) && descendant.getCreator()?.getName() !== 'WorkspacePlugin' ) { excludedRecords.push(descendant) } else { supportedRecords.push(descendant) } }) const descendants = new Database(supportedRecords) // Get 'path' and 'title' // TODO 'active' const path: string = pageRegRecord.get('path').asString().getValue() ?? '' const title: string = pageRegRecord.get('title').asString().getValue() ?? '' const active: boolean = pageRegRecord.get('active').toBoolean().getValue() ?? false // get 'landingPath' const appConfigRecords = descendants.query('sys_ux_app_config') const appConfigRecord = appConfigRecords[0] if (!appConfigRecord || appConfigRecords.length !== 1) { return { success: false } } const landingPath: string = appConfigRecord.get('landing_path').asString().getValue() ?? '' // Get 'tables' let tables: string[] = [] const pageProps = descendants.query('sys_ux_page_property') const chromeTabPageProp = pageProps.find( (prop) => prop.get('name').asString().getValue() === PAGE_PROP_NAME.CHROME_TAB ) if (chromeTabPageProp) { const chromeTab = chromeTabPageProp.get('value').asString().getValue() tables = parsePagePropChromeTabJson(chromeTab) } // Get 'listConfig' reference let listConfigRef: string | Shape = '' const listConfigIdProp = pageProps.find( (prop) => prop.get('name').asString().getValue() === PAGE_PROP_NAME.LISTCONFIGID ) if (listConfigIdProp) { const listConfig = listConfigIdProp.get('value') listConfigRef = listConfig.isString() ? await factory.createReference({ source: pageRegRecord, table: 'sys_ux_list_menu_config', guid: listConfig, }) : listConfig } let callExpressionShape = createWorkspaceCallExpression({ pageRegRecord, title, active, landingPath, path, tables, listConfigRef, }) /* * Collecting all the input props for the call expression is now complete (above). * * Now, we need to generate a snapshot of all the records it would have produced, * so that we can start the reconcile process of determining if any of our workspace * records have been tampered/edited by the user outside of the capabilities * of the workspace plugin. generateAllImplicitRecordsFromCallExpression() is the same * exact function that is used during toRecord(). * * This snapshot will be used to compare against ACTUAL records * in the database and determine which records we need to start data-dumping * into the defaultRecordOverrides field of the workspace plugin. */ const generatedRecords = await generateAllImplicitRecordsFromCallExpression({ callExpressionShape, factory, }) const queried: { [table: string]: Record[] } = {} const defaultRecordOverrides: DefaultRecordOverrides = {} Object.entries(generatedRecords).forEach(([recordKey, generatedRecord]) => { const gRecordTable = generatedRecord.getTable() const gRecordSysId = generatedRecord.getId().getValue() if (!queried[gRecordTable]) { queried[gRecordTable] = descendants.query(gRecordTable) } const actualRecord = queried[gRecordTable].find((r) => r.getId().getValue() === gRecordSysId) if (actualRecord) { const isActualRecordTamperFree = doesActualRecordMatchGeneratedRecord( actualRecord, generatedRecord ) if (!isActualRecordTamperFree) { const serializedActualRecordValue = serializeActualRecordAsOverrideToTheDefault( actualRecord, generatedRecord ) defaultRecordOverrides[recordKey] = serializedActualRecordValue } } }) callExpressionShape = createWorkspaceCallExpression({ pageRegRecord, title, active, path, landingPath, tables, listConfigRef, defaultRecordOverrides, }) if (excludedRecords.length > 0) { return { success: 'partial', value: callExpressionShape, unhandledRecords: excludedRecords, } } return { success: true, value: callExpressionShape, } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(source, { factory }) { if (source.getCallee() !== 'Workspace') { return { success: false } } const arg = source.getArgument(0).asObject() // Generate all the implicit records for a workspace based on the call expression const generatedRecords = await generateAllImplicitRecordsFromCallExpression({ callExpressionShape: source, factory, }) // Get the defaultRecordOverrides from the call expression arguments, to see if we need // to override any of the generated records. This could happen if the user modified // the records from the platform side in a way that is unsupported by this plugin. const defaultRecordOverrides = (arg.get('defaultRecordOverrides').getValue() ?? {}) as DefaultRecordOverrides // Loop through the records, patching in any overrides as needed const patchedRecords = await Promise.all( Object.entries(generatedRecords).map(async ([recordKey, generatedRecord]) => { const overridingRecord = defaultRecordOverrides[recordKey] if (!overridingRecord) { return generatedRecord } return await factory.createRecord({ source: source, table: generatedRecord.getTable(), explicitId: generatedRecord.getId(), properties: arg.transform(({ $ }) => { return generatedRecord .keys() .reduce<{ [key: string]: InnerTransformFromObjectShape }>((acc, fieldName) => { acc[fieldName] = $.val(overridingRecord[fieldName]) return acc }, {}) }), }) }) ) const records = patchedRecords const pageRegRecord = records[0] const restRecords = records.slice(1) if (!pageRegRecord) { return { success: false, error: 'No records generated, very unexpected' } } return { success: true, // Silly thing we have to do because the plugin system expects a single record value: pageRegRecord.with(...restRecords), } }, }, ], }) const generateAllImplicitRecordsFromCallExpression = async ({ callExpressionShape, factory, }: { callExpressionShape: CallExpressionShape factory: Factory }): Promise => { const arg = callExpressionShape.getArgument(0).asObject() const explicitIdGenerator = getExplicitIdGenerator(callExpressionShape, arg.get('$id').getValue() as string) const appConfig = await factory.createRecord({ source: callExpressionShape, table: 'sys_ux_app_config', explicitId: explicitIdGenerator('sys_ux_app_config', 'workspace'), properties: arg.transform(({ $ }) => ({ name: $.from('title'), landing_path: $.from('landingPath').def(DEFAULT_LANDING_PATH), active: $.val(true), description: $.val('Workspace created by Fluent'), custom_icon: $.val(''), icon: $.val(''), disable_auto_reflow: $.val(false), encode_query_string: $.val(false), })), }) const pageRegistry = await factory.createRecord({ source: callExpressionShape, table: 'sys_ux_page_registry', explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ admin_panel: $.val(appConfig), // how to reference another record admin_panel_table: $.val('sys_ux_app_config'), root_macroponent: $.val('c276387cc331101080d6d3658940ddd2'), parent_app: $.val('c86a62e2c7022010099a308dc7c26022'), title: $, path: $, active: $.toBoolean().def(true), page: $.val(''), auth_routes: $.val(''), })), }) const unifiedNav = await factory.createRecord({ source: callExpressionShape, table: 'sys_ux_registry_m2m_category', explicitId: explicitIdGenerator('sys_ux_registry_m2m_category', 'unifiedNav'), properties: arg.transform(({ $ }) => ({ experience_category: $.val(EXPERIENCE_CATEGORIES.WORKSPACE), order: $.toNumber().def(1000), page_registry: $.val(pageRegistry), })), }) const pagePropRecords = await generatePagePropRecordsFromCallExpression({ callExpressionShape, factory, appConfig, pageRegistry, }) const listPage = await createPage({ factory, arg, callExpressionShape, appConfig, type: 'list', }) const defaultRecordPage = await createPage({ factory, arg, callExpressionShape, appConfig, type: 'record', }) const dashboardPage = await createPage({ factory, arg, callExpressionShape, appConfig, type: 'home', }) const simpleListPage = await createPage({ factory, arg, callExpressionShape, appConfig, type: 'simple-list', }) const workspacePages = [...listPage, ...defaultRecordPage, ...dashboardPage, ...simpleListPage].reduce<{ [recordKey: string]: Record }>((acc, record) => { acc[buildRecordKey(record)] = record return acc }, {}) // Order matters. Since pageReg is first, it will represent the workspace plugin // when we return from the toShape() function. The other records are treated like // side effects of creating a page registry to represent the workspace. return { [buildRecordKey(pageRegistry)]: pageRegistry, [buildRecordKey(appConfig)]: appConfig, [buildRecordKey(unifiedNav)]: unifiedNav, ...pagePropRecords, ...workspacePages, } } const generatePagePropRecordsFromCallExpression = async ({ callExpressionShape, factory, appConfig, pageRegistry, }: { callExpressionShape: CallExpressionShape factory: Factory appConfig: Record pageRegistry: Record }) => { const arg = callExpressionShape.getArgument(0).asObject() const explicitIdGenerator = getExplicitIdGenerator(callExpressionShape, arg.get('$id').getValue() as string) const pagePropRecordDetails = [ { name: PAGE_PROP_NAME.CHROME_TOOLBAR, type: 'json', value: ($: InnerTransformFromObjectShape) => $.val( JSON.stringify([ { id: 'home', label: { translatable: true, message: 'Home', }, icon: 'home-fill', routeInfo: { route: 'home', }, group: 'top', order: 100, badge: {}, presence: {}, availability: {}, viewportInfo: {}, }, { id: 'list', label: { translatable: true, message: 'List', }, icon: 'list-fill', routeInfo: { route: 'list', }, group: 'top', order: 200, badge: {}, presence: {}, availability: {}, viewportInfo: {}, }, ]) ), }, { name: PAGE_PROP_NAME.VIEW, type: 'string', value: ($: InnerTransformFromObjectShape) => $.val(`workspace-${appConfig.getId().getValue()}`), }, { name: PAGE_PROP_NAME.WAPPLICABILITYCONFIGID, type: 'string', value: ($: InnerTransformFromObjectShape) => $.val(appConfig.getId().getValue()), }, { name: PAGE_PROP_NAME.CHROME_TAB, type: 'json', value: ($: InnerTransformFromObjectShape) => $.val( createChromeTabJsonStringForPageProp( arg .get('tables') .asArray() .map((item) => item.asString().getValue()) ) ), }, { name: PAGE_PROP_NAME.LISTCONFIGID, type: 'string', value: ($: InnerTransformFromObjectShape) => $.from('listConfig').def(DEFAULT_LISTCONFIGID), // Default UIB List Config }, { name: PAGE_PROP_NAME.CHROME_HEADER, type: 'json', value: ($: InnerTransformFromObjectShape) => $.val( '{"privatePage":{"userPrefsEnabled":false,"searchEnabled":false,"currentScreenLinkConfiguration":{},"globalTools":{"collapsingMenuId":0,"primaryItems":[],"secondaryItems":[]}},"publicPage":{"menuEnabled":false,"searchEnabled":false,"logoRoute":{},"actionButtons":[]}}' ), }, { name: PAGE_PROP_NAME.CHROME_FOOTER, type: 'json', value: ($: InnerTransformFromObjectShape) => $.val( '{"public_page":{"enable_footer_topbar":false,"footer_topbar_options":{},"enable_footer_bar":false,"footer_bar_options":{}}}' ), }, ] const pagePropRecords = pagePropRecordDetails.map((d) => factory.createRecord({ source: callExpressionShape, table: 'sys_ux_page_property', explicitId: explicitIdGenerator('sys_ux_page_property', d.name), properties: arg.transform(({ $ }) => ({ name: $.val(d.name), suffix: $.val(d.name), description: $.val(''), route: $.val(''), page: $.val(pageRegistry), type: $.val(d.type), value: d.value($), })), }) ) const resolvedRecords = await Promise.all(pagePropRecords) return resolvedRecords.reduce((acc, record) => { acc[buildRecordKey(record)] = record return acc }, {}) } const createWorkspaceCallExpression = ({ pageRegRecord, title, path, landingPath, active, tables, listConfigRef, defaultRecordOverrides, }: { pageRegRecord: Record title: string path: string landingPath: string active: boolean tables: string[] listConfigRef: string | Shape defaultRecordOverrides?: DefaultRecordOverrides }) => { return new CallExpressionShape({ source: pageRegRecord, callee: 'Workspace', args: [ pageRegRecord.transform(({ $ }) => { return { $id: $.val(NowIdShape.from(pageRegRecord)), landingPath: $.val(landingPath).def(DEFAULT_LANDING_PATH), active: $.val(active).def(true), title: $.val(title).def(''), path: $.val(path).def(''), tables: $.val(tables).def([]), listConfig: $.val(listConfigRef).def(DEFAULT_LISTCONFIGID), defaultRecordOverrides: $.val(defaultRecordOverrides).def({}), } }), ], }) }