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
JavaScript
/**
* 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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
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();