diff --git a/src/main/flow_parser.ts b/src/main/flow_parser.ts index 4f972d5..fdbd4cf 100644 --- a/src/main/flow_parser.ts +++ b/src/main/flow_parser.ts @@ -67,6 +67,7 @@ export interface Transition { */ export interface ParsedFlow { label?: string; + processType?: flowTypes.FlowProcessType; start?: flowTypes.FlowStart; apexPluginCalls?: flowTypes.FlowApexPluginCall[]; assignments?: flowTypes.FlowAssignment[]; @@ -131,8 +132,10 @@ export class FlowParser { private populateFlowNodes(flow: flowTypes.Flow) { this.beingParsed.label = flow.label; + this.beingParsed.processType = flow.processType; this.beingParsed.start = flow.start; this.validateFlowStart(); + setFlowStart(this.beingParsed.start); this.beingParsed.apexPluginCalls = ensureArray(flow.apexPluginCalls); this.beingParsed.assignments = ensureArray(flow.assignments); @@ -257,7 +260,9 @@ export class FlowParser { */ private getTransitionsForNode(node: flowTypes.FlowNode): Transition[] { const transitions: Transition[] = []; - if ( + if (isFlowStart(node)) { + transitions.push(...this.getTransitionsFromFlowStart(node)); + } else if ( isRecordCreate(node) || isRecordDelete(node) || isRecordLookup(node) || @@ -431,6 +436,31 @@ export class FlowParser { } return result; } + + private getTransitionsFromFlowStart(node: flowTypes.FlowStart): Transition[] { + const result: Transition[] = []; + + // Add main flow transition + if (node.connector) { + result.push( + this.createTransition(node, node.connector, false, undefined), + ); + } + + // Add scheduled path transitions + if (node.scheduledPaths && node.scheduledPaths.length > 0) { + for (const scheduledPath of node.scheduledPaths) { + if (scheduledPath.connector) { + const label = scheduledPath.pathType; + result.push( + this.createTransition(node, scheduledPath.connector, false, label), + ); + } + } + } + + return result; + } } /** @@ -570,6 +600,33 @@ function setCustomErrorMessages( } } +/** + * Set Flow Start + * + * Flow Start filters and scheduled paths are nested properties which also + * need to be converted to arrays. + */ +function setFlowStart(start: flowTypes.FlowStart | undefined) { + if (!start) { + return; + } + if (start.filters) { + start.filters = ensureArray( + start.filters, + ) as flowTypes.FlowRecordFilter[]; + } + if (start.scheduledPaths) { + start.scheduledPaths = ensureArray( + start.scheduledPaths, + ) as flowTypes.FlowScheduledPath[]; + } + if (start.capabilityTypes) { + start.capabilityTypes = ensureArray( + start.capabilityTypes, + ) as flowTypes.FlowCapability[]; + } +} + /** * The following functions are used to determine if a node is a specific type * of node. @@ -666,3 +723,9 @@ function isCustomError( ): node is flowTypes.FlowCustomError { return (node as flowTypes.FlowCustomError).customErrorMessages !== undefined; } + +function isFlowStart( + node: flowTypes.FlowNode, +): node is flowTypes.FlowStart { + return (node as flowTypes.FlowStart).connector !== undefined; +} diff --git a/src/main/flow_types.ts b/src/main/flow_types.ts index c202ff9..cfe5d62 100644 --- a/src/main/flow_types.ts +++ b/src/main/flow_types.ts @@ -714,7 +714,7 @@ export interface FlowScheduledPath extends FlowElement { connector: FlowConnector; label: string; maxBatchSize?: number; // Default is 200 - offsetNumber: number; // Can be positive or negative + offsetNumber: string; // XML parser returns string values for numbers offsetUnit: FlowScheduledPathOffsetUnit; pathType?: FlowScheduledPathType; // Default is null recordField?: string; // Required if timeSource is RecordField @@ -824,13 +824,13 @@ export interface FlowActionCallOutputParameter { export interface FlowElementReferenceOrValue { apexValue?: string; - booleanValue?: boolean; + booleanValue?: string; // XML parser returns string values for booleans dateTimeValue?: string; // Assuming dateTime is represented as a string dateValue?: string; // Assuming date is represented as a string elementReference?: string; formulaDataType?: FormulaDataType; formulaExpression?: string; - numberValue?: number; // using number to represent double + numberValue?: string; // XML parser returns string values for numbers setupReference?: string; setupReferenceType?: string; sobjectValue?: string; diff --git a/src/main/uml_generator.ts b/src/main/uml_generator.ts index 4339e17..d3ea1df 100644 --- a/src/main/uml_generator.ts +++ b/src/main/uml_generator.ts @@ -92,6 +92,7 @@ export abstract class UmlGenerator { generateUml(): string { const result = [ this.getHeader(this.parsedFlow.label!), + this.getFlowStart(), this.processFlowElements( this.parsedFlow.apexPluginCalls, (node) => this.getFlowApexPluginCall(node), @@ -175,6 +176,110 @@ export abstract class UmlGenerator { abstract getTransition(transition: Transition): string; abstract getFooter(): string; + private getFlowStart(): string { + if (!this.parsedFlow.start) { + return ""; + } + + const start = this.parsedFlow.start; + const entryCriteria: string[] = []; + + // Add process type information from the flow + if (this.parsedFlow.processType) { + entryCriteria.push(`Process Type: ${this.parsedFlow.processType}`); + } + + // Add trigger type information + if (start.triggerType) { + entryCriteria.push(`Trigger Type: ${start.triggerType}`); + } + + // Add object information for record-triggered flows + if (start.object) { + entryCriteria.push(`Object: ${start.object}`); + } + + // Add record trigger type information + if (start.recordTriggerType) { + entryCriteria.push(`Record Trigger: ${start.recordTriggerType}`); + } + + // Add entry type information + if (start.entryType) { + entryCriteria.push(`Entry Type: ${start.entryType}`); + } + + // Add filter information + if (start.filterLogic && start.filters && start.filters.length > 0) { + entryCriteria.push(`Filter Logic: ${start.filterLogic}`); + start.filters.forEach((filter, index) => { + entryCriteria.push( + `${index + 1}. ${filter.field} ${filter.operator} ${ + toString(filter.value) + }`, + ); + }); + } + + // Add filter formula if present + if (start.filterFormula) { + entryCriteria.push(`Filter Formula: ${start.filterFormula}`); + } + + // Add schedule information for scheduled flows + if (start.schedule) { + entryCriteria.push( + `Schedule: ${start.schedule.frequency} starting ${start.schedule.startDate} at ${start.schedule.startTime}`, + ); + } + + // Add capability information + if (start.capabilityTypes && start.capabilityTypes.length > 0) { + start.capabilityTypes.forEach((capability, index) => { + entryCriteria.push( + `Capability ${index + 1}: ${capability.capabilityName}`, + ); + }); + } + + // Add form information for form-triggered flows + if (start.form) { + entryCriteria.push(`Form: ${start.form}`); + } + + // Add segment information + if (start.segment) { + entryCriteria.push(`Segment: ${start.segment}`); + } + + // Add flow run as user information + if (start.flowRunAsUser) { + entryCriteria.push(`Run As: ${start.flowRunAsUser}`); + } + + // If no specific criteria found, add a default message + if (entryCriteria.length === 0) { + entryCriteria.push("No specific entry criteria defined"); + } + + return this.toUmlString({ + id: "FLOW_START", + label: "Flow Start", + type: "Flow Start", + color: SkinColor.NONE, + icon: Icon.NONE, + diffStatus: start.diffStatus, + innerNodes: [ + { + id: "FlowStart__EntryCriteria", + type: "Flow Details", + label: "", + content: entryCriteria, + }, + ], + }); + } + private getFlowApexPluginCall(node: flowTypes.FlowApexPluginCall): string { return this.toUmlString({ id: node.name, @@ -631,10 +736,10 @@ function toString(element: flowTypes.FlowElementReferenceOrValue | undefined) { return new Date(element.dateValue).toLocaleDateString(); } if (element.numberValue) { - return element.numberValue.toString(); + return element.numberValue; } - if (element.booleanValue) { - return element.booleanValue ? "true" : "false"; + if (element.booleanValue !== undefined) { + return element.booleanValue; } return ""; } diff --git a/src/test/flow_parser_test.ts b/src/test/flow_parser_test.ts index 65a486d..75e006d 100644 --- a/src/test/flow_parser_test.ts +++ b/src/test/flow_parser_test.ts @@ -35,6 +35,23 @@ const TEST_FILES = { ), circularTransition: join(GOLDENS_PATH, "circular_transition.flow-meta.xml"), rollback: join(GOLDENS_PATH, "rollback.flow-meta.xml"), + singleFilter: join(GOLDENS_PATH, "single_filter.flow-meta.xml"), + singleScheduledPath: join( + GOLDENS_PATH, + "single_scheduled_path.flow-meta.xml", + ), + singleCapability: join(GOLDENS_PATH, "single_capability.flow-meta.xml"), + multipleStartElements: join( + GOLDENS_PATH, + "multiple_start_elements.flow-meta.xml", + ), + noStartElements: join(GOLDENS_PATH, "no_start_elements.flow-meta.xml"), + asyncPathTest: join(GOLDENS_PATH, "async_path_test.flow-meta.xml"), + multipleAsyncPaths: join(GOLDENS_PATH, "multiple_async_paths.flow-meta.xml"), + nonAsyncScheduledPath: join( + GOLDENS_PATH, + "non_async_scheduled_path.flow-meta.xml", + ), }; const NODE_NAMES = { @@ -389,4 +406,225 @@ Deno.test("FlowParser", async (t) => { ); }, ); + + await t.step( + "should ensure flow start filters are always treated as arrays", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.singleFilter), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.start); + assert(parsedFlow.start.filters); + assert(Array.isArray(parsedFlow.start.filters)); + assertEquals(parsedFlow.start.filters.length, 1); + assertEquals(parsedFlow.start.filters[0].field, "Name"); + assertEquals(parsedFlow.start.filters[0].operator, "IsNull"); + assertEquals(parsedFlow.start.filters[0].value.booleanValue, "true"); + }, + ); + + await t.step( + "should ensure flow start scheduled paths are always treated as arrays", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.singleScheduledPath), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.start); + assert(parsedFlow.start.scheduledPaths); + assert(Array.isArray(parsedFlow.start.scheduledPaths)); + assertEquals(parsedFlow.start.scheduledPaths.length, 1); + assertEquals(parsedFlow.start.scheduledPaths[0].label, "Daily"); + assertEquals(parsedFlow.start.scheduledPaths[0].offsetNumber, "1"); + assertEquals(parsedFlow.start.scheduledPaths[0].offsetUnit, "Days"); + }, + ); + + await t.step( + "should ensure flow start capability types are always treated as arrays", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.singleCapability), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.start); + assert(parsedFlow.start.capabilityTypes); + assert(Array.isArray(parsedFlow.start.capabilityTypes)); + assertEquals(parsedFlow.start.capabilityTypes.length, 1); + assertEquals( + parsedFlow.start.capabilityTypes[0].capabilityName, + "Chatter", + ); + }, + ); + + await t.step( + "should ensure flow start with multiple filters, scheduled paths, and capabilities are handled correctly", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.multipleStartElements), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.start); + + // Check filters + assert(parsedFlow.start.filters); + assert(Array.isArray(parsedFlow.start.filters)); + assertEquals(parsedFlow.start.filters.length, 2); + assertEquals(parsedFlow.start.filters[0].field, "Name"); + assertEquals(parsedFlow.start.filters[0].operator, "IsNull"); + assertEquals(parsedFlow.start.filters[0].value.booleanValue, "true"); + assertEquals(parsedFlow.start.filters[1].field, "Type"); + assertEquals(parsedFlow.start.filters[1].operator, "EqualTo"); + assertEquals(parsedFlow.start.filters[1].value.stringValue, "Prospect"); + + // Check scheduled paths + assert(parsedFlow.start.scheduledPaths); + assert(Array.isArray(parsedFlow.start.scheduledPaths)); + assertEquals(parsedFlow.start.scheduledPaths.length, 2); + assertEquals(parsedFlow.start.scheduledPaths[0].label, "Daily"); + assertEquals(parsedFlow.start.scheduledPaths[0].offsetNumber, "1"); + assertEquals(parsedFlow.start.scheduledPaths[0].offsetUnit, "Days"); + assertEquals(parsedFlow.start.scheduledPaths[1].label, "Weekly"); + assertEquals(parsedFlow.start.scheduledPaths[1].offsetNumber, "7"); + assertEquals(parsedFlow.start.scheduledPaths[1].offsetUnit, "Days"); + + // Check capability types + assert(parsedFlow.start.capabilityTypes); + assert(Array.isArray(parsedFlow.start.capabilityTypes)); + assertEquals(parsedFlow.start.capabilityTypes.length, 2); + assertEquals( + parsedFlow.start.capabilityTypes[0].capabilityName, + "Chatter", + ); + assertEquals( + parsedFlow.start.capabilityTypes[1].capabilityName, + "Lightning", + ); + }, + ); + + await t.step( + "should handle flow start with no filters, scheduled paths, or capabilities gracefully", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.noStartElements), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.start); + // These should be undefined when not present in XML + assertEquals(parsedFlow.start.filters, undefined); + assertEquals(parsedFlow.start.scheduledPaths, undefined); + assertEquals(parsedFlow.start.capabilityTypes, undefined); + }, + ); + + await t.step( + "should handle single async path correctly", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.asyncPathTest), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.transitions); + assertEquals(parsedFlow.transitions.length, 2); + + // Main flow transition (no label) + assertEquals(parsedFlow.transitions[0], { + from: START_NODE_NAME, + to: "main_update", + fault: false, + label: undefined, + }); + + // Async path transition (with path type as label) + assertEquals(parsedFlow.transitions[1], { + from: START_NODE_NAME, + to: "async_update", + fault: false, + label: "AsyncAfterCommit", + }); + }, + ); + + await t.step( + "should handle multiple async paths correctly", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.multipleAsyncPaths), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.transitions); + assertEquals(parsedFlow.transitions.length, 3); + + // Main flow transition (no label) + assertEquals(parsedFlow.transitions[0], { + from: START_NODE_NAME, + to: "main_update", + fault: false, + label: undefined, + }); + + // First async path transition + assertEquals(parsedFlow.transitions[1], { + from: START_NODE_NAME, + to: "async_update_1", + fault: false, + label: "AsyncAfterCommit", + }); + + // Second async path transition + assertEquals(parsedFlow.transitions[2], { + from: START_NODE_NAME, + to: "async_update_2", + fault: false, + label: "AsyncAfterCommit", + }); + }, + ); + + await t.step( + "should not label non-async scheduled paths as asynchronous", + async () => { + systemUnderTest = new FlowParser( + Deno.readTextFileSync(TEST_FILES.nonAsyncScheduledPath), + ); + + parsedFlow = await systemUnderTest.generateFlowDefinition(); + + assert(parsedFlow.transitions); + assertEquals(parsedFlow.transitions.length, 2); + + // Main flow transition (no label) + assertEquals(parsedFlow.transitions[0], { + from: START_NODE_NAME, + to: "main_update", + fault: false, + label: undefined, + }); + + // Non-async scheduled path transition (with path type as label) + assertEquals(parsedFlow.transitions[1], { + from: START_NODE_NAME, + to: "scheduled_update", + fault: false, + label: "RecordField", + }); + }, + ); }); diff --git a/src/test/goldens/async_path_test.flow-meta.xml b/src/test/goldens/async_path_test.flow-meta.xml new file mode 100644 index 0000000..375b68c --- /dev/null +++ b/src/test/goldens/async_path_test.flow-meta.xml @@ -0,0 +1,77 @@ + + + 64.0 + Default + async_path_test {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + async_update + + 0 + 0 + + NumberOfEmployees + + 100.0 + + + $Record + + + main_update + + 0 + 0 + + Type + + Prospect + + + $Record + + + 0 + 0 + + main_update + + and + + Description + IsNull + + false + + + Account + Create + + + async_update + + AsyncAfterCommit + + RecordAfterSave + + Active + diff --git a/src/test/goldens/circular_transition.flow-meta.xml b/src/test/goldens/circular_transition.flow-meta.xml index 84ec7e1..16c8706 100644 --- a/src/test/goldens/circular_transition.flow-meta.xml +++ b/src/test/goldens/circular_transition.flow-meta.xml @@ -29,4 +29,4 @@ myLoop - \ No newline at end of file + diff --git a/src/test/goldens/missing_transition_node.flow-meta.xml b/src/test/goldens/missing_transition_node.flow-meta.xml index ae1e64c..c570fa3 100644 --- a/src/test/goldens/missing_transition_node.flow-meta.xml +++ b/src/test/goldens/missing_transition_node.flow-meta.xml @@ -23,4 +23,4 @@ Non_Existing_Element - \ No newline at end of file + diff --git a/src/test/goldens/multiple_async_paths.flow-meta.xml b/src/test/goldens/multiple_async_paths.flow-meta.xml new file mode 100644 index 0000000..df5900e --- /dev/null +++ b/src/test/goldens/multiple_async_paths.flow-meta.xml @@ -0,0 +1,96 @@ + + + 64.0 + Default + multiple_async_paths {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + async_update_1 + + 0 + 0 + + NumberOfEmployees + + 100.0 + + + $Record + + + async_update_2 + + 0 + 0 + + Industry + + Technology + + + $Record + + + main_update + + 0 + 0 + + Type + + Prospect + + + $Record + + + 0 + 0 + + main_update + + and + + Description + IsNull + + false + + + Account + Create + + + async_update_1 + + AsyncAfterCommit + + + + async_update_2 + + AsyncAfterCommit + + RecordAfterSave + + Active + diff --git a/src/test/goldens/multiple_elements.flow-meta.xml b/src/test/goldens/multiple_elements.flow-meta.xml index a333786..7ed6600 100644 --- a/src/test/goldens/multiple_elements.flow-meta.xml +++ b/src/test/goldens/multiple_elements.flow-meta.xml @@ -68,7 +68,7 @@ myRecordDelete - + myRecordDelete2 @@ -80,7 +80,7 @@ myRecordRollback - + myRecordRollback2 @@ -125,4 +125,4 @@ myActionCall2 - \ No newline at end of file + diff --git a/src/test/goldens/multiple_start_elements.flow-meta.xml b/src/test/goldens/multiple_start_elements.flow-meta.xml new file mode 100644 index 0000000..410dc3c --- /dev/null +++ b/src/test/goldens/multiple_start_elements.flow-meta.xml @@ -0,0 +1,59 @@ + + + + AutoLaunchedFlow + + 50 + 0 + + Assignment1 + + and + + Name + IsNull + + true + + + + Type + EqualTo + + Prospect + + + + + 1 + Days + + + + 7 + Days + + + Chatter + + + Lightning + + + + Assignment1 + + 200 + 0 + + $Record + Description + + Updated + + + + END + + + diff --git a/src/test/goldens/no_start_elements.flow-meta.xml b/src/test/goldens/no_start_elements.flow-meta.xml new file mode 100644 index 0000000..333ddfa --- /dev/null +++ b/src/test/goldens/no_start_elements.flow-meta.xml @@ -0,0 +1,28 @@ + + + + AutoLaunchedFlow + + 50 + 0 + + Assignment1 + + + + Assignment1 + + 200 + 0 + + $Record + Description + + Updated + + + + END + + + diff --git a/src/test/goldens/no_start_node.flow-meta.xml b/src/test/goldens/no_start_node.flow-meta.xml index 0179010..d932858 100644 --- a/src/test/goldens/no_start_node.flow-meta.xml +++ b/src/test/goldens/no_start_node.flow-meta.xml @@ -15,5 +15,4 @@ limitations under the License. --> - - \ No newline at end of file + diff --git a/src/test/goldens/non_async_scheduled_path.flow-meta.xml b/src/test/goldens/non_async_scheduled_path.flow-meta.xml new file mode 100644 index 0000000..47c90c6 --- /dev/null +++ b/src/test/goldens/non_async_scheduled_path.flow-meta.xml @@ -0,0 +1,78 @@ + + + 64.0 + Default + non_async_scheduled_path {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + scheduled_update + + 0 + 0 + + Type + + Prospect + + + $Record + + + main_update + + 0 + 0 + + Status + + Active + + + $Record + + + 0 + 0 + + main_update + + and + + Description + IsNull + + false + + + Account + Create + + + scheduled_update + + RecordField + + RecordAfterSave + + Active + diff --git a/src/test/goldens/rollback.flow-meta.xml b/src/test/goldens/rollback.flow-meta.xml index 06f3270..b5698ec 100644 --- a/src/test/goldens/rollback.flow-meta.xml +++ b/src/test/goldens/rollback.flow-meta.xml @@ -26,7 +26,7 @@ myRecordLookup - myRecordRollback + myRecordRollback @@ -38,4 +38,4 @@ myScreen - \ No newline at end of file + diff --git a/src/test/goldens/sample.flow-meta.xml b/src/test/goldens/sample.flow-meta.xml index 066a821..665b7e8 100644 --- a/src/test/goldens/sample.flow-meta.xml +++ b/src/test/goldens/sample.flow-meta.xml @@ -16,209 +16,213 @@ --> - - Add_Issue_Inserting_Tag_Record_Error - - 578 - 566 - TriggerActionFlowAddError - apex - - T__record - Case - - CurrentTransaction - - errorMessage - - issueInsertingTagRecordError - - - - record - - record - - - TriggerActionFlowAddError - 1 - - - Add_No_Tag_Definition_Found_Error - - 50 - 350 - TriggerActionFlowAddError - apex - - T__record - Case - - CurrentTransaction - - errorMessage - - noTagDefinitionFoundError - - - - record - - record - - - TriggerActionFlowAddError - 1 - - 59.0 - - Populate_Tag - - 314 - 350 - - tagToInsert.Case__c - Assign - - record.Id - - - - tagToInsert.Record_Id__c - Assign - - record.Id - - - - tagToInsert.Tag_Definition__c - Assign - - Get_Aurora_Tag_Definition.Id - - - - Insert_Tag - - - - Was_Tag_Definition_c_found - - 182 - 242 - - Populate_Tag - - Yes - - No - and - - Get_Aurora_Tag_Definition - IsNull - - true - - - - Add_No_Tag_Definition_Found_Error - - - - - Automatically inserts a Tag__c related to the Aurora Tag_Definition__c for newly created cases. - Default - - issueInsertingTagRecordError - String - "The system encountered an error when trying to insert the Tag__c record for this case which is related to the " +{!$Label.Aurora_Tag_Definition_Name} + " Tag_Definition__c." - - - noTagDefinitionFoundError - String - "The system was unable to find the " + {!$Label.Aurora_Tag_Definition_Name} + " Tag_Definition__c record." - - TA_Case_InsertAuroraTag {!$Flow.CurrentDateTime} - - - BuilderType - - LightningFlowBuilder - - - - CanvasMode - - AUTO_LAYOUT_CANVAS - - - - OriginBuilderType - - LightningFlowBuilder - - - AutoLaunchedFlow - - Insert_Tag - - 314 - 458 - - Add_Issue_Inserting_Tag_Record_Error - - tagToInsert - - - Get_Aurora_Tag_Definition - - 182 - 134 - false - - Was_Tag_Definition_c_found - - and - - Type__c - EqualTo - - General Tag - - - - Name - EqualTo - - $Label.Aurora_Tag_Definition_Name - - - true - Tag_Definition__c - true - - - 56 - 0 - - Get_Aurora_Tag_Definition - - - Active - - record - SObject - false - true - true - Case - - - tagToInsert - SObject - false - false - false - Tag__c - - \ No newline at end of file + + Add_Issue_Inserting_Tag_Record_Error + + 578 + 566 + TriggerActionFlowAddError + apex + + T__record + Case + + CurrentTransaction + + errorMessage + + issueInsertingTagRecordError + + + + record + + record + + + TriggerActionFlowAddError + 1 + + + Add_No_Tag_Definition_Found_Error + + 50 + 350 + TriggerActionFlowAddError + apex + + T__record + Case + + CurrentTransaction + + errorMessage + + noTagDefinitionFoundError + + + + record + + record + + + TriggerActionFlowAddError + 1 + + 59.0 + + Populate_Tag + + 314 + 350 + + tagToInsert.Case__c + Assign + + record.Id + + + + tagToInsert.Record_Id__c + Assign + + record.Id + + + + tagToInsert.Tag_Definition__c + Assign + + Get_Aurora_Tag_Definition.Id + + + + Insert_Tag + + + + Was_Tag_Definition_c_found + + 182 + 242 + + Populate_Tag + + Yes + + No + and + + Get_Aurora_Tag_Definition + IsNull + + true + + + + Add_No_Tag_Definition_Found_Error + + + + + Automatically inserts a Tag__c related to the Aurora Tag_Definition__c for newly created cases. + Default + + issueInsertingTagRecordError + String + "The system encountered an error when trying to insert the Tag__c record for this case which is related to the " +{!$Label.Aurora_Tag_Definition_Name} + " Tag_Definition__c." + + + noTagDefinitionFoundError + String + "The system was unable to find the " + {!$Label.Aurora_Tag_Definition_Name} + " Tag_Definition__c record." + + TA_Case_InsertAuroraTag {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + + OriginBuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + + Insert_Tag + + 314 + 458 + + Add_Issue_Inserting_Tag_Record_Error + + tagToInsert + + + Get_Aurora_Tag_Definition + + 182 + 134 + false + + Was_Tag_Definition_c_found + + and + + Type__c + EqualTo + + General Tag + + + + Name + EqualTo + + $Label.Aurora_Tag_Definition_Name + + + true + Tag_Definition__c + true + + + 56 + 0 + + Get_Aurora_Tag_Definition + + + Active + + record + SObject + false + true + true + Case + + + tagToInsert + SObject + false + false + false + Tag__c + + diff --git a/src/test/goldens/single_capability.flow-meta.xml b/src/test/goldens/single_capability.flow-meta.xml new file mode 100644 index 0000000..e0875db --- /dev/null +++ b/src/test/goldens/single_capability.flow-meta.xml @@ -0,0 +1,31 @@ + + + + AutoLaunchedFlow + + 50 + 0 + + Assignment1 + + + Chatter + + + + Assignment1 + + 200 + 0 + + $Record + Description + + Updated + + + + END + + + diff --git a/src/test/goldens/single_elements.flow-meta.xml b/src/test/goldens/single_elements.flow-meta.xml index 49fb903..0a93a7b 100644 --- a/src/test/goldens/single_elements.flow-meta.xml +++ b/src/test/goldens/single_elements.flow-meta.xml @@ -74,4 +74,4 @@ myActionCall - \ No newline at end of file + diff --git a/src/test/goldens/single_filter.flow-meta.xml b/src/test/goldens/single_filter.flow-meta.xml new file mode 100644 index 0000000..b434692 --- /dev/null +++ b/src/test/goldens/single_filter.flow-meta.xml @@ -0,0 +1,36 @@ + + + + AutoLaunchedFlow + + 50 + 0 + + Assignment1 + + and + + Name + IsNull + + true + + + + + Assignment1 + + 200 + 0 + + $Record + Description + + Updated + + + + END + + + diff --git a/src/test/goldens/single_scheduled_path.flow-meta.xml b/src/test/goldens/single_scheduled_path.flow-meta.xml new file mode 100644 index 0000000..b96ffaa --- /dev/null +++ b/src/test/goldens/single_scheduled_path.flow-meta.xml @@ -0,0 +1,33 @@ + + + + AutoLaunchedFlow + + 50 + 0 + + Assignment1 + + + + 1 + Days + + + + Assignment1 + + 200 + 0 + + $Record + Description + + Updated + + + + END + + + diff --git a/src/test/graphviz_generator_test.ts b/src/test/graphviz_generator_test.ts index 18edb3d..764aa14 100644 --- a/src/test/graphviz_generator_test.ts +++ b/src/test/graphviz_generator_test.ts @@ -163,8 +163,9 @@ function generateDecision(name: string): flowTypes.FlowDecision { leftValueReference: "foo", operator: flowTypes.FlowComparisonOperator.EQUAL_TO, rightValue: { - booleanValue: true, + booleanValue: "true", }, + processMetadataValues: [], }, ], }, diff --git a/src/test/mermaid_generator_test.ts b/src/test/mermaid_generator_test.ts index 8953ec9..2727949 100644 --- a/src/test/mermaid_generator_test.ts +++ b/src/test/mermaid_generator_test.ts @@ -145,10 +145,15 @@ function generateDecision(name: string): flowTypes.FlowDecision { return { name: name, label: name, + elementSubtype: "Decision", + locationX: 0, + locationY: 0, + description: name, rules: [ { name: `${name}_rule1`, label: "Rule 1", + description: "Rule 1 description", conditions: [ { leftValueReference: "leftValue", @@ -156,12 +161,14 @@ function generateDecision(name: string): flowTypes.FlowDecision { rightValue: { stringValue: "rightValue", }, + processMetadataValues: [], }, ], }, { name: `${name}_rule2`, label: "Rule 2", + description: "Rule 2 description", conditions: [ { leftValueReference: "anotherLeftValue", @@ -169,13 +176,15 @@ function generateDecision(name: string): flowTypes.FlowDecision { rightValue: { stringValue: "anotherRightValue", }, + processMetadataValues: [], }, { leftValueReference: "thirdLeftValue", operator: "GreaterThan", rightValue: { - numberValue: 10, + numberValue: "10", }, + processMetadataValues: [], }, ], conditionLogic: "1 AND 2", diff --git a/src/test/uml_generator_test.ts b/src/test/uml_generator_test.ts index 5263077..926d85f 100644 --- a/src/test/uml_generator_test.ts +++ b/src/test/uml_generator_test.ts @@ -45,6 +45,10 @@ const NODE_NAMES = { }; const UML_REPRESENTATIONS = { + flowStart: () => + `state Flow Start FLOW_START + Flow Details + No specific entry criteria defined`, apexPluginCall: (name: string) => `state Apex Plugin Call ${name}`, assignment: (name: string) => `state Assignment ${name} @@ -270,6 +274,7 @@ Deno.test("UmlGenerator", async (t) => { const expectedUml = [ NODE_NAMES.label, + UML_REPRESENTATIONS.flowStart(), UML_REPRESENTATIONS.apexPluginCall(NODE_NAMES.apexPluginCall), UML_REPRESENTATIONS.assignment(NODE_NAMES.assignment), UML_REPRESENTATIONS.collectionProcessor(NODE_NAMES.collectionProcessor), @@ -593,7 +598,7 @@ Deno.test("UmlGenerator", async (t) => { { assignToReference: "var2", operator: flowTypes.FlowAssignmentOperator.ADD, - value: { numberValue: 42 }, + value: { numberValue: "42" }, processMetadataValues: [], }, { @@ -631,4 +636,501 @@ Deno.test("UmlGenerator", async (t) => { }); }, ); + + await t.step( + "should generate AutoLaunchedFlow start node with process type", + () => { + // Setup test data for AutoLaunchedFlow + mockParsedFlow.processType = flowTypes.FlowProcessType.AUTOLAUNCHED_FLOW; + mockParsedFlow.start = { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "AutoLaunched flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + }; + + const uml = systemUnderTest.generateUml(); + + const expectedContent = [ + "Flow Start FLOW_START", + "Flow Details", + "Process Type: AutoLaunchedFlow", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should generate record-triggered flow start node with filters", + () => { + // Setup test data for record-triggered flow + mockParsedFlow.processType = flowTypes.FlowProcessType.FLOW; + mockParsedFlow.start = { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Record-triggered flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + triggerType: flowTypes.FlowTriggerType.RECORD_AFTER_SAVE, + object: "Account", + recordTriggerType: flowTypes.RecordTriggerType.CREATE_AND_UPDATE, + entryType: flowTypes.FlowEntryType.ALWAYS, + filterLogic: "1 AND 2", + filters: [ + { + field: "Type", + operator: flowTypes.FlowRecordFilterOperator.EQUAL_TO, + value: { stringValue: "Customer" }, + }, + { + field: "Status", + operator: flowTypes.FlowRecordFilterOperator.NOT_EQUAL_TO, + value: { stringValue: "Inactive" }, + }, + ], + filterFormula: + "{!$Record.Type} = 'Customer' && {!$Record.Status} != 'Inactive'", + }; + + const uml = systemUnderTest.generateUml(); + + const expectedContent = [ + "Process Type: Flow", + "Trigger Type: RecordAfterSave", + "Object: Account", + "Record Trigger: CreateAndUpdate", + "Entry Type: Always", + "Filter Logic: 1 AND 2", + "1. Type EqualTo Customer", + "2. Status NotEqualTo Inactive", + "Filter Formula: {!$Record.Type} = 'Customer' && {!$Record.Status} != 'Inactive'", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should generate scheduled flow start node with schedule information", + () => { + // Setup test data for scheduled flow + mockParsedFlow.processType = flowTypes.FlowProcessType.AUTOLAUNCHED_FLOW; + mockParsedFlow.start = { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Scheduled flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + triggerType: flowTypes.FlowTriggerType.SCHEDULED, + schedule: { + frequency: flowTypes.FlowStartFrequency.DAILY, + startDate: "2024-01-01", + startTime: "09:00:00", + }, + scheduledPaths: [ + { + name: "DailyPath", + label: "Daily Processing", + connector: { targetReference: "END", isGoTo: false }, + offsetNumber: "1", + offsetUnit: flowTypes.FlowScheduledPathOffsetUnit.DAYS, + timeSource: + flowTypes.FlowScheduledPathTimeSource.RECORD_TRIGGER_EVENT, + description: "Daily scheduled path", + }, + { + name: "WeeklyPath", + label: "Weekly Report", + connector: { targetReference: "END", isGoTo: false }, + offsetNumber: "7", + offsetUnit: flowTypes.FlowScheduledPathOffsetUnit.DAYS, + timeSource: flowTypes.FlowScheduledPathTimeSource.RECORD_FIELD, + description: "Weekly scheduled path", + }, + ], + }; + + const uml = systemUnderTest.generateUml(); + + const expectedContent = [ + "Process Type: AutoLaunchedFlow", + "Trigger Type: Scheduled", + "Schedule: Daily starting 2024-01-01 at 09:00:00", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should generate platform event triggered flow start node", + () => { + // Setup test data for platform event flow + mockParsedFlow.processType = flowTypes.FlowProcessType.AUTOLAUNCHED_FLOW; + mockParsedFlow.start = { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Platform event triggered flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + triggerType: flowTypes.FlowTriggerType.PLATFORM_EVENT, + object: "Order_Event__e", + segment: "High_Value_Orders", + flowRunAsUser: "admin@example.com", + }; + + const uml = systemUnderTest.generateUml(); + + const expectedContent = [ + "Process Type: AutoLaunchedFlow", + "Trigger Type: PlatformEvent", + "Object: Order_Event__e", + "Segment: High_Value_Orders", + "Run As: admin@example.com", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should generate flow start node with capabilities and form", + () => { + // Setup test data for capability-based flow + mockParsedFlow.processType = flowTypes.FlowProcessType.FLOW; + mockParsedFlow.start = { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Capability-based flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + triggerType: flowTypes.FlowTriggerType.CAPABILITY, + capabilityTypes: [ + { + name: "DataProcessing", + capabilityName: "Data Processing Capability", + inputs: [ + { + name: "InputData", + capabilityInputName: "Data Input", + dataType: "sObject", + isCollection: true, + description: "Input data collection", + }, + ], + description: "Data processing capability", + }, + { + name: "ValidationCapability", + capabilityName: "Validation Rules", + inputs: [ + { + name: "RecordToValidate", + capabilityInputName: "Record Input", + dataType: "sObject", + isCollection: false, + description: "Record to validate", + }, + ], + description: "Validation capability", + }, + ], + form: "Custom_Lead_Form", + segment: "Enterprise_Customers", + }; + + const uml = systemUnderTest.generateUml(); + + const expectedContent = [ + "Process Type: Flow", + "Trigger Type: Capability", + "Capability 1: Data Processing Capability", + "Capability 2: Validation Rules", + "Form: Custom_Lead_Form", + "Segment: Enterprise_Customers", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should handle minimal start node configuration", + () => { + // Create a minimal mock flow with just the start node + const minimalMockFlow: ParsedFlow = { + label: "Minimal Test", + processType: undefined, + start: { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Minimal flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + }, + transitions: [], + }; + + // Create a fresh generator with the minimal mock flow + class ConcreteUmlGenerator extends UmlGenerator { + getHeader(label: string): string { + return label; + } + toUmlString(node: DiagramNode): string { + let result = `state ${node.type} ${node.id}`; + if (node.innerNodes) { + const innerContent = node.innerNodes + .map((innerNode) => { + const header = [innerNode.type, innerNode.label] + .filter(Boolean) + .join(": "); + + const content = innerNode.content + .map((line) => ` ${line}`) + .join(EOL); + + return header ? ` ${header}${EOL}${content}` : content; + }) + .join(EOL); + result += EOL + innerContent; + } + return result; + } + getTransition(transition: Transition): string { + return UML_REPRESENTATIONS.transition(transition.from, transition.to); + } + getFooter(): string { + return ""; + } + } + + const minimalGenerator = new ConcreteUmlGenerator(minimalMockFlow); + const uml = minimalGenerator.generateUml(); + + const expectedContent = [ + "Flow Start FLOW_START", + "Flow Details", + "No specific entry criteria defined", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + + // Should not contain any specific trigger information + const unexpectedContent = [ + "Process Type:", + "Trigger Type:", + "Filter Logic:", + ]; + + unexpectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + false, + `Expected UML: ${uml} to NOT contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should handle start node with record change criteria", + () => { + // Create a record change mock flow with just the start node + const recordChangeMockFlow: ParsedFlow = { + label: "Record Change Test", + processType: flowTypes.FlowProcessType.FLOW, + start: { + name: "FLOW_START", + label: "Flow Start", + locationX: 0, + locationY: 0, + elementSubtype: "Start", + description: "Record change flow start", + connector: { targetReference: "nextNode", isGoTo: false }, + triggerType: flowTypes.FlowTriggerType.RECORD_BEFORE_SAVE, + object: "Opportunity", + recordTriggerType: flowTypes.RecordTriggerType.UPDATE, + entryType: flowTypes.FlowEntryType.ALWAYS, + doesRequireRecordChangedToMeetCriteria: true, + filterLogic: "1 OR 2", + filters: [ + { + field: "StageName", + operator: flowTypes.FlowRecordFilterOperator.EQUAL_TO, + value: { stringValue: "Closed Won" }, + }, + { + field: "Amount", + operator: flowTypes.FlowRecordFilterOperator.GREATER_THAN, + value: { numberValue: "100000" }, + }, + ], + }, + transitions: [], + }; + + // Create a fresh generator with the record change mock flow + class ConcreteUmlGenerator extends UmlGenerator { + getHeader(label: string): string { + return label; + } + toUmlString(node: DiagramNode): string { + let result = `state ${node.type} ${node.id}`; + if (node.innerNodes) { + const innerContent = node.innerNodes + .map((innerNode) => { + const header = [innerNode.type, innerNode.label] + .filter(Boolean) + .join(": "); + + const content = innerNode.content + .map((line) => ` ${line}`) + .join(EOL); + + return header ? ` ${header}${EOL}${content}` : content; + }) + .join(EOL); + result += EOL + innerContent; + } + return result; + } + getTransition(transition: Transition): string { + return UML_REPRESENTATIONS.transition(transition.from, transition.to); + } + getFooter(): string { + return ""; + } + } + + const recordChangeGenerator = new ConcreteUmlGenerator( + recordChangeMockFlow, + ); + const uml = recordChangeGenerator.generateUml(); + + const expectedContent = [ + "Process Type: Flow", + "Trigger Type: RecordBeforeSave", + "Object: Opportunity", + "Record Trigger: Update", + "Entry Type: Always", + "Filter Logic: 1 OR 2", + "1. StageName EqualTo Closed Won", + "2. Amount GreaterThan 100000", + ]; + + expectedContent.forEach((content) => { + assertEquals( + uml.includes(content), + true, + `Expected UML: ${uml} to contain: ${content}`, + ); + }); + }, + ); + + await t.step( + "should handle empty start node gracefully", + () => { + // Create an empty mock flow with no start node + const emptyMockFlow: ParsedFlow = { + label: "Empty Test", + processType: undefined, + start: undefined, + transitions: [], + }; + + // Create a fresh generator with the empty mock flow + class ConcreteUmlGenerator extends UmlGenerator { + getHeader(label: string): string { + return label; + } + toUmlString(node: DiagramNode): string { + let result = `state ${node.type} ${node.id}`; + if (node.innerNodes) { + const innerContent = node.innerNodes + .map((innerNode) => { + const header = [innerNode.type, innerNode.label] + .filter(Boolean) + .join(": "); + + const content = innerNode.content + .map((line) => ` ${line}`) + .join(EOL); + + return header ? ` ${header}${EOL}${content}` : content; + }) + .join(EOL); + result += EOL + innerContent; + } + return result; + } + getTransition(transition: Transition): string { + return UML_REPRESENTATIONS.transition(transition.from, transition.to); + } + getFooter(): string { + return ""; + } + } + + const emptyGenerator = new ConcreteUmlGenerator(emptyMockFlow); + const uml = emptyGenerator.generateUml(); + + // Should not contain flow start node when undefined + assertEquals( + uml.includes("Flow Start FLOW_START"), + false, + "Should not contain flow start node when undefined", + ); + }, + ); });