| """ |
| 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", |
| "show_headers": True, |
| "max_height": 400, |
| "max_width": None, |
| "striped": True, |
| "hoverable": True, |
| "sortable": False, |
| "filterable": False, |
| "selectable": True, |
| "compact": False, |
| |
| "border_style": "default", |
| "header_style": "default", |
| "custom_class": None, |
| "custom_css": None, |
| } |
| description = "Spreadsheet/table display with row or cell annotation" |
| supports_span_target = False |
|
|
| |
| 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", {}) |
|
|
| |
| 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)}" |
| ) |
|
|
| |
| 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)}" |
| ) |
|
|
| |
| 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") |
|
|
| |
| if isinstance(data, dict): |
| |
| if "rendered_html" in data: |
| return self._wrap_content(data["rendered_html"], options, field_key) |
|
|
| |
| rows = data.get("rows", []) |
| headers = data.get("headers", data.get("metadata", {}).get("headers", [])) |
| elif isinstance(data, list): |
| if data and isinstance(data[0], dict): |
| |
| headers = list(data[0].keys()) if data else [] |
| rows = [[row.get(h, "") for h in headers] for row in data] |
| else: |
| |
| rows = data |
| headers = [] |
| else: |
| return f'<div class="spreadsheet-error">Unsupported data format</div>' |
|
|
| |
| 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 = ["spreadsheet-display"] |
|
|
| |
| border_style = options.get("border_style", "default") |
| if border_style == "rounded": |
| container_classes.append("border-rounded") |
|
|
| container_class_str = " ".join(container_classes) |
|
|
| return f''' |
| <div class="{container_class_str}" |
| data-field-key="{field_key}" |
| data-annotation-mode="{mode}" |
| style="{style_str}"> |
| {content} |
| </div> |
| ''' |
|
|
| 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 = ["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 = options.get("border_style", "default") |
| if border_style and border_style != "default": |
| table_classes.append(f"border-{border_style}") |
|
|
| |
| header_style = options.get("header_style", "default") |
| if header_style and header_style != "default": |
| table_classes.append(f"header-{header_style}") |
|
|
| |
| custom_class = options.get("custom_class") |
| if custom_class: |
| |
| if isinstance(custom_class, list): |
| table_classes.extend(custom_class) |
| else: |
| table_classes.append(custom_class) |
|
|
| class_str = " ".join(table_classes) |
|
|
| |
| custom_css = options.get("custom_css", "") |
| style_attr = f' style="{html.escape(custom_css)}"' if custom_css else "" |
|
|
| parts.append(f'<table class="{class_str}" data-mode="{mode}"{style_attr}>') |
|
|
| |
| if headers and options.get("show_headers", True): |
| parts.append('<thead><tr>') |
| if options.get("selectable") and mode == "row": |
| parts.append('<th class="select-col"></th>') |
| for col_idx, header in enumerate(headers): |
| sortable_attr = 'data-sortable="true"' if options.get("sortable") else "" |
| parts.append( |
| f'<th data-col="{col_idx}" {sortable_attr}>' |
| f'{html.escape(str(header))}</th>' |
| ) |
| parts.append('</tr></thead>') |
|
|
| |
| parts.append('<tbody>') |
| for row_idx, row in enumerate(rows): |
| row_classes = ["spreadsheet-row"] |
| if mode == "row": |
| row_classes.append("selectable-row") |
|
|
| parts.append( |
| f'<tr class="{" ".join(row_classes)}" ' |
| f'data-row="{row_idx}">' |
| ) |
|
|
| |
| if options.get("selectable") and mode == "row": |
| parts.append( |
| f'<td class="select-col">' |
| f'<input type="checkbox" class="row-select" data-row="{row_idx}">' |
| f'</td>' |
| ) |
|
|
| 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'<td class="{" ".join(cell_classes)}" ' |
| f'data-row="{row_idx}" ' |
| f'data-col="{col_idx}" ' |
| f'data-cell-ref="{cell_ref}">' |
| f'{html.escape(cell_value)}' |
| f'</td>' |
| ) |
|
|
| parts.append('</tr>') |
|
|
| parts.append('</tbody>') |
| parts.append('</table>') |
|
|
| |
| if options.get("selectable") and mode == "row": |
| parts.append(''' |
| <div class="spreadsheet-selection-summary"> |
| <span class="selected-count">0</span> rows selected |
| </div> |
| ''') |
|
|
| return "\n".join(parts) |
|
|
| def _get_cell_ref(self, row: int, col: int) -> str: |
| """ |
| Get A1-style cell reference. |
| """ |
| |
| 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(); |
| } |
| ''' |
|
|