Skip to content

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 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)

LibraryLocationUse CaseStatus
exceljs v4.4.0apps/webapp/src/component/page/sourcingRoadmap/exportExcel/Sourcing Roadmap custom export✅ Production
MUI DataGrid Premiumlibs/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)

LibraryLocationUse CaseStatus
ClosedXML v0.91.0msource/src/.../GetTermsSideBySideDownloadResponseBuilder.csCommercial terms side-by-side comparison✅ Production
ClosedXML v0.91.0msource/src/.../GetExportTermsResponseBuilder.csTerms & conditions export✅ Production
ClosedXML v0.91.0msource/src/.../CommercialTermsDataImportService.csExcel import (commercial terms)✅ Production
ClosedXML v0.91.0msource/src/.../SupplierDataImportService.csExcel 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)

LibraryLocationUse CaseStatus
ExcelDataReader v3.7.0services/product-pricing/Co.Negotiate.ProductPricing.ServiceProduct pricing import (read-only)✅ Production
DocumentFormat.OpenXml v3.3.0services/core/Co.Negotiate.Core.InfrastructureFile validation (IsValidExcelDocument)✅ Production
DocumentFormat.OpenXml v3.3.0services/core/Co.Negotiate.Core.ServiceAvailable 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)

LibraryLocationUse CaseStatus
exceljsNot yet installedTarget 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)

LibraryLocationUse CaseStatus
SyncfusionExternal 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:

  1. Questionnaire data is already client-side - User has accessed and viewed the questionnaire in the browser, so data is already in memory
  2. Immediate user feedback - No polling, no job queue complexity
  3. Proven pattern - Already used successfully in mp.msource.frontend Sourcing Roadmap
  4. No operational overhead - No backend queue workers, Azure Queue Storage, or job tracking database needed
  5. Simpler implementation - 1-2 weeks vs 4-6 weeks for backend async pattern
  6. PII concerns mitigated - Users already have access to questionnaire data via the UI; exporting to Excel doesn't increase exposure
  7. 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-saver

Component 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

  1. For large exports: Show warning if >5K rows; suggest filtering or contact support
  2. For very large exports: Phase 2 can add backend async export option for power users
  3. For sensitive data: Already mitigated - user has permission to view data in UI
  4. 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