Loading Data from Multiple Sources into Jspreadsheet Pro with OpenETL
Build dynamic spreadsheets that pull data from databases, CRMs, and APIs
Introduction
Jspreadsheet Pro is a powerful JavaScript library for creating Excel-like spreadsheets in web applications. When combined with OpenETL, you can build dynamic spreadsheets that pull data from virtually any source: PostgreSQL, MySQL, MongoDB, HubSpot, Stripe, Xero, and more.
In this tutorial, we'll build a full-stack application that:
- Fetches data from multiple sources using OpenETL
- Transforms and normalizes the data
- Displays it in an interactive Jspreadsheet Pro grid
- Allows users to edit and sync changes back to the source
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Jspreadsheet Pro │ │
│ │ ┌─────┬─────────┬──────────┬─────────┬───────────────┐ │ │
│ │ │ ID │ Name │ Email │ Company │ Status │ │ │
│ │ ├─────┼─────────┼──────────┼─────────┼───────────────┤ │ │
│ │ │ 1 │ John │ [email protected] │ Acme │ Active │ │ │
│ │ │ 2 │ Jane │ [email protected] │ Corp │ Active │ │ │
│ │ └─────┴─────────┴──────────┴─────────┴───────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
│ REST API
┌───────────────────────────▼─────────────────────────────────────┐
│ Backend │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ OpenETL Orchestrator │ │
│ └──────────┬──────────────┬──────────────┬────────────────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ PostgreSQL │ │ HubSpot │ │ Xero │ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └─────────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
Backend Setup
Installation
mkdir spreadsheet-app
cd spreadsheet-app
npm init -y
npm install express cors openetl @openetl/postgresql @openetl/hubspot @openetl/xero
npm install -D typescript @types/express @types/node ts-node
Project Structure
spreadsheet-app/
├── src/
│ ├── server.ts
│ ├── routes/
│ │ └── data.ts
│ ├── services/
│ │ └── etl.ts
│ └── config/
│ └── vault.ts
├── public/
│ ├── index.html
│ └── app.js
├── package.json
└── tsconfig.json
Vault Configuration
// src/config/vault.ts
import { Vault } from 'openetl';
export const vault: Vault = {
'postgres-db': {
id: 'postgres-db',
type: 'basic',
credentials: {
host: process.env.PG_HOST || 'localhost',
port: process.env.PG_PORT || '5432',
database: process.env.PG_DATABASE || 'myapp',
username: process.env.PG_USER || 'postgres',
password: process.env.PG_PASSWORD || '',
},
},
'hubspot-api': {
id: 'hubspot-api',
type: 'oauth2',
credentials: {
client_id: process.env.HUBSPOT_CLIENT_ID || '',
client_secret: process.env.HUBSPOT_CLIENT_SECRET || '',
access_token: process.env.HUBSPOT_ACCESS_TOKEN || '',
refresh_token: process.env.HUBSPOT_REFRESH_TOKEN || '',
},
},
'xero-api': {
id: 'xero-api',
type: 'oauth2',
credentials: {
client_id: process.env.XERO_CLIENT_ID || '',
client_secret: process.env.XERO_CLIENT_SECRET || '',
access_token: process.env.XERO_ACCESS_TOKEN || '',
refresh_token: process.env.XERO_REFRESH_TOKEN || '',
},
},
};
ETL Service
// src/services/etl.ts
import { Orchestrator, Pipeline, Vault } from 'openetl';
import { postgresql } from '@openetl/postgresql';
import { hubspot } from '@openetl/hubspot';
import { xero } from '@openetl/xero';
const adapters = { postgresql, hubspot, xero };
export class ETLService {
private orchestrator;
constructor(vault: Vault) {
this.orchestrator = Orchestrator(vault, adapters);
}
/**
* Fetch customers from PostgreSQL database
*/
async getCustomers(limit = 100, offset = 0) {
const pipeline: Pipeline = {
id: `customers-${Date.now()}`,
source: {
id: 'pg-customers',
adapter_id: 'postgresql',
endpoint_id: 'table_query',
credential_id: 'postgres-db',
fields: ['id', 'name', 'email', 'company', 'status', 'created_at'],
config: {
schema: 'public',
table: 'customers',
},
sort: [{ field: 'created_at', type: 'desc' }],
pagination: {
type: 'offset',
itemsPerPage: limit,
},
},
};
const result = await this.orchestrator.runPipeline(pipeline);
return result.data;
}
/**
* Fetch contacts from HubSpot CRM
*/
async getHubSpotContacts(limit = 100) {
const pipeline: Pipeline = {
id: `hubspot-contacts-${Date.now()}`,
source: {
id: 'hs-contacts',
adapter_id: 'hubspot',
endpoint_id: 'contacts',
credential_id: 'hubspot-api',
fields: ['email', 'firstname', 'lastname', 'company', 'phone', 'lifecyclestage'],
pagination: {
type: 'cursor',
itemsPerPage: limit,
},
// Transform HubSpot data to flat structure
transform: [
{
type: 'concat',
options: {
properties: ['firstname', 'lastname'],
glue: ' ',
to: 'full_name',
},
},
],
},
};
const result = await this.orchestrator.runPipeline(pipeline);
return result.data;
}
/**
* Fetch invoices from Xero
*/
async getXeroInvoices(limit = 100) {
const pipeline: Pipeline = {
id: `xero-invoices-${Date.now()}`,
source: {
id: 'xero-inv',
adapter_id: 'xero',
endpoint_id: 'invoices',
credential_id: 'xero-api',
fields: ['InvoiceID', 'InvoiceNumber', 'Contact', 'Total', 'Status', 'DueDate'],
config: {
organisationName: process.env.XERO_ORG_NAME || 'My Company',
},
pagination: {
type: 'offset',
itemsPerPage: limit,
},
},
};
const result = await this.orchestrator.runPipeline(pipeline);
// Flatten nested Contact object
return result.data.map((invoice: any) => ({
id: invoice.InvoiceID,
invoice_number: invoice.InvoiceNumber,
contact_name: invoice.Contact?.Name || '',
total: invoice.Total,
status: invoice.Status,
due_date: invoice.DueDate,
}));
}
/**
* Insert new customer into PostgreSQL
*/
async createCustomer(data: any) {
const pipeline: Pipeline = {
id: `create-customer-${Date.now()}`,
data: [data],
target: {
id: 'pg-insert',
adapter_id: 'postgresql',
endpoint_id: 'table_insert',
credential_id: 'postgres-db',
fields: ['name', 'email', 'company', 'status'],
config: {
schema: 'public',
table: 'customers',
},
},
};
return await this.orchestrator.runPipeline(pipeline);
}
}
API Routes
// src/routes/data.ts
import { Router, Request, Response } from 'express';
import { ETLService } from '../services/etl';
import { vault } from '../config/vault';
const router = Router();
const etlService = new ETLService(vault);
// Get customers from PostgreSQL
router.get('/customers', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 100;
const offset = parseInt(req.query.offset as string) || 0;
const data = await etlService.getCustomers(limit, offset);
// Format for Jspreadsheet
res.json({
columns: [
{ type: 'hidden', name: 'id' },
{ type: 'text', title: 'Name', name: 'name', width: 150 },
{ type: 'text', title: 'Email', name: 'email', width: 200 },
{ type: 'text', title: 'Company', name: 'company', width: 150 },
{
type: 'dropdown',
title: 'Status',
name: 'status',
width: 100,
source: ['active', 'inactive', 'pending'],
},
{ type: 'calendar', title: 'Created', name: 'created_at', width: 120 },
],
data: data,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get contacts from HubSpot
router.get('/hubspot/contacts', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 100;
const data = await etlService.getHubSpotContacts(limit);
res.json({
columns: [
{ type: 'text', title: 'Name', name: 'full_name', width: 150, readOnly: true },
{ type: 'text', title: 'Email', name: 'email', width: 200 },
{ type: 'text', title: 'Company', name: 'company', width: 150 },
{ type: 'text', title: 'Phone', name: 'phone', width: 120 },
{ type: 'text', title: 'Stage', name: 'lifecyclestage', width: 100 },
],
data: data,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get invoices from Xero
router.get('/xero/invoices', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 100;
const data = await etlService.getXeroInvoices(limit);
res.json({
columns: [
{ type: 'hidden', name: 'id' },
{ type: 'text', title: 'Invoice #', name: 'invoice_number', width: 100 },
{ type: 'text', title: 'Contact', name: 'contact_name', width: 150 },
{ type: 'numeric', title: 'Total', name: 'total', width: 100, mask: '$ #,##0.00' },
{
type: 'dropdown',
title: 'Status',
name: 'status',
width: 100,
source: ['DRAFT', 'SUBMITTED', 'AUTHORISED', 'PAID', 'VOIDED'],
},
{ type: 'calendar', title: 'Due Date', name: 'due_date', width: 120 },
],
data: data,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create new customer
router.post('/customers', async (req: Request, res: Response) => {
try {
await etlService.createCustomer(req.body);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;
Express Server
// src/server.ts
import express from 'express';
import cors from 'cors';
import path from 'path';
import dataRoutes from './routes/data';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
app.use('/api', dataRoutes);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Frontend Implementation
HTML Structure
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Dashboard - OpenETL + Jspreadsheet</title>
<!-- Jspreadsheet Pro -->
<script src="https://jspreadsheet.com/v11/jspreadsheet.js"></script>
<script src="https://jsuites.net/v5/jsuites.js"></script>
<link rel="stylesheet" href="https://jspreadsheet.com/v11/jspreadsheet.css" type="text/css" />
<link rel="stylesheet" href="https://jsuites.net/v5/jsuites.css" type="text/css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
margin-bottom: 20px;
color: #333;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
background: #f0f0f0;
}
.tab.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.spreadsheet-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
#spreadsheet {
min-height: 400px;
}
</style>
</head>
<body>
<div class="container">
<h1>Data Dashboard</h1>
<div class="tabs">
<button class="tab active" data-source="customers">Customers (PostgreSQL)</button>
<button class="tab" data-source="hubspot">Contacts (HubSpot)</button>
<button class="tab" data-source="xero">Invoices (Xero)</button>
</div>
<div class="spreadsheet-container">
<div class="toolbar">
<button class="btn btn-primary" id="refresh-btn">Refresh Data</button>
<button class="btn btn-success" id="add-btn">Add Row</button>
<button class="btn btn-secondary" id="export-btn">Export CSV</button>
</div>
<div id="spreadsheet">
<div class="loading">Loading data...</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
JavaScript Application
// public/app.js
let spreadsheet = null;
let currentSource = 'customers';
const endpoints = {
customers: '/api/customers',
hubspot: '/api/hubspot/contacts',
xero: '/api/xero/invoices',
};
/**
* Load data from API and render spreadsheet
*/
async function loadData(source) {
const container = document.getElementById('spreadsheet');
container.innerHTML = '<div class="loading">Loading data...</div>';
try {
const response = await fetch(endpoints[source]);
const { columns, data } = await response.json();
// Destroy existing spreadsheet
if (spreadsheet) {
jspreadsheet.destroy(container);
}
// Create new spreadsheet
spreadsheet = jspreadsheet(container, {
data: data,
columns: columns,
style: {
A1: 'font-weight: bold;',
},
tableOverflow: true,
tableWidth: '100%',
tableHeight: '500px',
allowInsertRow: source === 'customers',
allowDeleteRow: source === 'customers',
allowInsertColumn: false,
allowDeleteColumn: false,
columnSorting: true,
search: true,
pagination: 20,
paginationOptions: [10, 20, 50, 100],
// Event handlers
onchange: function(instance, cell, x, y, value) {
console.log('Cell changed:', { x, y, value });
// Here you could implement auto-save
},
oninsertrow: function(instance, rowNumber, numRows, insertBefore) {
console.log('Row inserted:', rowNumber);
},
ondeleterow: function(instance, rowNumber, numRows) {
console.log('Row deleted:', rowNumber);
},
// Context menu customization
contextMenu: function(instance, x, y, e, items) {
// Add custom menu items
items.push({
title: 'View Details',
onclick: function() {
const data = instance.getRowData(y);
alert(JSON.stringify(data, null, 2));
}
});
return items;
},
});
currentSource = source;
} catch (error) {
container.innerHTML = `<div class="loading" style="color: red;">
Error loading data: ${error.message}
</div>`;
}
}
/**
* Add a new row to the spreadsheet
*/
function addRow() {
if (spreadsheet && currentSource === 'customers') {
spreadsheet.insertRow();
} else {
alert('Adding rows is only available for the Customers table');
}
}
/**
* Export spreadsheet data to CSV
*/
function exportCSV() {
if (spreadsheet) {
spreadsheet.download();
}
}
/**
* Save changes back to the database
*/
async function saveChanges() {
if (!spreadsheet || currentSource !== 'customers') {
return;
}
const data = spreadsheet.getData();
const headers = spreadsheet.getHeaders();
// Convert to array of objects
const records = data.map(row => {
const record = {};
headers.forEach((header, index) => {
record[header.toLowerCase().replace(' ', '_')] = row[index];
});
return record;
});
// Filter new records (no ID)
const newRecords = records.filter(r => !r.id);
for (const record of newRecords) {
try {
await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(record),
});
} catch (error) {
console.error('Failed to save record:', error);
}
}
// Reload to get new IDs
loadData(currentSource);
}
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
loadData(this.dataset.source);
});
});
// Toolbar buttons
document.getElementById('refresh-btn').addEventListener('click', () => loadData(currentSource));
document.getElementById('add-btn').addEventListener('click', addRow);
document.getElementById('export-btn').addEventListener('click', exportCSV);
// Initial load
loadData('customers');
Advanced Features
Real-Time Updates with WebSocket
// Add to server.ts
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('Client connected');
// Send updates when data changes
ws.on('message', async (message) => {
const { action, source } = JSON.parse(message.toString());
if (action === 'subscribe') {
// Set up polling or database triggers
setInterval(async () => {
const data = await etlService.getCustomers(100, 0);
ws.send(JSON.stringify({ type: 'update', source, data }));
}, 30000); // Poll every 30 seconds
}
});
});
Cell Validation
// Add validation rules to columns
columns: [
{
type: 'text',
title: 'Email',
name: 'email',
width: 200,
// Validate email format
validate: function(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
},
{
type: 'numeric',
title: 'Amount',
name: 'amount',
width: 100,
// Validate positive numbers
validate: function(value) {
return !isNaN(value) && parseFloat(value) >= 0;
}
},
]
Conditional Formatting
// Add updateTable callback for conditional formatting
updateTable: function(instance, cell, x, y, source, value, id) {
// Highlight overdue invoices
if (currentSource === 'xero') {
const statusCol = 4; // Status column index
const dueDateCol = 5; // Due date column index
if (x === statusCol && value !== 'PAID') {
const dueDate = instance.getValueFromCoords(dueDateCol, y);
if (new Date(dueDate) < new Date()) {
cell.style.backgroundColor = '#ffcccc';
}
}
}
// Highlight inactive customers
if (currentSource === 'customers' && x === 3 && value === 'inactive') {
cell.style.color = '#999';
cell.style.fontStyle = 'italic';
}
}
Multi-Source Dashboard
// Load multiple sources simultaneously
async function loadDashboard() {
const [customers, contacts, invoices] = await Promise.all([
fetch('/api/customers').then(r => r.json()),
fetch('/api/hubspot/contacts').then(r => r.json()),
fetch('/api/xero/invoices').then(r => r.json()),
]);
// Create multiple spreadsheet instances
jspreadsheet(document.getElementById('customers-grid'), {
data: customers.data,
columns: customers.columns,
tableHeight: '300px',
});
jspreadsheet(document.getElementById('contacts-grid'), {
data: contacts.data,
columns: contacts.columns,
tableHeight: '300px',
});
jspreadsheet(document.getElementById('invoices-grid'), {
data: invoices.data,
columns: invoices.columns,
tableHeight: '300px',
});
}
Data Synchronization
Two-Way Sync Pattern
// src/services/sync.ts
export class SyncService {
private etl: ETLService;
constructor(vault: Vault) {
this.etl = new ETLService(vault);
}
/**
* Sync contacts from HubSpot to local database
*/
async syncHubSpotToDatabase() {
// Download from HubSpot
const contacts = await this.etl.getHubSpotContacts(1000);
// Upload to PostgreSQL
const pipeline: Pipeline = {
id: `sync-hubspot-${Date.now()}`,
data: contacts.map(c => ({
email: c.email,
name: c.full_name,
company: c.company,
source: 'hubspot',
synced_at: new Date().toISOString(),
})),
target: {
id: 'pg-sync',
adapter_id: 'postgresql',
endpoint_id: 'table_insert',
credential_id: 'postgres-db',
fields: ['email', 'name', 'company', 'source', 'synced_at'],
config: { schema: 'public', table: 'contacts_sync' },
},
};
return await this.orchestrator.runPipeline(pipeline);
}
}
Conclusion
Combining OpenETL with Jspreadsheet Pro creates a powerful platform for:
- Unified data access: Query any source through a single interface
- Interactive editing: Excel-like experience in the browser
- Real-time sync: Keep data up-to-date across systems
- Export capabilities: Download data as CSV, Excel, or JSON
This architecture scales from simple data viewers to full-featured business applications.
Resources
Built with OpenETL and Jspreadsheet Pro