UNPKG

sf-flow-viewer

Version:

A beautiful, interactive HTML viewer for Salesforce Flows with advanced search, sorting, and export capabilities

1,319 lines (1,161 loc) 42.7 kB
#!/usr/bin/env node /** * sf-flow-viewer – interactive HTML viewer for Salesforce Flows * ---------------------------------------------------------------------- * Commands: * sf-flow-viewer list -o <org> [--format html|table|json|csv] [--no-open] * sf-flow-viewer view -o <org> -f <flow> [--no-open] */ const { Command } = require('commander'); const { execSync } = require('child_process'); const https = require('https'); const fs = require('fs'); const path = require('path'); const {openResource} = require('open-resource'); // ======================================================================== // CONSTANTS // ======================================================================== const CONSTANTS = { API_VERSION: '59.0', ITEMS_PER_PAGE: 50, MAX_INTERVIEWS: 100, MIN_SIDEBAR_WIDTH: 150, MAX_SIDEBAR_WIDTH: 500, DEFAULT_SIDEBAR_WIDTH: 256, OUTPUT_DIR: 'flows', FLOW_FILE_EXTENSION: '.flow-meta.xml', HTML_EXTENSION: '.html', }; const PROCESS_TYPE_LABELS = { 'AutoLaunchedFlow': 'Autolaunched Flow', 'Flow': 'Screen Flow', 'Workflow': 'Record-Triggered Flow', 'CustomEvent': 'Platform Event-Triggered Flow', 'InvocableProcess': 'Invocable Process', }; const OUTPUT_FORMATS = { HTML: 'html', TABLE: 'table', JSON: 'json', CSV: 'csv', }; // ======================================================================== // UTILITY FUNCTIONS // ======================================================================== /** * Formats a date string to locale string * @param {string} dateString - ISO date string * @returns {string} Formatted date or 'N/A' */ function formatDate(dateString) { return dateString ? new Date(dateString).toLocaleString() : 'N/A'; } /** * Safely gets nested property value * @param {Object} obj - Object to traverse * @param {string} path - Dot-separated path (e.g., 'CreatedBy.Name') * @param {*} defaultValue - Default value if path doesn't exist * @returns {*} Property value or default */ function safeGet(obj, path, defaultValue = 'N/A') { return path.split('.').reduce((current, key) => current?.[key], obj) ?? defaultValue; } /** * Ensures an array is returned from metadata property * @param {*} value - Metadata value that could be single item or array * @returns {Array} Array of items */ function ensureArray(value) { return Array.isArray(value) ? value : (value ? [value] : []); } /** * Validates org username/alias format * @param {string} username - Username to validate * @returns {boolean} Whether username is valid */ function isValidUsername(username) { return username && typeof username === 'string' && username.length > 0; } /** * Escapes HTML special characters * @param {string} text - Text to escape * @returns {string} Escaped text */ function escapeHtml(text) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; return String(text).replace(/[&<>"']/g, m => map[m]); } // ======================================================================== // SALESFORCE CONNECTION // ======================================================================== /** * Gets Salesforce connection information * @param {string} username - Salesforce org username or alias * @returns {{accessToken: string, instanceUrl: string, apiVersion: string}} * @throws {Error} If connection fails */ function getSalesforceConnection(username) { if (!isValidUsername(username)) { throw new Error('Invalid username or alias provided'); } try { const result = execSync( `sf org display -o ${username} --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } ); const orgInfo = JSON.parse(result); if (orgInfo.status !== 0) { throw new Error(orgInfo.message || 'Failed to get org info'); } if (!orgInfo.result?.accessToken || !orgInfo.result?.instanceUrl) { throw new Error('Invalid org info returned from Salesforce CLI'); } return { accessToken: orgInfo.result.accessToken, instanceUrl: orgInfo.result.instanceUrl, apiVersion: orgInfo.result.apiVersion || CONSTANTS.API_VERSION, }; } catch (error) { console.error('❌ Error connecting to Salesforce:', error.message); console.error('💡 Make sure the Salesforce CLI is installed and the org is authenticated.'); console.error(` Try: sf org login web -a ${username}`); process.exit(1); } } /** * Makes an HTTPS request to Salesforce * @param {Object} conn - Connection object * @param {string} endpoint - API endpoint * @returns {Promise<Object>} Response data */ function salesforceRequest(conn, endpoint) { return new Promise((resolve, reject) => { const url = new URL(endpoint, conn.instanceUrl); const options = { hostname: url.hostname, path: url.pathname + url.search, method: 'GET', headers: { 'Authorization': `Bearer ${conn.accessToken}`, 'Content-Type': 'application/json', }, }; const request = https.request(options, response => { let data = ''; response.on('data', chunk => { data += chunk; }); response.on('end', () => { if (response.statusCode >= 200 && response.statusCode < 300) { try { resolve(JSON.parse(data)); } catch (parseError) { reject(new Error(`Failed to parse JSON response: ${parseError.message}`)); } } else { reject(new Error(`HTTP ${response.statusCode}: ${data}`)); } }); }); request.on('error', error => { reject(new Error(`Request failed: ${error.message}`)); }); request.setTimeout(30000, () => { request.destroy(); reject(new Error('Request timeout after 30 seconds')); }); request.end(); }); } /** * Executes a SOQL query * @param {Object} conn - Connection object * @param {string} query - SOQL query * @returns {Promise<Object>} Query results */ async function querySOQL(conn, query) { const encodedQuery = encodeURIComponent(query); return salesforceRequest( conn, `/services/data/v${conn.apiVersion}/query?q=${encodedQuery}` ); } /** * Executes a Tooling API query * @param {Object} conn - Connection object * @param {string} query - SOQL query * @returns {Promise<Object>} Query results */ async function queryTooling(conn, query) { const encodedQuery = encodeURIComponent(query); return salesforceRequest( conn, `/services/data/v${conn.apiVersion}/tooling/query?q=${encodedQuery}` ); } // ======================================================================== // FLOW DATA RETRIEVAL // ======================================================================== /** * Fetches comprehensive flow data * @param {Object} conn - Connection object * @param {string} flowApiName - Flow API name (DeveloperName) * @returns {Promise<Object>} Flow data including definition, versions, metadata */ async function getFlowData(conn, flowApiName) { console.log('📋 Fetching Flow versions...'); // First, get all versions of the flow using Definition.DeveloperName const flowVersionsQuery = ` SELECT Id, VersionNumber, Status, Description, ProcessType, ApiVersion, LastModifiedDate, LastModifiedBy.Name, CreatedDate, CreatedBy.Name, RunInMode, MasterLabel, Definition.DeveloperName FROM Flow WHERE Definition.DeveloperName = '${flowApiName}' ORDER BY VersionNumber DESC `.replace(/\s+/g, ' ').trim(); const versionsResponse = await queryTooling(conn, flowVersionsQuery); const versions = versionsResponse.records || []; if (!versions.length) { throw new Error(`Flow '${flowApiName}' not found in the org`); } console.log(`✅ Found ${versions.length} version(s)`); // Get active version or latest const activeVersion = versions.find(v => v.Status === 'Active') || versions[0]; const latestVersion = versions[0]; const oldestVersion = versions[versions.length - 1]; // Now get the FlowDefinition metadata const definitionId = activeVersion.DefinitionId; const defQuery = ` SELECT Id, DeveloperName FROM FlowDefinition WHERE DeveloperName = '${flowApiName}' LIMIT 1 `.replace(/\s+/g, ' ').trim(); const defResponse = await queryTooling(conn, defQuery); const flowDefId = defResponse.records[0]?.Id || definitionId; // Build flowDefinition object from available data const flowDefinition = { Id: flowDefId, ApiName: flowApiName, Label: latestVersion.MasterLabel, Description: activeVersion.Description || '', ProcessType: activeVersion.ProcessType || 'Unknown', TriggerType: null, // Will be extracted from metadata RecordTriggerType: null, TriggerOrder: null, IsTemplate: false, IsActive: activeVersion.Status === 'Active', LastModifiedDate: latestVersion.LastModifiedDate, LastModifiedBy: latestVersion.LastModifiedBy || { Name: 'N/A' }, CreatedDate: oldestVersion.CreatedDate || oldestVersion.LastModifiedDate, CreatedBy: oldestVersion.CreatedBy || { Name: 'N/A' }, NamespacePrefix: null, ManageableState: null }; console.log(`✅ Found: ${flowDefinition.Label}`); // Fetch metadata for active version console.log('🔍 Fetching Flow metadata...'); const metadataQuery = ` SELECT Metadata FROM Flow WHERE Id = '${activeVersion.Id}' `.replace(/\s+/g, ' ').trim(); const metadataResponse = await queryTooling(conn, metadataQuery); const metadata = metadataResponse.records[0]?.Metadata || {}; console.log('✅ Metadata retrieved'); // Extract additional info from metadata if available if (metadata.start) { flowDefinition.TriggerType = metadata.triggerType || metadata.start?.triggerType; flowDefinition.RecordTriggerType = metadata.recordTriggers?.[0]?.triggerType; } // Parse flow elements const flowElements = parseFlowMetadata(metadata); // Fetch recent interviews (optional, may fail if no access) console.log('🏃 Fetching recent Flow interviews...'); const interviewsQuery = ` SELECT Id, Name, CurrentElement, InterviewStatus, CreatedDate, CreatedBy.Name FROM FlowInterview WHERE FlowVersionViewId = '${activeVersion.Id}' ORDER BY CreatedDate DESC LIMIT ${CONSTANTS.MAX_INTERVIEWS} `.replace(/\s+/g, ' ').trim(); let interviews = []; try { const interviewsResponse = await querySOQL(conn, interviewsQuery); interviews = interviewsResponse.records || []; console.log(`✅ Found ${interviews.length} recent interview(s)`); } catch (error) { console.log('⚠️ FlowInterview not accessible (may require additional permissions)'); } return { flowDefinition, versions, activeVersion, metadata, elements: flowElements, interviews, }; } /** * Parses flow metadata into categorized elements * @param {Object} metadata - Flow metadata * @returns {Object} Categorized flow elements */ function parseFlowMetadata(metadata) { const elements = { screens: [], decisions: [], assignments: [], recordCreates: [], recordUpdates: [], recordDeletes: [], recordLookups: [], loops: [], subflows: [], actions: [], variables: [], formulas: [], choices: [], constants: [], }; // Parse screens if (metadata.screens) { elements.screens = ensureArray(metadata.screens).map(screen => ({ name: screen.name, label: screen.label, fieldCount: screen.fields ? ensureArray(screen.fields).length : 0, })); } // Parse decisions if (metadata.decisions) { elements.decisions = ensureArray(metadata.decisions).map(decision => ({ name: decision.name, label: decision.label, rulesCount: decision.rules ? ensureArray(decision.rules).length : 0, })); } // Parse assignments if (metadata.assignments) { elements.assignments = ensureArray(metadata.assignments).map(assignment => ({ name: assignment.name, label: assignment.label, assignmentsCount: assignment.assignmentItems ? ensureArray(assignment.assignmentItems).length : 0, })); } // Parse record operations const recordOps = [ { key: 'recordCreates', target: 'recordCreates' }, { key: 'recordUpdates', target: 'recordUpdates' }, { key: 'recordDeletes', target: 'recordDeletes' }, { key: 'recordLookups', target: 'recordLookups' }, ]; recordOps.forEach(({ key, target }) => { if (metadata[key]) { elements[target] = ensureArray(metadata[key]).map(record => ({ name: record.name, label: record.label, object: record.object, })); } }); // Parse loops if (metadata.loops) { elements.loops = ensureArray(metadata.loops).map(loop => ({ name: loop.name, label: loop.label, collectionReference: loop.collectionReference, })); } // Parse subflows if (metadata.subflows) { elements.subflows = ensureArray(metadata.subflows).map(subflow => ({ name: subflow.name, label: subflow.label, flowName: subflow.flowName, })); } // Parse action calls if (metadata.actionCalls) { elements.actions = ensureArray(metadata.actionCalls).map(action => ({ name: action.name, label: action.label, actionName: action.actionName, actionType: action.actionType, })); } // Parse variables if (metadata.variables) { elements.variables = ensureArray(metadata.variables).map(variable => ({ name: variable.name, dataType: variable.dataType, isInput: variable.isInput === true, isOutput: variable.isOutput === true, objectType: variable.objectType, })); } // Parse formulas if (metadata.formulas) { elements.formulas = ensureArray(metadata.formulas).map(formula => ({ name: formula.name, dataType: formula.dataType, expression: formula.expression, })); } // Parse choices if (metadata.choices) { elements.choices = ensureArray(metadata.choices).map(choice => ({ name: choice.name, dataType: choice.dataType, value: choice.value, })); } // Parse constants if (metadata.constants) { elements.constants = ensureArray(metadata.constants).map(constant => ({ name: constant.name, dataType: constant.dataType, value: constant.value, })); } return elements; } // ======================================================================== // XML EXPORT // ======================================================================== /** * Saves flow metadata to XML file * @param {string} flowApiName - Flow API name * @param {Object} data - Flow data */ async function saveFlowToXML(flowApiName, data) { try { const xml2js = require('xml2js'); const builder = new xml2js.Builder({ xmldec: { version: '1.0', encoding: 'UTF-8' }, renderOpts: { pretty: true, indent: ' ' }, }); const xmlObject = { Flow: { $: { xmlns: 'http://soap.sforce.com/2006/04/metadata' }, ...data.metadata, }, }; const xml = builder.buildObject(xmlObject); const directory = path.join(process.cwd(), CONSTANTS.OUTPUT_DIR); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } const filePath = path.join( directory, `${flowApiName}${CONSTANTS.FLOW_FILE_EXTENSION}` ); fs.writeFileSync(filePath, xml); console.log(`💾 XML saved to: ${filePath}`); } catch (error) { console.error('⚠️ Failed to save XML:', error.message); } } // ======================================================================== // HTML GENERATION // ======================================================================== /** * Generates interactive HTML viewer * @param {Object} data - Flow data * @returns {string} HTML content */ function generateHTML(data) { const { flowDefinition, versions, activeVersion, elements, interviews } = data; const processTypeLabel = PROCESS_TYPE_LABELS[flowDefinition.ProcessType] || flowDefinition.ProcessType; return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Flow: ${escapeHtml(flowDefinition.Label)}</title> <link rel="icon" type="image/x-icon" href="https://mohan-chinnappan-n5.github.io/dfv/img/mc_favIcon.ico" /> <script src="https://cdn.tailwindcss.com"></script> <style> body { background: #0f172a; color: #e2e8f0; } .splitter { cursor: col-resize; background: #1e293b; width: 4px; user-select: none; } .splitter:hover { background: #3b82f6; } .sidebar-item { cursor: pointer; transition: all 0.2s; } .sidebar-item:hover { background: #1e293b; } .sidebar-item.active { background: #1e40af; border-left: 4px solid #3b82f6; } th { cursor: pointer; user-select: none; } th:hover { background: #1e293b; } .sort-icon { display: inline-block; margin-left: 5px; opacity: 0.3; } th.sorted .sort-icon { opacity: 1; } .badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; } .badge-active { background: #10b981; color: #fff; } .badge-inactive { background: #6b7280; color: #fff; } .page-btn { padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; } .page-btn.active { background: #3b82f6; color: white; } </style> </head> <body class="font-sans"> <div class="min-h-screen p-6"> <!-- Header --> <div class="mb-6"> <div class="flex items-center gap-4"> <h1 class="text-3xl font-bold text-blue-400">${escapeHtml(flowDefinition.Label)}</h1> <span class="badge badge-${flowDefinition.IsActive ? 'active' : 'inactive'}"> ${flowDefinition.IsActive ? 'Active' : 'Inactive'} </span> </div> <p class="text-gray-400 mt-1">Flow Details</p> </div> <!-- Key Information --> <div class="bg-slate-800 rounded-lg p-6 mb-6 shadow-xl"> <h2 class="text-xl font-semibold mb-4 text-blue-300">Key Information</h2> <div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div> <p class="text-gray-400 text-sm">API Name</p> <p class="font-semibold">${escapeHtml(flowDefinition.ApiName)}</p> </div> <div> <p class="text-gray-400 text-sm">Process Type</p> <p class="font-semibold">${escapeHtml(processTypeLabel)}</p> </div> <div> <p class="text-gray-400 text-sm">Trigger Type</p> <p class="font-semibold">${escapeHtml(flowDefinition.TriggerType || 'N/A')}</p> </div> <div> <p class="text-gray-400 text-sm">Record Trigger</p> <p class="font-semibold">${escapeHtml(flowDefinition.RecordTriggerType || 'N/A')}</p> </div> <div> <p class="text-gray-400 text-sm">Active Version</p> <p class="font-semibold">${activeVersion.VersionNumber}</p> </div> <div> <p class="text-gray-400 text-sm">Total Versions</p> <p class="font-semibold text-blue-400">${versions.length}</p> </div> <div> <p class="text-gray-400 text-sm">Created By</p> <p class="font-semibold">${escapeHtml(safeGet(flowDefinition, 'CreatedBy.Name'))}</p> </div> <div> <p class="text-gray-400 text-sm">Last Modified By</p> <p class="font-semibold">${escapeHtml(safeGet(flowDefinition, 'LastModifiedBy.Name'))}</p> </div> <div> <p class="text-gray-400 text-sm">Created Date</p> <p class="font-semibold">${formatDate(flowDefinition.CreatedDate)}</p> </div> <div> <p class="text-gray-400 text-sm">Last Modified</p> <p class="font-semibold">${formatDate(flowDefinition.LastModifiedDate)}</p> </div> <div> <p class="text-gray-400 text-sm">Is Template</p> <p class="font-semibold">${flowDefinition.IsTemplate ? 'Yes' : 'No'}</p> </div> <div> <p class="text-gray-400 text-sm">Namespace</p> <p class="font-semibold">${escapeHtml(flowDefinition.NamespacePrefix || 'None')}</p> </div> <div class="col-span-2 md:col-span-4"> <p class="text-gray-400 text-sm">Description</p> <p class="font-semibold">${escapeHtml(flowDefinition.Description || 'No description')}</p> </div> </div> </div> <!-- Main Content Area --> <div class="bg-slate-800 rounded-lg shadow-xl flex" style="height: 600px;"> <!-- Sidebar --> <div id="sidebar" class="w-64 p-4 overflow-y-auto bg-slate-900 rounded-l-lg" style="min-width: 200px;"> <h3 class="text-lg font-semibold mb-4 text-blue-300">Sections</h3> <div class="space-y-2"> ${generateSidebarItems(elements, versions, interviews)} </div> </div> <!-- Splitter --> <div class="splitter"></div> <!-- Content Display --> <div id="content" class="flex-1 flex flex-col overflow-hidden"> <div class="p-4 border-b border-slate-700 flex gap-4 items-center"> <input type="text" id="search-input" placeholder="Search..." class="flex-1 px-4 py-2 bg-slate-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"> <button id="export-btn" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded font-medium transition"> Export CSV </button> </div> <div id="content-display" class="flex-1 overflow-auto p-4"></div> <div class="p-4 border-t border-slate-700 flex justify-between items-center"> <div class="text-sm text-gray-400"> Showing <span id="page-info">0</span> items </div> <div class="flex gap-2"> <button id="prev-btn" class="px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded disabled:opacity-50"> Previous </button> <span id="page-numbers" class="flex gap-1"></span> <button id="next-btn" class="px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded disabled:opacity-50"> Next </button> </div> </div> </div> </div> </div> <script> ${generateJavaScript(data)} </script> </body> </html>`; } /** * Generates sidebar navigation items * @param {Object} elements - Flow elements * @param {Array} versions - Flow versions * @param {Array} interviews - Flow interviews * @returns {string} HTML for sidebar items */ function generateSidebarItems(elements, versions, interviews) { const sections = [ { key: 'versions', label: 'Versions', count: versions.length }, { key: 'screens', label: 'Screens', count: elements.screens.length }, { key: 'decisions', label: 'Decisions', count: elements.decisions.length }, { key: 'assignments', label: 'Assignments', count: elements.assignments.length }, { key: 'record-creates', label: 'Record Creates', count: elements.recordCreates.length }, { key: 'record-updates', label: 'Record Updates', count: elements.recordUpdates.length }, { key: 'record-deletes', label: 'Record Deletes', count: elements.recordDeletes.length }, { key: 'record-lookups', label: 'Record Lookups', count: elements.recordLookups.length }, { key: 'loops', label: 'Loops', count: elements.loops.length }, { key: 'subflows', label: 'Subflows', count: elements.subflows.length }, { key: 'actions', label: 'Actions', count: elements.actions.length }, { key: 'variables', label: 'Variables', count: elements.variables.length }, { key: 'formulas', label: 'Formulas', count: elements.formulas.length }, { key: 'interviews', label: 'Recent Runs', count: interviews.length }, ]; return sections.map((section, index) => ` <div class="sidebar-item ${index === 0 ? 'active' : ''} px-4 py-3 rounded" data-section="${section.key}"> <p class="font-medium">${section.label}</p> <p class="text-sm text-gray-400">${section.count} items</p> </div> `).join(''); } /** * Generates JavaScript for interactive viewer * @param {Object} data - Flow data * @returns {string} JavaScript code */ function generateJavaScript(data) { const { versions, elements, interviews } = data; return ` const data = { 'versions': ${JSON.stringify(versions)}, 'screens': ${JSON.stringify(elements.screens)}, 'decisions': ${JSON.stringify(elements.decisions)}, 'assignments': ${JSON.stringify(elements.assignments)}, 'record-creates': ${JSON.stringify(elements.recordCreates)}, 'record-updates': ${JSON.stringify(elements.recordUpdates)}, 'record-deletes': ${JSON.stringify(elements.recordDeletes)}, 'record-lookups': ${JSON.stringify(elements.recordLookups)}, 'loops': ${JSON.stringify(elements.loops)}, 'subflows': ${JSON.stringify(elements.subflows)}, 'actions': ${JSON.stringify(elements.actions)}, 'variables': ${JSON.stringify(elements.variables)}, 'formulas': ${JSON.stringify(elements.formulas)}, 'interviews': ${JSON.stringify(interviews)} }; const ITEMS_PER_PAGE = ${CONSTANTS.ITEMS_PER_PAGE}; let currentSection = 'versions'; let currentPage = 1; let sortColumn = null; let sortDirection = 'asc'; let searchQuery = ''; let filteredData = []; // Sidebar resizing const sidebar = document.getElementById('sidebar'); const splitter = document.querySelector('.splitter'); let isResizing = false; splitter.addEventListener('mousedown', (e) => { isResizing = true; document.addEventListener('mousemove', handleResize); document.addEventListener('mouseup', () => { isResizing = false; document.removeEventListener('mousemove', handleResize); }); }); function handleResize(e) { if (!isResizing) return; const rect = sidebar.getBoundingClientRect(); const width = e.clientX - rect.left; if (width >= ${CONSTANTS.MIN_SIDEBAR_WIDTH} && width <= ${CONSTANTS.MAX_SIDEBAR_WIDTH}) { sidebar.style.width = width + 'px'; } } // Sidebar navigation document.querySelectorAll('.sidebar-item').forEach(item => { item.addEventListener('click', () => { document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); currentSection = item.dataset.section; resetState(); render(); }); }); // Search document.getElementById('search-input').addEventListener('input', (e) => { searchQuery = e.target.value.toLowerCase(); currentPage = 1; render(); }); // Export document.getElementById('export-btn').addEventListener('click', exportToCSV); // Column definitions function getColumns(section) { const columnMap = { 'versions': [ { key: 'VersionNumber', label: 'Version' }, { key: 'Status', label: 'Status' }, { key: 'ProcessType', label: 'Type' }, { key: 'LastModifiedDate', label: 'Last Modified' } ], 'screens': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'fieldCount', label: 'Fields' } ], 'decisions': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'rulesCount', label: 'Rules' } ], 'assignments': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'assignmentsCount', label: 'Assignments' } ], 'record-creates': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'object', label: 'Object' } ], 'record-updates': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'object', label: 'Object' } ], 'record-deletes': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'object', label: 'Object' } ], 'record-lookups': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'object', label: 'Object' } ], 'loops': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'collectionReference', label: 'Collection' } ], 'subflows': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'flowName', label: 'Flow Name' } ], 'actions': [ { key: 'name', label: 'Name' }, { key: 'label', label: 'Label' }, { key: 'actionName', label: 'Action' }, { key: 'actionType', label: 'Type' } ], 'variables': [ { key: 'name', label: 'Name' }, { key: 'dataType', label: 'Data Type' }, { key: 'isInput', label: 'Input' }, { key: 'isOutput', label: 'Output' }, { key: 'objectType', label: 'Object Type' } ], 'formulas': [ { key: 'name', label: 'Name' }, { key: 'dataType', label: 'Data Type' }, { key: 'expression', label: 'Expression' } ], 'interviews': [ { key: 'Name', label: 'Interview' }, { key: 'InterviewStatus', label: 'Status' }, { key: 'CurrentElement', label: 'Current Element' }, { key: 'CreatedDate', label: 'Created' }, { key: 'CreatedBy.Name', label: 'Created By' } ] }; return columnMap[section] || []; } // Get nested property value function getValue(obj, path) { return path.split('.').reduce((current, key) => current?.[key], obj); } // Filter and sort data function filterAndSort() { let items = [...data[currentSection]]; // Apply search filter if (searchQuery) { const columns = getColumns(currentSection); items = items.filter(item => columns.some(col => { const value = getValue(item, col.key); return value != null && value.toString().toLowerCase().includes(searchQuery); }) ); } // Apply sorting if (sortColumn) { items.sort((a, b) => { let valueA = getValue(a, sortColumn); let valueB = getValue(b, sortColumn); // Handle boolean values if (typeof valueA === 'boolean') valueA = valueA ? 1 : 0; if (typeof valueB === 'boolean') valueB = valueB ? 1 : 0; // Handle null/undefined if (valueA == null) valueA = ''; if (valueB == null) valueB = ''; // Convert to strings for comparison valueA = valueA.toString().toLowerCase(); valueB = valueB.toString().toLowerCase(); const comparison = valueA > valueB ? 1 : valueA < valueB ? -1 : 0; return sortDirection === 'asc' ? comparison : -comparison; }); } return items; } // Render table function render() { filteredData = filterAndSort(); const columns = getColumns(currentSection); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; const pageData = filteredData.slice(startIndex, endIndex); const display = document.getElementById('content-display'); if (!pageData.length) { display.innerHTML = '<div class="text-center text-gray-400 py-8">No data found</div>'; updatePagination(); return; } // Build table let html = '<table class="w-full text-left"><thead><tr>'; columns.forEach(col => { const icon = sortColumn === col.key ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'; const sortedClass = sortColumn === col.key ? 'sorted' : ''; html += \`<th class="px-4 py-3 bg-slate-700 font-semibold \${sortedClass}" data-col="\${col.key}"> \${col.label}<span class="sort-icon">\${icon}</span> </th>\`; }); html += '</tr></thead><tbody>'; pageData.forEach(row => { html += '<tr class="border-b border-slate-700 hover:bg-slate-700">'; columns.forEach(col => { let value = getValue(row, col.key); // Format value if (value === true) value = 'Yes'; if (value === false) value = 'No'; if (col.key.includes('Date') && value) { value = new Date(value).toLocaleString(); } if (value == null) value = '—'; html += \`<td class="px-4 py-3">\${value}</td>\`; }); html += '</tr>'; }); html += '</tbody></table>'; display.innerHTML = html; // Add sort handlers display.querySelectorAll('th[data-col]').forEach(th => { th.onclick = () => { const col = th.dataset.col; if (sortColumn === col) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { sortColumn = col; sortDirection = 'asc'; } render(); }; }); updatePagination(); } // Update pagination controls function updatePagination() { const total = filteredData.length; const totalPages = Math.ceil(total / ITEMS_PER_PAGE); const start = Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, total); const end = Math.min(currentPage * ITEMS_PER_PAGE, total); document.getElementById('page-info').textContent = \`\${start}–\${end} of \${total}\`; const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); prevBtn.disabled = currentPage === 1; nextBtn.disabled = currentPage === totalPages || totalPages === 0; prevBtn.onclick = () => { if (currentPage > 1) { currentPage--; render(); } }; nextBtn.onclick = () => { if (currentPage < totalPages) { currentPage++; render(); } }; // Page number buttons const pageNumbers = document.getElementById('page-numbers'); pageNumbers.innerHTML = ''; for (let i = 1; i <= totalPages; i++) { const btn = document.createElement('button'); btn.className = 'page-btn bg-slate-700 hover:bg-slate-600'; if (i === currentPage) btn.classList.add('active'); btn.textContent = i; btn.onclick = () => { currentPage = i; render(); }; pageNumbers.appendChild(btn); } } // Export to CSV function exportToCSV() { const columns = getColumns(currentSection); const rows = filterAndSort(); let csv = columns.map(c => c.label).join(',') + '\\n'; rows.forEach(row => { csv += columns.map(col => { let value = getValue(row, col.key); if (value == null) value = ''; value = value.toString(); // Escape CSV special characters if (value.includes(',') || value.includes('"') || value.includes('\\n')) { value = '"' + value.replace(/"/g, '""') + '"'; } return value; }).join(',') + '\\n'; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = \`\${currentSection}.csv\`; link.click(); URL.revokeObjectURL(url); } // Reset state function resetState() { currentPage = 1; sortColumn = null; sortDirection = 'asc'; searchQuery = ''; document.getElementById('search-input').value = ''; } // Initial render render(); `; } // ======================================================================== // LIST FLOWS // ======================================================================== /** * Lists all flows in the org * @param {Object} conn - Connection object * @param {string} format - Output format * @param {boolean} openBrowser - Whether to open in browser */ async function listFlows(conn, format = OUTPUT_FORMATS.HTML, openBrowser = true) { console.log('📋 Fetching all Flow definitions...'); // Query all active Flow versions to get the flow list const query = ` SELECT Definition.DeveloperName, MasterLabel, ProcessType, Status, VersionNumber FROM Flow WHERE Status = 'Active' OR Status = 'Draft' ORDER BY MasterLabel `.replace(/\s+/g, ' ').trim(); const response = await queryTooling(conn, query); const flowRecords = response.records || []; // Group by DeveloperName to get unique flows const flowMap = new Map(); flowRecords.forEach(f => { const devName = f.Definition?.DeveloperName; if (!devName) return; // Keep the active version, or the highest version number if (!flowMap.has(devName) || f.Status === 'Active') { flowMap.set(devName, { ApiName: devName, Label: f.MasterLabel, ProcessType: f.ProcessType || 'Unknown', IsActive: f.Status === 'Active', VersionNumber: f.VersionNumber }); } }); const normalizedFlows = Array.from(flowMap.values()).sort((a, b) => a.Label.localeCompare(b.Label) ); console.log(`✅ Found ${normalizedFlows.length} flow(s)`); switch (format) { case OUTPUT_FORMATS.JSON: console.log(JSON.stringify(normalizedFlows, null, 2)); break; case OUTPUT_FORMATS.CSV: outputCSV(normalizedFlows); break; case OUTPUT_FORMATS.TABLE: outputTable(normalizedFlows); break; case OUTPUT_FORMATS.HTML: default: await outputHTMLList(normalizedFlows, openBrowser); break; } } /** * Outputs flows as CSV * @param {Array} flows - Array of flows */ function outputCSV(flows) { const headers = ['API Name', 'Label', 'Process Type', 'Active']; const rows = flows.map(f => [ f.ApiName, f.Label, f.ProcessType, f.IsActive ].map(v => `"${(v || '').toString().replace(/"/g, '""')}"`).join(',')); console.log([headers.join(','), ...rows].join('\n')); } /** * Outputs flows as table * @param {Array} flows - Array of flows */ function outputTable(flows) { const table = flows.map(f => ({ 'API Name': f.ApiName, 'Label': f.Label, 'Type': f.ProcessType, 'Active': f.IsActive ? 'Yes' : 'No' })); console.table(table); } /** * Outputs flows as HTML list * @param {Array} flows - Array of flows * @param {boolean} openBrowser - Whether to open in browser */ async function outputHTMLList(flows, openBrowser) { const html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Flows in Org</title> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-slate-900 text-gray-200 p-8"> <div class="max-w-7xl mx-auto"> <h1 class="text-3xl font-bold mb-6 text-blue-400">All Flows</h1> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> ${flows.map(f => ` <a href="${escapeHtml(f.ApiName)}${CONSTANTS.HTML_EXTENSION}" class="block p-6 bg-slate-800 rounded-lg hover:bg-slate-700 transition shadow-lg"> <h3 class="font-semibold text-lg mb-2">${escapeHtml(f.Label)}</h3> <p class="text-sm text-gray-400 mb-2">API: ${escapeHtml(f.ApiName)}</p> <p class="text-xs ${f.IsActive ? 'text-green-400' : 'text-gray-500'}"> ${f.IsActive ? '● Active' : '○ Inactive'} </p> </a> `).join('')} </div> </div> </body> </html>`; const directory = path.join(process.cwd(), CONSTANTS.OUTPUT_DIR); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } const indexPath = path.join(directory, 'index.html'); fs.writeFileSync(indexPath, html); console.log(`💾 List saved to: ${indexPath}`); if (openBrowser) { openResource(indexPath); } } // ======================================================================== // CLI COMMANDS // ======================================================================== const program = new Command(); program .name('sf-flow-viewer') .description('View Salesforce Flow details in a beautiful HTML interface') .version('1.0.0'); program .command('list') .description('List all flows in the org') .requiredOption('-o, --org <username>', 'Salesforce org username or alias') .option('--format <format>', 'Output format (html|table|json|csv)', OUTPUT_FORMATS.HTML) .option('--no-open', 'Do not open in browser') .action(async (options) => { try { const conn = getSalesforceConnection(options.org); await listFlows(conn, options.format, options.open); console.log('✅ Done!'); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } }); program .command('view') .description('View detailed flow in HTML') .requiredOption('-o, --org <username>', 'Salesforce org username or alias') .requiredOption('-f, --flow <apiName>', 'Flow API name') .option('--no-open', 'Do not open in browser') .action(async (options) => { try { const conn = getSalesforceConnection(options.org); const data = await getFlowData(conn, options.flow); await saveFlowToXML(options.flow, data); const html = generateHTML(data); const directory = path.join(process.cwd(), CONSTANTS.OUTPUT_DIR); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } const filePath = path.join(directory, `${options.flow}${CONSTANTS.HTML_EXTENSION}`); fs.writeFileSync(filePath, html); console.log(`💾 Flow viewer saved to: ${filePath}`); if (options.open) { openResource(filePath); } console.log('✅ Done!'); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } }); // Parse CLI arguments program.parse();