Appearance
Excel Export Library for Questionnaire Data
- Status: accepted
- Deciders: Development Team
- Date: 2026-01-27
- Tags: excel, export, questionnaire, frontend, exceljs
Technical Story: Questionnaire Export Feature - Users need to export questionnaire data (questions, supplier responses, metadata) to Excel format for offline analysis and sharing with stakeholders.
Decision Outcome
Chosen Option 1: Frontend Export with exceljs. Using exceljs on the negotiate.ui frontend is the best way forward as it will balance development time and the export needs of the users well.
Context and Problem Statement
Users need to export questionnaire data to Excel format. We need to choose an appropriate library and architecture (frontend vs backend) for generating Excel exports. The system must support:
- Exporting questionnaire questions and supplier responses
- Professional formatting (headers, logos, styling)
- Multi-sheet workbooks (summary + detailed data)
- Large datasets (1K+ rows)
- Secure handling of potentially sensitive data
How should we implement Excel export functionality for questionnaires in the negotiate.ui application?
Decision Drivers
- Security: Questionnaire responses may contain PII or sensitive business data
- User Experience: Exports should be fast and provide progress feedback
- Maintainability: Minimize dependencies and operational overhead
- Existing Patterns: Leverage proven patterns from mp.msource.frontend and msource
- Scalability: Handle both small (100 rows) and large (10K+ rows) exports
- Consistency: Match existing export patterns where appropriate
Considered Options
Option 1: Frontend Export with exceljs (Recommended)
Option 2: Backend Export with DocumentFormat.OpenXml + Azure Queue Storage
Option 3: Backend Export with ClosedXML (Legacy Pattern)
Current Excel Implementation Landscape
mp.msource.frontend (Legacy Frontend)
| Library | Location | Use Case | Status |
|---|---|---|---|
| exceljs v4.4.0 | apps/webapp/src/component/page/sourcingRoadmap/exportExcel/ | Sourcing Roadmap custom export | ✅ Production |
| MUI DataGrid Premium | libs/master-components/src/lib/Miscellaneous/cdDataGrid/ | Analytics, spend overview, diversity exports (grid-based data only) | ✅ Production |
exceljs Pattern:
typescript
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('3 Years');
// Embed logo
const logoImage = workbook.addImage({
base64: premierLogo,
extension: 'png',
});
worksheet.addImage(logoImage, {
tl: { col: 0.99, row: 0.99 },
ext: { width: 120, height: 25 },
});
// Export
const buffer = await workbook.xlsx.writeBuffer();
saveAs(new Blob([buffer]), 'export.xlsx');Pros:
- ✅ Client-side processing (no server load)
- ✅ Immediate download (no polling)
- ✅ Rich formatting support (colors, fonts, formulas, images)
- ✅ Multi-sheet workbooks
- ✅ Proven in production (Sourcing Roadmap)
- ✅ 85KB bundle size (reasonable)
Cons:
- ❌ All data exposed to browser (security risk for PII)
- ❌ Memory-intensive for 10K+ row exports
- ❌ No progress indication for long-running exports
- ❌ Client CPU usage impacts performance
msource (Legacy Backend)
| Library | Location | Use Case | Status |
|---|---|---|---|
| ClosedXML v0.91.0 | msource/src/.../GetTermsSideBySideDownloadResponseBuilder.cs | Commercial terms side-by-side comparison | ✅ Production |
| ClosedXML v0.91.0 | msource/src/.../GetExportTermsResponseBuilder.cs | Terms & conditions export | ✅ Production |
| ClosedXML v0.91.0 | msource/src/.../CommercialTermsDataImportService.cs | Excel import (commercial terms) | ✅ Production |
| ClosedXML v0.91.0 | msource/src/.../SupplierDataImportService.cs | Excel import (supplier data with SignalR progress) | ✅ Production |
ClosedXML Pattern:
csharp
// Load template
var templatePath = Path.Combine(directory, "Terms_Extract_Template_Side_By_Side.xlsx");
using var workbook = new XLWorkbook(templatePath);
var worksheet = workbook.Worksheet(1);
// Populate cells
worksheet.Cell(2, 1).Value = eventName;
worksheet.Cell(3, 1).Value = organizationName;
// Stream response
var stream = new MemoryStream();
workbook.SaveAs(stream);
stream.Position = 0;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
};Pros:
- ✅ Server-side data filtering (secure)
- ✅ Template-based approach (reusable, consistent branding)
- ✅ No client memory constraints
- ✅ Works for import and export
- ✅ Proven in production (multiple endpoints)
Cons:
- ❌ Synchronous HTTP blocking (timeout risk on large exports)
- ❌ Outdated library (ClosedXML 0.91.0 from 2019, not actively maintained)
- ❌ No progress tracking for users
- ❌ Memory buffering (entire file in RAM)
- ❌ No async/await patterns
- ❌ Direct HTTP streaming (doesn't scale well)
negotiate (New Backend)
| Library | Location | Use Case | Status |
|---|---|---|---|
| ExcelDataReader v3.7.0 | services/product-pricing/Co.Negotiate.ProductPricing.Service | Product pricing import (read-only) | ✅ Production |
| DocumentFormat.OpenXml v3.3.0 | services/core/Co.Negotiate.Core.Infrastructure | File validation (IsValidExcelDocument) | ✅ Production |
| DocumentFormat.OpenXml v3.3.0 | services/core/Co.Negotiate.Core.Service | Available but not yet used for exports | ⚠️ Not Used |
ExcelDataReader Pattern (Import Only):
csharp
using var stream = file.OpenReadStream();
using var reader = ExcelReaderFactory.CreateReader(stream);
var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration
{
ConfigureDataTable = _ => new ExcelDataTableConfiguration
{
UseHeaderRow = true
}
});DocumentFormat.OpenXml Pattern (Validation Only):
csharp
public static bool IsValidExcelDocument(IFile file)
{
try
{
using var stream = file.OpenReadStream();
using var doc = SpreadsheetDocument.Open(stream, false);
return doc.WorkbookPart != null;
}
catch { return false; }
}Pros:
- ✅ Microsoft-official library (long-term support)
- ✅ .NET 9 first-class support
- ✅ Already in dependencies (no new library)
- ✅ Suitable for complex exports
Cons:
- ❌ Not yet used for actual Excel generation
- ❌ More verbose API than ClosedXML
- ❌ Would require template infrastructure
- ❌ Requires async job queue for large exports
negotiate.ui (New Frontend)
| Library | Location | Use Case | Status |
|---|---|---|---|
| exceljs | Not yet installed | Target for questionnaire exports | 🎯 Proposed |
Status: No Excel export functionality currently implemented.
Note: MUI DataGrid Premium is available in the project but not suitable for questionnaire exports due to incompatibility with surveyjs data structures and requirements for multi-sheet workbooks with custom formatting.
Other Projects (Not in Scope)
| Library | Location | Use Case | Status |
|---|---|---|---|
| Syncfusion | External project (not msource/mp.msource.frontend) | Excel/spreadsheet components | ❌ Excluded |
Why Excluded from Consideration:
- ❌ Commercial licensing required - Adds cost and license management overhead
- ❌ Complexity - Heavy enterprise library with steep learning curve
- ❌ Not in current stack - Would introduce new dependency
- ❌ Overkill for use case - Questionnaire exports don't require Syncfusion's advanced features
- ❌ Past Experience - Terrible support/documentation, buggy and brittle
Syncfusion Capabilities (for reference):
- Excel/spreadsheet manipulation (both client and server)
- Advanced grid components
- PDF generation, Word processing
- Requires paid license for commercial use
Decision Outcome
Chosen option: "Frontend Export with exceljs", because:
- Questionnaire data is already client-side - User has accessed and viewed the questionnaire in the browser, so data is already in memory
- Immediate user feedback - No polling, no job queue complexity
- Proven pattern - Already used successfully in mp.msource.frontend Sourcing Roadmap
- No operational overhead - No backend queue workers, Azure Queue Storage, or job tracking database needed
- Simpler implementation - 1-2 weeks vs 4-6 weeks for backend async pattern
- PII concerns mitigated - Users already have access to questionnaire data via the UI; exporting to Excel doesn't increase exposure
- Consistent with existing tools - Matches mp.msource.frontend pattern users are familiar with
Implementation Plan
Phase 1: Basic Export (Week 1)
bash
npm install exceljs@^4.4.0 file-saver@^2.0.5
npm install --save-dev @types/file-saverComponent Structure:
typescript
// src/Features/Questionnaire/components/QuestionnaireExportButton.tsx
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
export const QuestionnaireExportButton = ({ questionnaire, responses }) => {
const handleExport = async () => {
const workbook = new ExcelJS.Workbook();
// Metadata
workbook.creator = 'Negotiate Platform';
workbook.created = new Date();
// Summary sheet
const summarySheet = workbook.addWorksheet('Summary');
summarySheet.columns = [
{ header: 'Event Name', key: 'eventName', width: 30 },
{ header: 'Export Date', key: 'exportDate', width: 20 },
{ header: 'Exported By', key: 'user', width: 25 }
];
// Questions sheet
const questionsSheet = workbook.addWorksheet('Questions');
questionsSheet.columns = [
{ header: 'Question #', key: 'number', width: 12 },
{ header: 'Question Text', key: 'text', width: 50 },
{ header: 'Category', key: 'category', width: 20 },
{ header: 'Response Type', key: 'type', width: 15 }
];
questionnaire.questions.forEach(q => {
questionsSheet.addRow({
number: q.number,
text: q.text,
category: q.category,
type: q.responseType
});
});
// Responses sheet (if includeSupplierResponses)
if (responses.length > 0) {
const responsesSheet = workbook.addWorksheet('Responses');
responsesSheet.columns = [
{ header: 'Question #', key: 'questionNumber', width: 12 },
{ header: 'Supplier', key: 'supplier', width: 30 },
{ header: 'Response', key: 'response', width: 50 }
];
responses.forEach(r => {
responsesSheet.addRow({
questionNumber: r.questionNumber,
supplier: r.supplierName,
response: r.answer
});
});
}
// Style headers
[summarySheet, questionsSheet, workbook.getWorksheet('Responses')].forEach(sheet => {
if (sheet) {
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' }
};
}
});
// Generate and download
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, `questionnaire-${questionnaire.eventId}-${new Date().toISOString()}.xlsx`);
};
return (
<Button onClick={handleExport} startIcon={<CloudDownloadIcon />}>
Export to Excel
</Button>
);
};Phase 2: Enhanced Formatting (Week 2)
- Add company logo (base64-encoded Premier logo)
- Apply cell borders and colors
- Add formulas (e.g., response completion %)
- Freeze header rows
- Auto-filter on columns
Positive Consequences
- ✅ Fast time-to-market (1-2 weeks vs 4-6 weeks backend approach)
- ✅ No additional backend infrastructure needed
- ✅ Leverages existing pattern from mp.msource.frontend
- ✅ Users get immediate feedback (no polling, no job queue)
- ✅ Scales adequately for expected dataset sizes (<5K rows typical)
- ✅ Easy to iterate and add features (formatting, additional sheets)
- ✅ Consistent with how users currently export sourcing roadmap data
Negative Consequences
- ❌ Large exports (>10K rows) may cause browser memory issues
- ❌ No server-side progress tracking
- ❌ Client-side CPU usage during export
- ❌ If questionnaire data becomes highly sensitive, would need to revisit architecture
- ❌ Different pattern than what we'd use for bulk/scheduled exports in the future
Mitigation Strategies
- For large exports: Show warning if >5K rows; suggest filtering or contact support
- For very large exports: Phase 2 can add backend async export option for power users
- For sensitive data: Already mitigated - user has permission to view data in UI
- For performance: Use Web Workers if needed (Phase 3 optimization)
Pros and Cons of the Options
Option 1: Frontend Export with exceljs (Selected)
Implementation Complexity: Low
Time to Implement: 1-2 weeks
Operational Overhead: None
Best For: <5K rows, immediate exports
Pros:
- ✅ Immediate download (no polling or job queue)
- ✅ No backend infrastructure changes needed
- ✅ Proven in production (mp.msource.frontend)
- ✅ Rich formatting support (images, colors, formulas)
- ✅ Multi-sheet workbooks
- ✅ Simple to maintain and iterate
- ✅ 85KB bundle size (acceptable)
- ✅ Fast time-to-market
Cons:
- ❌ Client memory constraints (10K+ rows may struggle)
- ❌ No progress indication for long-running exports
- ❌ CPU usage on client device
- ❌ Different pattern from potential future backend exports
Option 2: Backend Export with DocumentFormat.OpenXml + Azure Queue Storage
Implementation Complexity: High
Time to Implement: 4-6 weeks
Operational Overhead: Medium (Azure Queue, worker service)
Best For: 10K+ rows, scheduled exports, highly sensitive data
Pros:
- ✅ Server-side data filtering (maximum security)
- ✅ No client memory constraints
- ✅ Progress tracking via job status
- ✅ Handles very large datasets (100K+ rows)
- ✅ Microsoft-official library (long-term support)
- ✅ Async pattern prevents HTTP timeouts
- ✅ Blob storage + SAS tokens (secure delivery)
Cons:
- ❌ Complex implementation (GraphQL mutations, queue workers, job tracking)
- ❌ Operational overhead (Azure Queue Storage, worker monitoring)
- ❌ User must poll for completion (adds latency)
- ❌ Longer time-to-market (4-6 weeks)
- ❌ Over-engineered for typical questionnaire exports
When to Reconsider: If questionnaire exports regularly exceed 10K rows or sensitive data requires server-side filtering.
Option 3: Backend Export with ClosedXML (Legacy Pattern)
Implementation Complexity: Medium
Time to Implement: 2-3 weeks
Operational Overhead: Low
Best For: Matching msource patterns (not recommended)
Pros:
- ✅ Matches existing msource pattern
- ✅ Simpler API than DocumentFormat.OpenXml
- ✅ Template-based approach (reusable)
- ✅ Server-side filtering
Cons:
- ❌ Outdated library (ClosedXML 0.91.0, not actively maintained)
- ❌ Synchronous blocking (timeout risk)
- ❌ No .NET 9 optimizations
- ❌ Would perpetuate legacy pattern
- ❌ Not recommended for new development
Verdict: Do not use. If backend approach is needed, use DocumentFormat.OpenXml instead.
File Embedding Capabilities (Important Limitation)
What exceljs CAN Do
✅ Embed images (PNG, JPEG, GIF):
typescript
const logoImage = workbook.addImage({
base64: premierLogoBase64,
extension: 'png',
});
worksheet.addImage(logoImage, {
tl: { col: 0.99, row: 0.99 },
ext: { width: 120, height: 25 },
});✅ Add hyperlinks to external resources:
typescript
worksheet.getCell('A1').value = {
text: 'View Attachment',
hyperlink: 'https://blob.core.windows.net/files/attachment.pdf?sas_token',
};✅ Insert formulas and rich formatting:
typescript
worksheet.getCell('B10').value = { formula: '=SUM(B2:B9)' };
worksheet.getCell('A1').font = { bold: true, size: 14, color: { argb: 'FF0000' } };What exceljs CANNOT Do
❌ Embed arbitrary files (PDFs, Word docs, ZIP files) as OLE objects
❌ Excel's native "Insert Object" functionality
❌ Attachments stored within the .xlsx file
Why This Limitation Exists
Excel file embedding uses OLE (Object Linking and Embedding) which:
- Is a complex proprietary binary format
- Not part of the OpenXML standard that exceljs implements
- Requires Windows COM automation or Apache POI (Java) level complexity
- Not supported by any JavaScript Excel library (exceljs, xlsx, SheetJS)
Workaround for File Attachments
Recommended Approach: Use hyperlinks to Azure Blob Storage
typescript
// Upload file to blob storage first
const attachmentUrl = await uploadToAzureBlob(file);
const sasUrl = generateSasToken(attachmentUrl, 24); // 24-hour expiry
// Add hyperlink in Excel
worksheet.getCell('D5').value = {
text: '📎 View Supporting Document',
hyperlink: sasUrl,
};
worksheet.getCell('D5').font = {
color: { argb: '0563C1' },
underline: true,
};Benefits:
- ✅ Works with any file type (PDF, Word, images, etc.)
- ✅ Files stored securely in Azure with SAS tokens
- ✅ Clickable links in Excel
- ✅ No file size limitations
- ✅ Audit trail in Azure Blob Storage
Trade-offs:
- ⚠️ Requires internet connection to access attachments
- ⚠️ SAS tokens expire (typically 24 hours)
- ⚠️ Files not embedded in .xlsx (external dependencies)
References
Existing Implementations
mp.msource.frontend:
- Sourcing Roadmap Export:
mp.msource.frontend/apps/webapp/src/component/page/sourcingRoadmap/exportExcel/exportExcel.tsx - DataGrid Exports:
mp.msource.frontend/libs/master-components/src/lib/Miscellaneous/cdDataGrid/cdDataGrid.tsx
msource:
- Terms Side-by-Side:
msource/src/.../GetTermsSideBySideDownloadResponseBuilder.cs - Terms Export:
msource/src/.../GetExportTermsResponseBuilder.cs - Supplier Data Import:
msource/src/.../SupplierDataImportService.cs
negotiate:
- Product Pricing Import:
negotiate/services/product-pricing/Co.Negotiate.ProductPricing.Service/Services/ProductPricingService.cs - File Validation:
negotiate/services/core/Co.Negotiate.Core.Infrastructure/Sharepoint/FileValidation.cs
External Documentation
- exceljs: https://github.com/exceljs/exceljs
- exceljs API Docs: https://github.com/exceljs/exceljs#interface
- DocumentFormat.OpenXml: https://learn.microsoft.com/en-us/office/open-xml/
- Azure Blob Storage SAS: https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview
- MUI DataGrid Export: https://mui.com/x/react-data-grid/export/