From 9490247c11569f2135d5b5e897b4e7d239e1ae09 Mon Sep 17 00:00:00 2001 From: Tom Carman Date: Thu, 4 Jul 2024 20:53:17 +0100 Subject: [PATCH] feat: various experimental features --- src/common/experimental/FlowMermaid.ts | 90 ++++++++++++++++++++++ src/common/experimental/FlowNodeTypes.ts | 33 ++++++++ src/common/experimental/FlowNodeWrapper.ts | 10 ++- src/common/experimental/FlowWalker.ts | 55 +------------ src/common/experimental/FlowWrapper.ts | 38 +-------- src/common/util.ts | 76 ++++++++++++++++++ src/rules/no-dml-in-flow-for-loop.ts | 26 +++++-- 7 files changed, 231 insertions(+), 97 deletions(-) create mode 100644 src/common/experimental/FlowMermaid.ts diff --git a/src/common/experimental/FlowMermaid.ts b/src/common/experimental/FlowMermaid.ts new file mode 100644 index 0000000..3aac62e --- /dev/null +++ b/src/common/experimental/FlowMermaid.ts @@ -0,0 +1,90 @@ +import type { FlowNodeWrapper } from './FlowNodeWrapper.js'; + +export function generateMermaid(nodes: FlowNodeWrapper[]): void { + console.log('\n\nGenerating Mermaid flow...'); + console.log(generateMermaidFlowWithStyle(nodes)); +} + +export function generateMermaidFlowWithStyle(nodes: FlowNodeWrapper[]): string { + let mermaid = '```mermaid\n'; + mermaid += ` + %%{ init: { 'flowchart': { 'curve': 'stepAfter', 'htmlLabels': false } } }%% + flowchart TD + `; + + mermaid += ` + classDef orangeNodes fill:#FF5D2D,stroke:#FF5D2D,stroke-width:2px,color:#FFF; + classDef pinkNodes fill:#FF538A,stroke:#FF538A,stroke-width:2px,color:#FFF; + classDef blueNodes fill:#032D60,stroke:#032D60,stroke-width:2px,color:#FFF; + classDef lightBlueNodes fill:#1B96FF,stroke:#1B96FF,stroke-width:2px,color:#FFF; + classDef start fill:#0B827C,stroke:#0B827C,stroke-width:2px,color:#FFF; + classDef terminators fill:#EA001E,stroke:#EA001E,stroke-width:2px,color:#FFF,shape:circle; + `; + + nodes.sort((a, b) => { + const aLocation = a.location ? a.location[1] : 0; + const bLocation = b.location ? b.location[1] : 0; + return aLocation - bLocation; + }); + + const orangeNodeTypes = ['assignments', 'decisions', 'loops', 'transforms', 'collectionProcessors', 'waits']; + const pinkNodeTypes = ['recordCreates', 'recordDeletes', 'recordLookups', 'recordUpdates', 'recordRollbacks']; + const blueNodeTypes = ['actionCalls', 'subflows', 'customErrors', 'apexPluginCalls', 'orchestratedStages', 'steps']; + const lightBlueNodeTypes = ['screens']; + + nodes.forEach((node) => { + const nodeTypeText = node.type === 'start' ? `\`**${node.label}**\`` : `\`**${node.label}**\n${node.typeLabel}\``; + node.connectors.forEach((connection) => { + const source = node.name.replace(/ /g, '_'); + const target = connection.targetReference.replace(/ /g, '_'); + const label = connection.connectionLabel ? `|"${connection.connectionLabel}"|` : ''; + + if (connection.type === 'Terminator') { + mermaid += ` ${source}["${nodeTypeText}"] --> ${label} ${source}_end(("End"))\n`; + } else { + mermaid += ` ${source}["${nodeTypeText}"] --> ${label} ${target}["\`**${ + nodes.find((n) => n.name === connection.targetReference)?.label + }**\n${nodes.find((n) => n.name === connection.targetReference)?.typeLabel}\`"]\n`; + } + }); + }); + + nodes.forEach((node) => { + const nodeName = node.name.replace(/ /g, '_'); + const nodeTypeText = node.type === 'start' ? `\`**${node.label}**\`` : `\`**${node.label}**\n${node.typeLabel}\``; + let nodeClass = ''; + + if (orangeNodeTypes.includes(node.type)) { + nodeClass = 'orangeNodes'; + mermaid += ` ${nodeName}${node.type === 'decisions' ? `{"${nodeTypeText}"}` : `("${nodeTypeText}")`}\n`; + } else if (pinkNodeTypes.includes(node.type)) { + nodeClass = 'pinkNodes'; + mermaid += ` ${nodeName}("${nodeTypeText}")\n`; + } else if (blueNodeTypes.includes(node.type)) { + nodeClass = 'blueNodes'; + mermaid += ` ${nodeName}("${nodeTypeText}")\n`; + } else if (lightBlueNodeTypes.includes(node.type)) { + nodeClass = 'lightBlueNodes'; + mermaid += ` ${nodeName}("${nodeTypeText}")\n`; + } else if (node.type === 'start') { + nodeClass = 'start'; + mermaid += ` ${nodeName}("${nodeTypeText}")\n`; + } + + if (nodeClass) { + mermaid += ` class ${nodeName} ${nodeClass};\n`; + } + }); + + nodes.forEach((node) => { + node.connectors.forEach((connection) => { + if (connection.type === 'Terminator') { + const nodeName = node.name.replace(/ /g, '_') + '_end'; + mermaid += ` class ${nodeName} terminators;\n`; + } + }); + }); + + mermaid += '```'; + return mermaid; +} diff --git a/src/common/experimental/FlowNodeTypes.ts b/src/common/experimental/FlowNodeTypes.ts index 3ceaa16..3ef5361 100644 --- a/src/common/experimental/FlowNodeTypes.ts +++ b/src/common/experimental/FlowNodeTypes.ts @@ -1,4 +1,5 @@ import { + Flow, FlowAssignment, FlowCollectionProcessor, FlowCustomError, @@ -51,3 +52,35 @@ export type AnyFlowNode = export type AnyFlowElement = [ FlowChoice | FlowConstant | FlowDynamicChoiceSet | FlowFormula | FlowStage | FlowTextTemplate | FlowVariable ]; + +export const FlowNodeTypes: Array = [ + 'assignments', + 'collectionProcessors', + 'customErrors', + 'recordRollbacks', + 'screens', + 'subflows', + 'transforms', + 'actionCalls', + 'apexPluginCalls', + 'orchestratedStages', + 'recordCreates', + 'recordDeletes', + 'recordLookups', + 'recordUpdates', + 'waits', + 'decisions', + 'loops', + 'steps', + 'start', +]; + +export const FlowElementTypes: Array = [ + 'choices', + 'constants', + 'dynamicChoiceSets', + 'formulas', + 'stages', + 'textTemplates', + 'variables', +]; diff --git a/src/common/experimental/FlowNodeWrapper.ts b/src/common/experimental/FlowNodeWrapper.ts index 2ea4488..63515d6 100644 --- a/src/common/experimental/FlowNodeWrapper.ts +++ b/src/common/experimental/FlowNodeWrapper.ts @@ -1,5 +1,5 @@ import type { FlowConnector } from '@salesforce/types/metadata'; -import { arrayify } from '../util.js'; +import { arrayify, getFlowComponentTypeLabel } from '../util.js'; import type { AnyFlowNode } from './FlowNodeTypes.js'; type Connector = FlowConnector & { @@ -9,16 +9,20 @@ type Connector = FlowConnector & { export class FlowNodeWrapper { public type: string; + public typeLabel: string; public name: string; + public label: string; public location?: [number, number]; - // public data: AnyFlowNode; + public data: AnyFlowNode; public connectors: Connector[] = []; public constructor(typeOfNode: string, node: AnyFlowNode) { this.type = typeOfNode; + this.typeLabel = getFlowComponentTypeLabel(typeOfNode, node); this.name = typeOfNode === 'start' ? 'Start' : node.name ?? 'Unknown Node Name'; + this.label = node.label ?? this.name; this.location = [node.locationX, node.locationY]; - // this.data = node; + this.data = node; this.buildConnections(node); this.buildTerminators(); } diff --git a/src/common/experimental/FlowWalker.ts b/src/common/experimental/FlowWalker.ts index 0b81d72..48f195c 100644 --- a/src/common/experimental/FlowWalker.ts +++ b/src/common/experimental/FlowWalker.ts @@ -30,7 +30,7 @@ export function walk(flow: FlowWrapper): void { } } -type PathEntry = { +export type PathEntry = { nodeName: string; nodeType: string; nodeLocation?: [number, number]; @@ -104,59 +104,6 @@ export function getPaths(flow: FlowWrapper): PathEntry[][] { return paths; } -export function generateMermaid(nodes: FlowNodeWrapper[]): void { - console.log('\n\nGenerating Mermaid flow...'); - console.log(generateMermaidFlow(nodes)); - console.log('\n\nGenerating State flow...'); - console.log(generateStateDiagram(nodes)); -} - -export function generateMermaidFlow(nodes: FlowNodeWrapper[]): string { - let mermaid = 'flowchart TD\n'; - - nodes.sort((a, b) => { - const aLocation = a.location ? a.location[1] : 0; - const bLocation = b.location ? b.location[1] : 0; - return aLocation - bLocation; - }); - - nodes.forEach((node) => { - node.connectors.forEach((connection) => { - const source = node.name.replace(/ /g, '_'); - const target = connection.targetReference.replace(/ /g, '_'); - const label = connection.connectionLabel ? `|"${connection.connectionLabel}"|` : ''; - - if (connection.type === 'Terminator') { - mermaid += ` ${source}["${node.name}"] --> ${label} ${source}_end["End"]\n`; - } else { - mermaid += ` ${source}["${node.name}"] --> ${label} ${target}["${ - nodes.find((n) => n.name === connection.targetReference)?.name - }"]\n`; - } - }); - }); - - return mermaid; -} - -function generateStateDiagram(nodes: FlowNodeWrapper[]): string { - let mermaid = 'stateDiagram-v2\n'; - - nodes.forEach((node) => { - node.connectors.forEach((connection) => { - const source = node.name.replace(/ /g, '_'); - const target = connection.targetReference.replace(/ /g, '_'); - const label = connection.connectionLabel ? `: ${connection.connectionLabel}` : ''; - - if (connection.type !== 'Terminator') { - mermaid += ` ${source} --> ${target}${label}\n`; - } - }); - }); - - return mermaid; -} - function printNode(node: FlowNodeWrapper): void { console.log('\n\n--- NODE ---'); console.log('Name: ', node.name); diff --git a/src/common/experimental/FlowWrapper.ts b/src/common/experimental/FlowWrapper.ts index 11fd512..ee6af96 100644 --- a/src/common/experimental/FlowWrapper.ts +++ b/src/common/experimental/FlowWrapper.ts @@ -1,7 +1,7 @@ import { Flow } from '@salesforce/types/metadata'; import { FlowNodeWrapper } from './FlowNodeWrapper.js'; import { FlowElementWrapper } from './FlowElementWrapper.js'; -import { AnyFlowNode, AnyFlowElement } from './FlowNodeTypes.js'; +import { AnyFlowNode, AnyFlowElement, FlowNodeTypes, FlowElementTypes } from './FlowNodeTypes.js'; export class FlowWrapper { public flowName: string; @@ -11,39 +11,7 @@ export class FlowWrapper { public constructor(flow: Flow) { this.flowName = flow.label ?? ''; - const flowNodes: Array = [ - 'assignments', - 'collectionProcessors', - 'customErrors', - 'recordRollbacks', - 'screens', - 'subflows', - 'transforms', - 'actionCalls', - 'apexPluginCalls', - 'orchestratedStages', - 'recordCreates', - 'recordDeletes', - 'recordLookups', - 'recordUpdates', - 'waits', - 'decisions', - 'loops', - 'steps', - 'start', - ]; - - const flowElements: Array = [ - 'choices', - 'constants', - 'dynamicChoiceSets', - 'formulas', - 'stages', - 'textTemplates', - 'variables', - ]; - - flowNodes.forEach((property) => { + FlowNodeTypes.forEach((property) => { if (flow[property] !== undefined) { if (Array.isArray(flow[property])) { (flow[property] as AnyFlowNode[]).forEach((node) => { @@ -55,7 +23,7 @@ export class FlowWrapper { } }); - flowElements.forEach((property) => { + FlowElementTypes.forEach((property) => { if (flow[property] !== undefined) { if (Array.isArray(flow[property])) { (flow[property] as unknown as AnyFlowElement[]).forEach((node) => { diff --git a/src/common/util.ts b/src/common/util.ts index 352bed0..3822599 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -4,8 +4,15 @@ import { Messages } from '@salesforce/core'; import { XMLParser } from 'fast-xml-parser'; import { encode } from 'html-entities'; import indexToPosition from 'index-to-position'; +import { + FlowApexPluginCall, + FlowRecordCreate, + FlowCollectionProcessor, + FlowActionCall, +} from '@salesforce/types/metadata'; import { warningsCache } from '../commands/metalint/run.js'; import type { Location } from './types.js'; +import type { AnyFlowNode } from './experimental/FlowNodeTypes.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sf-metadata-linter', 'metalint.utils'); @@ -88,3 +95,72 @@ export function getCustomMetadata(files: string[], types?: string[], excludeName return true; }); } + +export function getFlowComponentTypeLabel(typeOfNode: string, node: AnyFlowNode): string { + if (flowComponentLabels.has(typeOfNode)) { + return flowComponentLabels.get(typeOfNode)!; + } + switch (typeOfNode) { + case 'recordCreates': + return getRecordCreatesLabel(node as FlowRecordCreate); + case 'collectionProcessors': + return getCollectionProcessorsLabel(node as FlowCollectionProcessor); + case 'actionCalls': + return getActionCallsLabel(node as FlowActionCall); + case 'apexPluginCalls': + return getApexPluginCallsLabel(node as FlowApexPluginCall); + default: + return typeOfNode; + } +} + +const flowComponentLabels = new Map([ + ['start', 'Start'], + ['assignments', 'Assignment'], + ['screens', 'Screen'], + ['subflows', 'Subflow'], + ['recordDeletes', 'Delete Records'], + ['recordLookups', 'Get Records'], + ['recordUpdates', 'Update Records'], + ['customErrors', 'Custom Error'], + ['recordRollbacks', 'Roll Back Records'], + ['transforms', 'Transform'], + ['decisions', 'Decision'], + ['loops', 'Loop'], + ['steps', 'Step'], + ['orchestratedStages', 'Orchestrated Stage'], + ['waits', 'Wait'], +]); + +function getRecordCreatesLabel(node: FlowRecordCreate): string { + return node.object?.includes('__e') ? 'Platform Event' : 'Create Records'; +} + +function getCollectionProcessorsLabel(node: FlowCollectionProcessor): string { + const typeLabels = { + SortCollectionProcessor: 'Collection Sort', + FilterCollectionProcessor: 'Collection Filter', + RecommendationMapCollectionProcessor: 'Collection Recommendation Map', + }; + return typeLabels[node.collectionProcessorType] || 'Collection'; +} + +function getActionCallsLabel(node: FlowActionCall): string { + const actionTypeLabels: { [key: string]: string } = { + apex: `Apex: ${node.actionName}`, + emailSimple: `Send Email: ${node.actionName}`, + emailSObject: `Send Email: ${node.actionName}`, + emailAlert: `Email Alert: ${node.actionName}`, + externalService: `External Service: ${node.actionName}`, + chatterPost: `Chatter Post: ${node.actionName}`, + }; + let label; + if (node.actionType !== undefined) { + label = actionTypeLabels[node.actionType]; + } + return (label ?? `Action: ${node.actionName}`) || 'Action'; +} + +function getApexPluginCallsLabel(node: FlowApexPluginCall): string { + return `Apex Plugin Call: ${node.apexClass}`; +} diff --git a/src/rules/no-dml-in-flow-for-loop.ts b/src/rules/no-dml-in-flow-for-loop.ts index b9dd66d..bd68c97 100644 --- a/src/rules/no-dml-in-flow-for-loop.ts +++ b/src/rules/no-dml-in-flow-for-loop.ts @@ -3,7 +3,8 @@ import type { Flow } from '@salesforce/types/metadata'; import { RuleClass } from '../common/types.js'; import { parseMetadataXml } from '../common/util.js'; import { FlowWrapper } from '../common/experimental/FlowWrapper.js'; -import { generateMermaid, getPaths } from '../common/experimental/FlowWalker.js'; +// import { getPaths } from '../common/experimental/FlowWalker.js'; +import { generateMermaid } from '../common/experimental/FlowMermaid.js'; export default class NoDmlInFlowForLoop extends RuleClass { public ruleId: string = 'no-missing-description-on-fields'; @@ -13,16 +14,31 @@ export default class NoDmlInFlowForLoop extends RuleClass { public endLine = 1; public execute(): void { - const flows = this.files.filter((file) => file.endsWith('.flow-meta.xml')); + const flows = this.files.filter((file) => + file.endsWith('Create_Ongoing_Advice_Review_Service_Appointment.flow-meta.xml') + ); for (const file of flows) { const fileText = fs.readFileSync(file, 'utf-8'); const flow = parseMetadataXml(fileText, 'Flow'); const flowWrapper = new FlowWrapper(flow); - // console.dir(flowWrapper.nodes, { depth: null }); + console.log('\n\n\nFlow Name: ', flowWrapper.flowName); + // console.dir(flowWrapper, { depth: null }); // walk(flowWrapper); - const paths = getPaths(flowWrapper); - console.log('Paths: ', paths.length); + // const paths = getPaths(flowWrapper); + + // let i = 0; + // paths.forEach((path) => { + // i++ + // const hash = Md5.hashStr(path.map(entry => entry.nodeName).join(' -> ')); + // console.log('i: ', i, 'Hash: ', hash, 'Path: ', path.map(entry => entry.nodeName).join(' -> ')); + // console.log('csv: ', path.map(entry => entry.nodeName).join(', '), ','); + // console.log('paths: ', paths.length); + // console.log(path.map(entry => entry.nodeName).join(' -> ')); + // }); + + // console.log(paths); + // console.log('Flow Name: ', flowWrapper.flowName, 'Paths: ', paths.length); generateMermaid(flowWrapper.nodes); } }