NEW! Now you can apply AWS credits to Label Studio Enterprise

Spreadsheet Editor

This template creates a custom spreadsheet editor built with ReactCode and allows users to view, edit, and manage product data in a table format.

The labeling interface provides a full-featured spreadsheet experience with capabilities that include:

  • Adding and deleting rows and columns
  • Filtering data across all columns or by specific columns
  • Resizing column widths
  • Reordering columns by dragging
  • Editing individual cells

Screenshot

Enterprise

This template can only be used in Label Studio Enterprise.

Labeling configuration

You must be using Label Studio Enterprise to access the ReactCode tag. For more information, see ReactCode

<View>
  <ReactCode name="spreadsheet_editor" toName="spreadsheet_editor" data="$attributes_data">
<![CDATA[
({ React, data, regions, addRegion }) => {
  // Parse the data structure
  const [spreadsheetData, setSpreadsheetData] = React.useState([]);
  const [originalRows, setOriginalRows] = React.useState([]); // Store original data for comparison
  const [columns, setColumns] = React.useState([]);
  const [originalColumns, setOriginalColumns] = React.useState([]);
  const [newColumnName, setNewColumnName] = React.useState('');
  const [filterText, setFilterText] = React.useState('');
  const [columnFilters, setColumnFilters] = React.useState({});
  const [columnWidths, setColumnWidths] = React.useState({});
  const [draggedColumn, setDraggedColumn] = React.useState(null);
  const [editingCell, setEditingCell] = React.useState(null);
  const [resizingColumn, setResizingColumn] = React.useState(null);
  const [resizeStartX, setResizeStartX] = React.useState(0);
  const [resizeStartWidth, setResizeStartWidth] = React.useState(0);

  // Track changes separately - only this will be saved to regions
  const [changes, setChanges] = React.useState({
    cellEdits: [],      // Array of {rowIndex, column, oldValue, newValue, productId}
    addedRows: [],      // Array of new row objects with temporary IDs
    deletedRows: [],    // Array of {productId, rowData} for deleted rows
    addedColumns: [],   // Array of new column names
    deletedColumns: []  // Array of deleted column names
  });

  const defaultState = {
    changes: {
      cellEdits: [],
      addedRows: [],
      deletedRows: [],
      addedColumns: [],
      deletedColumns: []
    }
  };

  const state = regions[0]?.value ?? defaultState;
  
  // Initialize changes from existing region if present
  React.useEffect(() => {
    if (regions[0]?.value?.changes) {
      setChanges(regions[0].value.changes);
    }
  }, [regions]);

  // Helper function to extract data from nested structure
  const extractRows = (data) => {
    try {
      const parsed = typeof data === 'string' ? JSON.parse(data) : data;
      
      // Handle the nested structure: [{ data: { attributes_data: [...] } }]
      if (Array.isArray(parsed) && parsed.length > 0) {
        const firstItem = parsed[0];
        if (firstItem.data && Array.isArray(firstItem.data.attributes_data)) {
          return firstItem.data.attributes_data;
        }
      }
      
      // Handle direct array of rows
      if (Array.isArray(parsed)) {
        return parsed;
      }
      
      // Handle object with attributes_data
      if (parsed && Array.isArray(parsed.attributes_data)) {
        return parsed.attributes_data;
      }
      
      // Handle object with data.attributes_data
      if (parsed && parsed.data && Array.isArray(parsed.data.attributes_data)) {
        return parsed.data.attributes_data;
      }
      
      return [];
    } catch (e) {
      console.error('Error parsing data:', e);
      return [];
    }
  };

  // Initialize data and columns
  React.useEffect(() => {
    const rows = extractRows(data);
    
    if (rows.length > 0) {
      setSpreadsheetData(rows);
      // Store original rows for comparison (only on first load)
      if (originalRows.length === 0) {
        setOriginalRows(JSON.parse(JSON.stringify(rows))); // Deep copy
      }
      
      // Extract all unique column names from all rows (including all_attributes)
      const allColumns = new Set();
      rows.forEach(row => {
        Object.keys(row).forEach(key => {
          allColumns.add(key); // Include all_attributes now
        });
      });
      
      const columnList = Array.from(allColumns);
      
      // Default columns if none exist
      const defaultColumns = [
        'product_id',
        'name',
        'norm_value',
        'current_value',
        'has_change',
        'rationales',
        'link',
        'all_attributes'
      ];
      
      // Merge default columns with found columns, ensuring all_attributes is included
      const mergedColumns = [...new Set([...defaultColumns, ...columnList])];
      setColumns(mergedColumns);
      setOriginalColumns(mergedColumns); // Track original columns
      
      // Initialize default column widths
      const defaultWidths = {
        'product_id': 120,
        'name': 200,
        'norm_value': 150,
        'current_value': 150,
        'has_change': 100,
        'rationales': 300,
        'link': 200,
        'all_attributes': 300
      };
      
      // Initialize region if it doesn't exist - only save empty changes object
      if (!regions[0]) {
        addRegion({
          changes: {
            cellEdits: [],
            addedRows: [],
            deletedRows: [],
            addedColumns: [],
            deletedColumns: []
          }
        });
        setColumnWidths(defaultWidths);
      } else {
        // Restore changes from existing region
        const existingState = regions[0].value || {};
        if (existingState.changes) {
          setChanges(existingState.changes);
        }
        const origCols = existingState.originalColumns || mergedColumns;
        const existingWidths = existingState.columnWidths || {};
        const mergedWidths = { ...defaultWidths, ...existingWidths };
        setOriginalColumns(origCols);
        setColumnWidths(mergedWidths);
        setColumnFilters(existingState.columnFilters || {});
      }
    } else {
      // Initialize with empty state
      const defaultCols = ['product_id', 'name', 'norm_value', 'current_value', 'has_change', 'rationales', 'link', 'all_attributes'];
      const defaultWidths = {
        'product_id': 120,
        'name': 200,
        'norm_value': 150,
        'current_value': 150,
        'has_change': 100,
        'rationales': 300,
        'link': 200,
        'all_attributes': 300
      };
      setColumns(defaultCols);
      setOriginalColumns(defaultCols);
      setSpreadsheetData([]);
      setColumnWidths(defaultWidths);
      
      if (!regions[0]) {
        addRegion({
          changes: {
            cellEdits: [],
            addedRows: [],
            deletedRows: [],
            addedColumns: [],
            deletedColumns: []
          }
        });
      }
    }
  }, [data]);

  // Get current rows by applying changes to original rows
  const currentRows = React.useMemo(() => {
    let rows = [...originalRows];
    
    // Remove deleted rows first
    const deletedProductIds = new Set(changes.deletedRows.map(d => d.productId));
    rows = rows.filter(row => !deletedProductIds.has(row.product_id));
    
    // Add new rows first
    rows = [...rows, ...changes.addedRows];
    
    // Apply cell edits (using product_id or _tempId to find the row)
    changes.cellEdits.forEach(edit => {
      const rowIndex = rows.findIndex(r => 
        (r.product_id && r.product_id === edit.productId) || 
        (r._tempId && r._tempId === edit.productId)
      );
      if (rowIndex >= 0) {
        rows[rowIndex] = { ...rows[rowIndex], [edit.column]: edit.newValue };
      }
    });
    
    return rows;
  }, [originalRows, changes]);
  
  const currentColumns = state.columns && state.columns.length > 0 ? state.columns : columns;
  const currentOriginalColumns = state.originalColumns && state.originalColumns.length > 0 ? state.originalColumns : originalColumns;
  const currentColumnWidths = state.columnWidths || columnWidths;
  const currentColumnFilters = state.columnFilters || columnFilters;
  
  // Helper function to save only changes to region
  const saveChanges = (updatedChanges) => {
    const changesToSave = {
      changes: updatedChanges
    };
    
    if (regions[0]) {
      regions[0].update(changesToSave);
    } else {
      addRegion(changesToSave);
    }
    setChanges(updatedChanges);
  };

  // Filter rows based on filter text and column-specific filters
  const filteredRows = React.useMemo(() => {
    let filtered = currentRows;
    
    // Apply global filter if present
    if (filterText.trim()) {
      const searchText = filterText.toLowerCase();
      filtered = filtered.filter(row => {
        return currentColumns.some(col => {
          const value = row[col];
          if (value === null || value === undefined) return false;
          const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value);
          return valueStr.toLowerCase().includes(searchText);
        });
      });
    }
    
    // Apply column-specific filters
    const activeColumnFilters = Object.entries(currentColumnFilters).filter(([_, filterValue]) => filterValue && filterValue.trim());
    if (activeColumnFilters.length > 0) {
      filtered = filtered.filter(row => {
        return activeColumnFilters.every(([colName, filterValue]) => {
          const value = row[colName];
          if (value === null || value === undefined) return false;
          const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value);
          return valueStr.toLowerCase().includes(filterValue.toLowerCase());
        });
      });
    }
    
    return filtered;
  }, [currentRows, filterText, currentColumns, currentColumnFilters]);

  // Add new row
  const addRow = () => {
    const newRow = {};
    currentColumns.forEach(col => {
      newRow[col] = '';
    });
    // Add temporary ID for tracking
    newRow._tempId = `temp_${Date.now()}_${Math.random()}`;
    
    const updatedChanges = {
      ...changes,
      addedRows: [...changes.addedRows, newRow]
    };
    
    saveChanges(updatedChanges);
    setSpreadsheetData([...currentRows, newRow]);
  };

  // Delete row
  const deleteRow = (rowIndex) => {
    const rowToDelete = currentRows[rowIndex];
    
    // Check if it's a newly added row (has temp ID)
    if (rowToDelete._tempId) {
      // Remove from addedRows
      const updatedChanges = {
        ...changes,
        addedRows: changes.addedRows.filter(r => r._tempId !== rowToDelete._tempId)
      };
      saveChanges(updatedChanges);
    } else {
      // It's an original row - add to deletedRows
      const updatedChanges = {
        ...changes,
        deletedRows: [...changes.deletedRows, {
          productId: rowToDelete.product_id,
          rowData: { ...rowToDelete }
        }]
      };
      saveChanges(updatedChanges);
    }
    
    const newRows = currentRows.filter((_, idx) => idx !== rowIndex);
    setSpreadsheetData(newRows);
  };

  // Add new column
  const addColumn = () => {
    if (!newColumnName.trim()) {
      alert('Please enter a column name');
      return;
    }
    
    if (currentColumns.includes(newColumnName.trim())) {
      alert('Column already exists');
      return;
    }
    
    const newCols = [...currentColumns, newColumnName.trim()];
    
    // Add empty value for this column to all existing rows
    const newRows = currentRows.map(row => ({
      ...row,
      [newColumnName.trim()]: ''
    }));
    
    // Add default width for new column
    const newWidths = { ...currentColumnWidths, [newColumnName.trim()]: 150 };
    
    // Track new column addition
    const updatedChanges = {
      ...changes,
      addedColumns: [...changes.addedColumns, newColumnName.trim()]
    };
    saveChanges(updatedChanges);
    
    setColumns(newCols);
    setColumnWidths(newWidths);
    setSpreadsheetData(newRows);
    setNewColumnName('');
  };

  // Delete column (only for new columns)
  const deleteColumn = (colName) => {
    // Check if it's an original column
    if (currentOriginalColumns.includes(colName)) {
      alert('Cannot delete original columns. Only newly added columns can be deleted.');
      return;
    }
    
    if (currentColumns.length <= 1) {
      alert('Cannot delete the last column');
      return;
    }
    
    const newCols = currentColumns.filter(col => col !== colName);
    const newRows = currentRows.map(row => {
      const newRow = { ...row };
      delete newRow[colName];
      return newRow;
    });
    
    // Remove width and filter for deleted column
    const newWidths = { ...currentColumnWidths };
    delete newWidths[colName];
    const newFilters = { ...currentColumnFilters };
    delete newFilters[colName];
    
    // Track column deletion
    const updatedChanges = {
      ...changes,
      deletedColumns: [...changes.deletedColumns, colName],
      // Also remove any cell edits for this column
      cellEdits: changes.cellEdits.filter(e => e.column !== colName)
    };
    saveChanges(updatedChanges);
    
    setColumns(newCols);
    setColumnWidths(newWidths);
    setColumnFilters(newFilters);
    setSpreadsheetData(newRows);
  };

  // Update cell value
  const updateCell = (rowIndex, colName, value) => {
    const row = currentRows[rowIndex];
    const oldValue = row[colName];
    const rowId = row.product_id || row._tempId;
    
    // Find original value for this cell
    let originalValue = oldValue;
    if (row.product_id && !row._tempId) {
      // It's an original row - find it in originalRows
      const originalRow = originalRows.find(r => r.product_id === row.product_id);
      if (originalRow) {
        originalValue = originalRow[colName];
      }
    }
    
    // Check if this is actually a change from original
    const isNewRow = row._tempId !== undefined;
    const isActualChange = !isNewRow && JSON.stringify(originalValue) !== JSON.stringify(value);
    
    if (isNewRow || isActualChange) {
      // Check if we already have an edit for this cell (using productId/tempId)
      const existingEditIndex = changes.cellEdits.findIndex(
        e => e.productId === rowId && e.column === colName
      );
      
      let updatedEdits = [...changes.cellEdits];
      if (existingEditIndex >= 0) {
        // Update existing edit
        updatedEdits[existingEditIndex] = {
          ...updatedEdits[existingEditIndex],
          newValue: value
        };
      } else {
        // Add new edit
        updatedEdits.push({
          column: colName,
          oldValue: isNewRow ? oldValue : originalValue,
          newValue: value,
          productId: rowId
        });
      }
      
      const updatedChanges = {
        ...changes,
        cellEdits: updatedEdits
      };
      
      saveChanges(updatedChanges);
    }
    
    const newRows = [...currentRows];
    if (!newRows[rowIndex]) {
      newRows[rowIndex] = {};
    }
    newRows[rowIndex] = { ...newRows[rowIndex], [colName]: value };
    setSpreadsheetData(newRows);
  };

  // Handle column reordering
  const handleColumnDragStart = (colIndex) => {
    setDraggedColumn(colIndex);
  };

  const handleColumnDragOver = (e, colIndex) => {
    e.preventDefault();
    if (draggedColumn === null || draggedColumn === colIndex) return;
    
    const newColumns = [...currentColumns];
    const draggedCol = newColumns[draggedColumn];
    newColumns.splice(draggedColumn, 1);
    newColumns.splice(colIndex, 0, draggedCol);
    
    setColumns(newColumns);
    setDraggedColumn(colIndex);
  };

  const handleColumnDragEnd = () => {
    // Column reordering is UI-only, doesn't need to be saved as a change
    setDraggedColumn(null);
  };

  // Handle column width resizing
  const handleResizeStart = (e, colName) => {
    e.preventDefault();
    e.stopPropagation();
    setResizingColumn(colName);
    setResizeStartX(e.clientX);
    setResizeStartWidth(currentColumnWidths[colName] || 150);
  };

  React.useEffect(() => {
    if (!resizingColumn) return;

    const handleResize = (e) => {
      const diff = e.clientX - resizeStartX;
      const newWidth = Math.max(50, resizeStartWidth + diff);
      setColumnWidths(prev => ({ ...prev, [resizingColumn]: newWidth }));
    };

    const handleResizeEnd = () => {
      // Column width resizing is UI-only, doesn't need to be saved as a change
      setResizingColumn(null);
    };

    document.addEventListener('mousemove', handleResize);
    document.addEventListener('mouseup', handleResizeEnd);
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
    
    return () => {
      document.removeEventListener('mousemove', handleResize);
      document.removeEventListener('mouseup', handleResizeEnd);
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
    };
  }, [resizingColumn, resizeStartX, resizeStartWidth, state, currentColumnFilters, regions]);

  // Update column filter
  const updateColumnFilter = (colName, filterValue) => {
    // Filtering is UI-only, doesn't need to be saved as a change
    const newFilters = { ...currentColumnFilters, [colName]: filterValue };
    setColumnFilters(newFilters);
  };

  // Handle submit
  const handleSubmit = () => {
    // Final submission - save changes with metadata
    const submission = {
      changes: {
        ...changes,
        submittedAt: new Date().toISOString(),
        submitted: true
      }
    };
    
    if (regions[0]) {
      regions[0].update(submission);
    } else {
      addRegion(submission);
    }
    
    const changeCount = changes.cellEdits.length + changes.addedRows.length + changes.deletedRows.length;
    alert(`Spreadsheet submitted successfully! ${changeCount} change(s) recorded.`);
  };

  // Styles
  const containerStyle = {
    padding: '20px',
    fontFamily: 'Arial, sans-serif',
    maxWidth: '100%',
    overflowX: 'auto'
  };

  const headerStyle = {
    marginBottom: '20px',
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
    flexWrap: 'wrap',
    gap: '10px'
  };

  const titleStyle = {
    fontSize: '24px',
    fontWeight: 'bold',
    color: '#333',
    margin: 0
  };

  const buttonStyle = {
    padding: '10px 20px',
    fontSize: '14px',
    fontWeight: 'bold',
    border: 'none',
    borderRadius: '6px',
    cursor: 'pointer',
    marginRight: '10px',
    backgroundColor: '#2196F3',
    color: 'white'
  };

  const addColumnStyle = {
    ...buttonStyle,
    backgroundColor: '#4CAF50'
  };

  const deleteButtonStyle = {
    ...buttonStyle,
    backgroundColor: '#f44336',
    padding: '5px 10px',
    fontSize: '12px',
    marginRight: '5px'
  };

  const submitButtonStyle = {
    ...buttonStyle,
    backgroundColor: '#4CAF50',
    fontSize: '16px',
    padding: '12px 24px'
  };

  const tableContainerStyle = {
    overflowX: 'auto',
    border: '1px solid #ddd',
    borderRadius: '8px',
    marginBottom: '20px'
  };

  const tableStyle = {
    width: '100%',
    borderCollapse: 'collapse',
    backgroundColor: 'white',
    minWidth: '800px'
  };

  const thStyle = {
    backgroundColor: '#f5f5f5',
    padding: '12px',
    textAlign: 'left',
    borderBottom: '2px solid #ddd',
    borderRight: '1px solid #ddd',
    fontWeight: 'bold',
    color: '#333',
    position: 'sticky',
    top: 0,
    zIndex: 10
  };

  const tdStyle = {
    padding: '10px',
    borderBottom: '1px solid #eee',
    borderRight: '1px solid #eee',
    fontSize: '14px'
  };

  const inputStyle = {
    width: '100%',
    padding: '8px',
    border: '1px solid #ccc',
    borderRadius: '4px',
    fontSize: '14px',
    boxSizing: 'border-box'
  };

  const textareaStyle = {
    ...inputStyle,
    minHeight: '60px',
    resize: 'vertical',
    fontFamily: 'inherit'
  };

  const addColumnContainerStyle = {
    display: 'flex',
    gap: '10px',
    alignItems: 'center',
    marginBottom: '20px',
    padding: '15px',
    backgroundColor: '#f9f9f9',
    borderRadius: '6px'
  };

  const columnInputStyle = {
    padding: '8px',
    border: '1px solid #ccc',
    borderRadius: '4px',
    fontSize: '14px',
    flex: '1',
    maxWidth: '300px'
  };

  const rowActionsStyle = {
    display: 'flex',
    gap: '5px'
  };

  const emptyStateStyle = {
    textAlign: 'center',
    padding: '40px',
    color: '#999'
  };

  const filterContainerStyle = {
    marginBottom: '20px',
    padding: '15px',
    backgroundColor: '#f9f9f9',
    borderRadius: '6px',
    display: 'flex',
    gap: '10px',
    alignItems: 'center'
  };

  const filterInputStyle = {
    padding: '10px',
    border: '1px solid #ccc',
    borderRadius: '4px',
    fontSize: '14px',
    flex: '1',
    maxWidth: '400px'
  };

  return React.createElement("div", { style: containerStyle },
    // Header
    React.createElement("div", { style: headerStyle },
      React.createElement("h1", { style: titleStyle }, "Spreadsheet Editor"),
      React.createElement("div", {},
        React.createElement("button", {
          onClick: addRow,
          style: buttonStyle
        }, "+ Add Row"),
        React.createElement("button", {
          onClick: handleSubmit,
          style: submitButtonStyle
        }, "Submit")
      )
    ),

    // Filter Section
    currentRows.length > 0 && React.createElement("div", { style: filterContainerStyle },
      React.createElement("label", { style: { fontWeight: 'bold', fontSize: '14px', color: '#555' } }, "Global Filter:"),
      React.createElement("input", {
        type: "text",
        value: filterText,
        onChange: (e) => setFilterText(e.target.value),
        placeholder: "Search across all columns...",
        style: filterInputStyle
      }),
      filterText && React.createElement("button", {
        onClick: () => setFilterText(''),
        style: { ...buttonStyle, backgroundColor: '#999', padding: '10px 15px' }
      }, "Clear")
    ),

    // Add Column Section
    React.createElement("div", { style: addColumnContainerStyle },
      React.createElement("input", {
        type: "text",
        value: newColumnName,
        onChange: (e) => setNewColumnName(e.target.value),
        placeholder: "Enter new column name",
        style: columnInputStyle,
        onKeyPress: (e) => {
          if (e.key === 'Enter') {
            addColumn();
          }
        }
      }),
      React.createElement("button", {
        onClick: addColumn,
        style: addColumnStyle
      }, "+ Add Column")
    ),

    // Spreadsheet Table
    currentRows.length > 0 ? React.createElement("div", { style: tableContainerStyle },
      React.createElement("table", { style: tableStyle },
        // Header Row
        React.createElement("thead", {},
          React.createElement("tr", {},
            React.createElement("th", { style: { ...thStyle, width: '80px' } }, "Actions"),
            currentColumns.map((col, colIdx) =>
              React.createElement("th", { 
                key: colIdx, 
                style: { 
                  ...thStyle, 
                  width: currentColumnWidths[col] || 150,
                  minWidth: currentColumnWidths[col] || 150,
                  maxWidth: currentColumnWidths[col] || 150,
                  position: 'relative',
                  userSelect: 'none',
                  opacity: draggedColumn === colIdx ? 0.5 : 1,
                  backgroundColor: draggedColumn === colIdx ? '#e3f2fd' : thStyle.backgroundColor
                },
                draggable: true,
                onDragStart: () => handleColumnDragStart(colIdx),
                onDragOver: (e) => handleColumnDragOver(e, colIdx),
                onDragEnd: handleColumnDragEnd
              },
                React.createElement("div", { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
                  // Column header with drag handle
                  React.createElement("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
                    React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: '5px', flex: 1, cursor: 'move' } },
                      React.createElement("span", { style: { fontSize: '10px', color: '#999', cursor: 'grab' } }, "⋮⋮"),
                      React.createElement("span", {}, col)
                    ),
                    // Only show delete button for new columns (not original columns)
                    !currentOriginalColumns.includes(col) && React.createElement("button", {
                      onClick: () => deleteColumn(col),
                      style: { ...deleteButtonStyle, padding: '2px 8px', fontSize: '10px' },
                      title: "Delete column"
                    }, "×")
                  ),
                  // Column filter input
                  React.createElement("input", {
                    type: "text",
                    value: currentColumnFilters[col] || '',
                    onChange: (e) => updateColumnFilter(col, e.target.value),
                    placeholder: `Filter ${col}...`,
                    style: {
                      padding: '4px 8px',
                      border: '1px solid #ccc',
                      borderRadius: '4px',
                      fontSize: '12px',
                      width: '100%',
                      boxSizing: 'border-box'
                    },
                    onClick: (e) => e.stopPropagation()
                  })
                ),
                // Resize handle
                React.createElement("div", {
                  onMouseDown: (e) => handleResizeStart(e, col),
                  style: {
                    position: 'absolute',
                    right: 0,
                    top: 0,
                    bottom: 0,
                    width: '5px',
                    cursor: 'col-resize',
                    backgroundColor: resizingColumn === col ? '#2196F3' : 'transparent',
                    zIndex: 20
                  },
                  title: "Drag to resize column"
                })
              )
            )
          )
        ),
        // Data Rows (using filtered rows)
        React.createElement("tbody", {},
          filteredRows.map((row, filteredIdx) => {
            // Find the actual index in currentRows by matching the row object reference or key fields
            let actualRowIndex = currentRows.findIndex(r => r === row);
            
            // If reference match fails, try matching by key fields
            if (actualRowIndex < 0 && row.product_id && row.name) {
              actualRowIndex = currentRows.findIndex(r => 
                r.product_id === row.product_id && r.name === row.name
              );
            }
            
            // Final fallback: use filtered index (shouldn't happen in normal cases)
            const rowIdx = actualRowIndex >= 0 ? actualRowIndex : filteredIdx;
            
            return React.createElement("tr", { key: filteredIdx },
              // Actions column
              React.createElement("td", { style: tdStyle },
                React.createElement("div", { style: rowActionsStyle },
                  React.createElement("button", {
                    onClick: () => deleteRow(rowIdx),
                    style: deleteButtonStyle,
                    title: "Delete row"
                  }, "Delete")
                )
              ),
              // Data cells
              currentColumns.map((col, colIdx) => {
                let cellValue = row[col];
                // Handle all_attributes field - display as JSON
                if (col === 'all_attributes') {
                  if (cellValue === null || cellValue === undefined) {
                    cellValue = '';
                  } else if (typeof cellValue === 'object') {
                    cellValue = JSON.stringify(cellValue, null, 2);
                  } else {
                    cellValue = String(cellValue);
                  }
                } else {
                  cellValue = cellValue || '';
                }
                const isEditing = editingCell && editingCell.row === rowIdx && editingCell.col === col;
                
                return React.createElement("td", { 
                  key: colIdx, 
                  style: { 
                    ...tdStyle, 
                    width: currentColumnWidths[col] || 150,
                    minWidth: currentColumnWidths[col] || 150,
                    maxWidth: currentColumnWidths[col] || 150
                  } 
                },
                  (col === 'rationales' || col === 'all_attributes') ? (
                    React.createElement("textarea", {
                      value: typeof cellValue === 'object' ? JSON.stringify(cellValue, null, 2) : (cellValue || ''),
                      onChange: (e) => {
                        if (col === 'all_attributes') {
                          try {
                            const parsed = JSON.parse(e.target.value);
                            updateCell(rowIdx, col, parsed);
                          } catch (err) {
                            updateCell(rowIdx, col, e.target.value);
                          }
                        } else {
                          try {
                            const parsed = JSON.parse(e.target.value);
                            updateCell(rowIdx, col, parsed);
                          } catch (err) {
                            updateCell(rowIdx, col, e.target.value);
                          }
                        }
                      },
                      style: textareaStyle,
                      placeholder: "Enter value"
                    })
                  ) : (
                    React.createElement("input", {
                      type: "text",
                      value: cellValue || '',
                      onChange: (e) => updateCell(rowIdx, col, e.target.value),
                      style: inputStyle,
                      placeholder: "Enter value"
                    })
                  )
                );
              })
            );
          })
        )
      )
    ) : React.createElement("div", { style: emptyStateStyle },
      currentRows.length === 0 ? (
        React.createElement(React.Fragment, {},
          React.createElement("p", { style: { fontSize: '18px', marginBottom: '10px' } }, "No data available"),
          React.createElement("p", { style: { fontSize: '14px', color: '#999' } }, "Click 'Add Row' to start adding data")
        )
      ) : (
        React.createElement(React.Fragment, {},
          React.createElement("p", { style: { fontSize: '18px', marginBottom: '10px' } }, "No rows match the filter"),
          React.createElement("p", { style: { fontSize: '14px', color: '#999' } }, `Try adjusting your search. Total rows: ${currentRows.length}`)
        )
      )
    ),

    // Summary
    currentRows.length > 0 && React.createElement("div", { style: { marginTop: '20px', padding: '15px', backgroundColor: '#f9f9f9', borderRadius: '6px' } },
      React.createElement("p", { style: { margin: 0, fontSize: '14px', color: '#666' } },
        filterText ? 
          `Showing ${filteredRows.length} of ${currentRows.length} rows | Total Columns: ${currentColumns.length}` :
          `Total Rows: ${currentRows.length} | Total Columns: ${currentColumns.length}`
      )
    )
  );
}
]]>
  </ReactCode>
</View>

Example input

Import this as a JSON file to create three example rows in the spreadsheet:

{
"data": {
    "attributes_data": [
      {
        "link": "https://www.example-store.com/products/WM-2024-001",
        "name": "Wireless Connectivity Range",
        "product_id": "WM-2024-001",
        "has_change": false,
        "norm_value": null,
        "rationales": "{\"input_attributes\": [{\"Wireless Connectivity Range\": \"10 meters\"}], \"input_rules\": [\"Wireless Connectivity Range: The maximum distance the device can maintain a stable connection from the receiver.\"], \"other\": \"The input explicitly states the Wireless Connectivity Range as '10 meters'.\"}",
        "current_value": "10 meters",
        "all_attributes": {
          "Item": "Wireless Mouse",
          "Connectivity": "2.4GHz Wireless",
          "Battery Life": "12 months",
          "For Use With": "Desktop, Laptop",
          "Standards": "CE, FCC Certified",
          "Mouse Type": "Optical",
          "Buttons": "3-Button",
          "Body Material": "Plastic",
          "Grip Material": "Rubber",
          "Scroll Wheel": "Yes, with tilt",
          "Operation Type": "Wireless",
          "Overall Height": "1.5 in",
          "Overall Length": "4.2 in",
          "Overall Width": "2.5 in",
          "Weight": "85g",
          "Mounting Orientation": "Right or Left Hand",
          "DPI Range": "800-1600",
          "Connection Type": "USB Receiver",
          "Compatibility": "Windows, macOS, Linux",
          "Wireless Range": "10 meters",
          "Battery Type": "AA",
          "Color": "Black",
          "Package Contents": "Mouse, USB Receiver, Battery",
          "Warranty Period": "2 years",
          "Operating Temperature - Maximum": "40°C",
          "Operating Temperature - Minimum": "0°C",
          "Storage Humidity - Maximum": "85%",
          "Storage Humidity - Minimum": "10%"
        }
      },
      {
        "link": "https://www.example-store.com/products/WM-2024-002",
        "name": "Battery Life Duration",
        "product_id": "WM-2024-002",
        "has_change": false,
        "norm_value": null,
        "rationales": "{\"input_attributes\": [{\"Battery Life Duration\": \"18 months\"}], \"input_rules\": [\"Battery Life Duration: The expected operating time before battery replacement is needed under normal usage.\"], \"other\": \"The input explicitly states the Battery Life Duration as '18 months'.\"}",
        "current_value": "18 months",
        "all_attributes": {
          "Item": "Ergonomic Wireless Mouse",
          "Connectivity": "Bluetooth 5.0",
          "Battery Life": "18 months",
          "For Use With": "Desktop, Laptop, Tablet",
          "Standards": "CE, FCC, RoHS Certified",
          "Mouse Type": "Laser",
          "Buttons": "5-Button",
          "Body Material": "ABS Plastic",
          "Grip Material": "Silicone",
          "Scroll Wheel": "Yes, with horizontal tilt",
          "Operation Type": "Wireless",
          "Overall Height": "1.8 in",
          "Overall Length": "4.8 in",
          "Overall Width": "3.0 in",
          "Weight": "105g",
          "Mounting Orientation": "Right Hand",
          "DPI Range": "1000-3200",
          "Connection Type": "Bluetooth or USB Receiver",
          "Compatibility": "Windows, macOS, Linux, Android",
          "Wireless Range": "15 meters",
          "Battery Type": "Rechargeable Lithium",
          "Color": "Silver",
          "Package Contents": "Mouse, USB-C Cable, USB Receiver, Quick Start Guide",
          "Warranty Period": "3 years",
          "Operating Temperature - Maximum": "45°C",
          "Operating Temperature - Minimum": "-5°C",
          "Storage Humidity - Maximum": "90%",
          "Storage Humidity - Minimum": "5%"
        }
      },
      {
        "link": "https://www.example-store.com/products/WM-2024-003",
        "name": "DPI Sensitivity Setting",
        "product_id": "WM-2024-003",
        "has_change": true,
        "norm_value": "1600",
        "rationales": "{\"input_attributes\": [{\"DPI Sensitivity Setting\": \"2400\"}], \"input_rules\": [\"DPI Sensitivity Setting: The dots per inch sensitivity level for cursor movement precision.\"], \"other\": \"The input shows a DPI setting of '2400', which differs from the normalized value of '1600'.\"}",
        "current_value": "2400",
        "all_attributes": {
          "Item": "Gaming Wireless Mouse",
          "Connectivity": "2.4GHz Wireless + Bluetooth",
          "Battery Life": "6 months",
          "For Use With": "Gaming PC, Laptop",
          "Standards": "CE, FCC Certified",
          "Mouse Type": "Optical Gaming Sensor",
          "Buttons": "6-Button Programmable",
          "Body Material": "Matte Plastic",
          "Grip Material": "Textured Rubber",
          "Scroll Wheel": "Yes, RGB Backlit",
          "Operation Type": "Wireless",
          "Overall Height": "1.6 in",
          "Overall Length": "5.0 in",
          "Overall Width": "2.8 in",
          "Weight": "120g",
          "Mounting Orientation": "Right Hand",
          "DPI Range": "800-4000",
          "Connection Type": "USB Receiver",
          "Compatibility": "Windows, macOS",
          "Wireless Range": "12 meters",
          "Battery Type": "AA",
          "Color": "RGB Customizable",
          "Package Contents": "Mouse, USB Receiver, Battery, Software CD",
          "Warranty Period": "1 year",
          "Operating Temperature - Maximum": "50°C",
          "Operating Temperature - Minimum": "0°C",
          "Storage Humidity - Maximum": "80%",
          "Storage Humidity - Minimum": "10%"
        }
      }
    ]
}
}

Example output

The output only reflects edits made to the spreadsheet. For example, in this annotation the user added values to the previously empty norm_value cells:

{
  "result": [
    {
      "id": "eRb3JW4K72",
      "type": "reactcode",
      "value": {
        "reactcode": {
          "changes": {
            "addedRows": [],
            "cellEdits": [
              {
                "column": "norm_value",
                "newValue": "1200",
                "oldValue": null,
                "productId": "WM-2024-001"
              },
              {
                "column": "norm_value",
                "newValue": "1400",
                "oldValue": null,
                "productId": "WM-2024-002"
              }
            ],
            "deletedRows": [],
            "addedColumns": [],
            "deletedColumns": []
          }
        }
      },
      "origin": "manual",
      "to_name": "spreadsheet_editor",
      "from_name": "spreadsheet_editor"
    }
  ]
}
Designed for teams of all sizes Contact Sales