glam/frontend/src/components/query/ResultsTable.tsx
kempersc 2761857b0d Add scripts for converting OWL/Turtle ontology to Mermaid and PlantUML diagrams
- Implemented `owl_to_mermaid.py` to convert OWL/Turtle files into Mermaid class diagrams.
- Implemented `owl_to_plantuml.py` to convert OWL/Turtle files into PlantUML class diagrams.
- Added two new PlantUML files for custodian multi-aspect diagrams.
2025-11-22 23:01:13 +01:00

303 lines
8.9 KiB
TypeScript

/**
* Results Table Component
*
* Displays SPARQL query results in a sortable, paginated table.
* Supports URI rendering, literal type indicators, and cell copy functionality.
*/
import React, { useState, useMemo } from 'react';
import type { SelectResults, BindingValue } from '../../lib/sparql/client';
import './ResultsTable.css';
export interface ResultsTableProps {
results: SelectResults;
onExport?: (format: 'csv' | 'json' | 'jsonld') => void;
maxHeight?: string;
}
type SortDirection = 'asc' | 'desc' | null;
interface SortState {
column: string | null;
direction: SortDirection;
}
export const ResultsTable: React.FC<ResultsTableProps> = ({
results,
onExport,
maxHeight = '600px',
}) => {
const [sortState, setSortState] = useState<SortState>({
column: null,
direction: null,
});
const [currentPage, setCurrentPage] = useState(1);
const [copiedCell, setCopiedCell] = useState<string | null>(null);
const rowsPerPage = 100;
// Extract variables (column names) from results
const variables = results.head.vars;
const bindings = results.results.bindings;
// Sort bindings based on current sort state
const sortedBindings = useMemo(() => {
if (!sortState.column || !sortState.direction) {
return bindings;
}
return [...bindings].sort((a, b) => {
const aValue = a[sortState.column!]?.value || '';
const bValue = b[sortState.column!]?.value || '';
const comparison = aValue.localeCompare(bValue);
return sortState.direction === 'asc' ? comparison : -comparison;
});
}, [bindings, sortState]);
// Paginate sorted bindings
const paginatedBindings = useMemo(() => {
const startIdx = (currentPage - 1) * rowsPerPage;
return sortedBindings.slice(startIdx, startIdx + rowsPerPage);
}, [sortedBindings, currentPage]);
const totalPages = Math.ceil(sortedBindings.length / rowsPerPage);
// Handle column header click for sorting
const handleSort = (column: string) => {
setSortState((prev) => {
if (prev.column === column) {
// Cycle through: asc -> desc -> null
const newDirection =
prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
return { column: newDirection ? column : null, direction: newDirection };
} else {
return { column, direction: 'asc' };
}
});
setCurrentPage(1); // Reset to first page when sorting
};
// Copy cell value to clipboard
const handleCopyCell = async (value: string, cellId: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedCell(cellId);
setTimeout(() => setCopiedCell(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Render a single cell value based on its type
const renderCell = (
variable: string,
binding: BindingValue | undefined,
rowIdx: number
): React.ReactNode => {
if (!binding) {
return <span className="cell-empty"></span>;
}
const cellId = `${rowIdx}-${variable}`;
const { type, value } = binding;
const cellContent = (
<>
{type === 'uri' ? (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="cell-uri"
title={value}
>
{shortenUri(value)}
</a>
) : type === 'literal' ? (
<span className="cell-literal" title={value}>
{value}
{'xml:lang' in binding && (
<span className="cell-lang">@{binding['xml:lang']}</span>
)}
{binding.datatype && (
<span className="cell-datatype" title={binding.datatype}>
^^{shortenUri(binding.datatype)}
</span>
)}
</span>
) : type === 'bnode' ? (
<span className="cell-bnode" title={value}>
{value}
</span>
) : (
<span>{value}</span>
)}
<button
className={`copy-btn ${copiedCell === cellId ? 'copied' : ''}`}
onClick={() => handleCopyCell(value, cellId)}
title="Copy to clipboard"
aria-label="Copy value"
>
{copiedCell === cellId ? '✓' : '📋'}
</button>
</>
);
return <div className="cell-content">{cellContent}</div>;
};
// Shorten URIs for display (show prefix + local name)
const shortenUri = (uri: string): string => {
const lastSlash = Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('#'));
if (lastSlash > 0 && lastSlash < uri.length - 1) {
return '...' + uri.substring(lastSlash);
}
return uri;
};
// Render pagination controls
const renderPagination = () => {
if (totalPages <= 1) return null;
const pageNumbers: (number | string)[] = [];
const showPages = 5; // Number of page buttons to show
if (totalPages <= showPages + 2) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Show first, last, and pages around current
pageNumbers.push(1);
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) pageNumbers.push('...');
for (let i = start; i <= end; i++) {
pageNumbers.push(i);
}
if (end < totalPages - 1) pageNumbers.push('...');
pageNumbers.push(totalPages);
}
return (
<div className="pagination">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="pagination-btn"
>
Previous
</button>
{pageNumbers.map((page, idx) =>
typeof page === 'number' ? (
<button
key={idx}
onClick={() => setCurrentPage(page)}
className={`pagination-btn ${currentPage === page ? 'active' : ''}`}
>
{page}
</button>
) : (
<span key={idx} className="pagination-ellipsis">
{page}
</span>
)
)}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="pagination-btn"
>
Next
</button>
<span className="pagination-info">
Page {currentPage} of {totalPages} ({sortedBindings.length} results)
</span>
</div>
);
};
if (bindings.length === 0) {
return (
<div className="results-table-container">
<div className="results-empty">
<p>No results found</p>
<p className="results-empty-hint">
Try a different query or check your SPARQL syntax
</p>
</div>
</div>
);
}
return (
<div className="results-table-container">
<div className="results-header">
<div className="results-summary">
<strong>{sortedBindings.length}</strong> result{sortedBindings.length !== 1 ? 's' : ''}
{variables.length > 0 && (
<span className="results-columns">
{' '}
· {variables.length} column{variables.length !== 1 ? 's' : ''}
</span>
)}
</div>
{onExport && (
<div className="results-export">
<button onClick={() => onExport('csv')} className="export-btn">
Export CSV
</button>
<button onClick={() => onExport('json')} className="export-btn">
Export JSON
</button>
<button onClick={() => onExport('jsonld')} className="export-btn">
Export JSON-LD
</button>
</div>
)}
</div>
<div className="results-table-wrapper" style={{ maxHeight }}>
<table className="results-table">
<thead>
<tr>
{variables.map((variable) => (
<th key={variable} onClick={() => handleSort(variable)}>
<div className="th-content">
<span className="th-label">{variable}</span>
{sortState.column === variable && (
<span className="sort-indicator">
{sortState.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedBindings.map((binding, rowIdx) => (
<tr key={rowIdx}>
{variables.map((variable) => (
<td key={variable} className={`cell-type-${binding[variable]?.type || 'empty'}`}>
{renderCell(variable, binding[variable], rowIdx)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{renderPagination()}
</div>
);
};