codebook / potato /server_utils /displays /spreadsheet_display.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
12.1 kB
"""
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'<div class="spreadsheet-error">Unsupported data format</div>'
# 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'''
<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
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'<table class="{class_str}" data-mode="{mode}"{style_attr}>')
# Headers
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>')
# Body
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}">'
)
# Row selection checkbox
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>')
# Add selection summary for row mode
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.
"""
# 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();
}
'''