""" Spreadsheet Display Component Renders tabular data with support for row-based or cell-based annotation. Usage: In instance_display config: fields: - key: data_table type: spreadsheet display_options: annotation_mode: row show_headers: true max_height: 400 """ from typing import Dict, Any, List, Optional import html import logging from .base import BaseDisplay logger = logging.getLogger(__name__) class SpreadsheetDisplay(BaseDisplay): """ Display type for tabular/spreadsheet data. Renders data as an HTML table with support for row-based or cell-based annotation modes. """ name = "spreadsheet" required_fields = ["key"] optional_fields = { "annotation_mode": "row", # "row", "cell", or "range" "show_headers": True, # Show column headers "max_height": 400, # Max container height "max_width": None, # Max container width "striped": True, # Alternating row colors "hoverable": True, # Highlight row on hover "sortable": False, # Enable column sorting "filterable": False, # Enable column filtering "selectable": True, # Enable row/cell selection "compact": False, # Compact table styling # New styling options "border_style": "default", # "default", "bordered", "minimal", "rounded", "none" "header_style": "default", # "default", "dark", "primary", "gradient", "light", "transparent" "custom_class": None, # Additional CSS classes for the table "custom_css": None, # Inline CSS styles for the table } description = "Spreadsheet/table display with row or cell annotation" supports_span_target = False # No .text-content wrapper; table data not compatible with span offsets # Valid values for style options VALID_BORDER_STYLES = ["default", "bordered", "minimal", "rounded", "none"] VALID_HEADER_STYLES = ["default", "dark", "primary", "gradient", "light", "transparent"] def validate_config(self, field_config: Dict[str, Any]) -> List[str]: """ Validate spreadsheet display configuration. Returns: List of validation error messages (empty if valid) """ errors = super().validate_config(field_config) options = field_config.get("display_options", {}) # Validate border_style border_style = options.get("border_style", "default") if border_style not in self.VALID_BORDER_STYLES: errors.append( f"Invalid border_style '{border_style}'. " f"Must be one of: {', '.join(self.VALID_BORDER_STYLES)}" ) # Validate header_style header_style = options.get("header_style", "default") if header_style not in self.VALID_HEADER_STYLES: errors.append( f"Invalid header_style '{header_style}'. " f"Must be one of: {', '.join(self.VALID_HEADER_STYLES)}" ) # Validate annotation_mode annotation_mode = options.get("annotation_mode", "row") valid_modes = ["row", "cell", "range"] if annotation_mode not in valid_modes: errors.append( f"Invalid annotation_mode '{annotation_mode}'. " f"Must be one of: {', '.join(valid_modes)}" ) return errors def render(self, field_config: Dict[str, Any], data: Any) -> str: """ Render spreadsheet data. Args: field_config: Display configuration data: Either a dict with extracted content, list of lists, or list of dicts Returns: HTML string for rendering """ options = self.get_display_options(field_config) field_key = field_config.get("key", "spreadsheet") # Handle different data formats if isinstance(data, dict): # Pre-extracted FormatOutput data if "rendered_html" in data: return self._wrap_content(data["rendered_html"], options, field_key) # Extract from metadata rows = data.get("rows", []) headers = data.get("headers", data.get("metadata", {}).get("headers", [])) elif isinstance(data, list): if data and isinstance(data[0], dict): # List of dictionaries headers = list(data[0].keys()) if data else [] rows = [[row.get(h, "") for h in headers] for row in data] else: # List of lists rows = data headers = [] else: return f'
Unsupported data format
' # Generate table HTML table_html = self._render_table(rows, headers, options, field_key) return self._wrap_content(table_html, options, field_key) def _wrap_content( self, content: str, options: Dict[str, Any], field_key: str ) -> str: """ Wrap table content in container with styles. """ styles = [] max_height = options.get("max_height") max_width = options.get("max_width") if max_height: styles.append(f"max-height: {max_height}px") styles.append("overflow-y: auto") if max_width: styles.append(f"max-width: {max_width}px") styles.append("overflow-x: auto") style_str = "; ".join(styles) if styles else "" mode = options.get("annotation_mode", "row") # Container classes container_classes = ["spreadsheet-display"] # Add border-rounded class to container for rounded style border_style = options.get("border_style", "default") if border_style == "rounded": container_classes.append("border-rounded") container_class_str = " ".join(container_classes) return f'''
{content}
''' def _render_table( self, rows: List[List], headers: List[str], options: Dict[str, Any], field_key: str ) -> str: """ Render data as HTML table. """ parts = [] mode = options.get("annotation_mode", "row") # Table classes table_classes = ["spreadsheet-table"] if options.get("striped"): table_classes.append("table-striped") if options.get("hoverable"): table_classes.append("table-hoverable") if options.get("compact"): table_classes.append("table-compact") if options.get("selectable"): table_classes.append("table-selectable") # Border style class border_style = options.get("border_style", "default") if border_style and border_style != "default": table_classes.append(f"border-{border_style}") # Header style class header_style = options.get("header_style", "default") if header_style and header_style != "default": table_classes.append(f"header-{header_style}") # Custom class from admin config custom_class = options.get("custom_class") if custom_class: # Support both string and list of classes if isinstance(custom_class, list): table_classes.extend(custom_class) else: table_classes.append(custom_class) class_str = " ".join(table_classes) # Custom inline CSS custom_css = options.get("custom_css", "") style_attr = f' style="{html.escape(custom_css)}"' if custom_css else "" parts.append(f'') # Headers if headers and options.get("show_headers", True): parts.append('') if options.get("selectable") and mode == "row": parts.append('') for col_idx, header in enumerate(headers): sortable_attr = 'data-sortable="true"' if options.get("sortable") else "" parts.append( f'' ) parts.append('') # Body parts.append('') for row_idx, row in enumerate(rows): row_classes = ["spreadsheet-row"] if mode == "row": row_classes.append("selectable-row") parts.append( f'' ) # Row selection checkbox if options.get("selectable") and mode == "row": parts.append( f'' ) for col_idx, cell in enumerate(row): cell_value = str(cell) if cell is not None else "" cell_classes = ["spreadsheet-cell"] if mode == "cell": cell_classes.append("selectable-cell") cell_ref = self._get_cell_ref(row_idx, col_idx) parts.append( f'' ) parts.append('') parts.append('') parts.append('
' f'{html.escape(str(header))}
' f'' f'' f'{html.escape(cell_value)}' f'
') # Add selection summary for row mode if options.get("selectable") and mode == "row": parts.append('''
0 rows selected
''') return "\n".join(parts) def _get_cell_ref(self, row: int, col: int) -> str: """ Get A1-style cell reference. """ # Convert column to letter col_letter = "" col_num = col + 1 while col_num > 0: col_num, remainder = divmod(col_num - 1, 26) col_letter = chr(65 + remainder) + col_letter return f"{col_letter}{row + 1}" def get_css_classes(self, field_config: Dict[str, Any]) -> List[str]: """Get CSS classes for the display container.""" classes = super().get_css_classes(field_config) options = self.get_display_options(field_config) if field_config.get("span_target"): classes.append("span-target-spreadsheet") mode = options.get("annotation_mode", "row") classes.append(f"spreadsheet-mode-{mode}") return classes def get_data_attributes( self, field_config: Dict[str, Any], data: Any ) -> Dict[str, str]: """Get data attributes for JavaScript initialization.""" attrs = super().get_data_attributes(field_config, data) options = self.get_display_options(field_config) attrs["annotation-mode"] = options.get("annotation_mode", "row") attrs["selectable"] = str(options.get("selectable", True)).lower() return attrs def get_js_init(self) -> Optional[str]: """ Return JavaScript initialization code for spreadsheet interactivity. """ return ''' if (typeof initSpreadsheetDisplays === 'function') { initSpreadsheetDisplays(); } '''