feat(frontend): add search filter and claim page filter to DuckLakePanel

- Add search bar to filter table data across all columns
- Filter web archive claims by selected page
- Include source_page in claim queries for filtering
- Fix TypeScript unused parameter warning
This commit is contained in:
kempersc 2025-12-07 19:20:40 +01:00
parent f82dd57903
commit 810022d524
2 changed files with 169 additions and 85 deletions

View file

@ -1,5 +1,5 @@
{
"generated": "2025-12-07T16:47:16.823Z",
"generated": "2025-12-07T18:19:27.338Z",
"version": "1.0.0",
"categories": [
{

View file

@ -335,6 +335,11 @@ const TEXT = {
backToTables: { nl: '← Terug naar tabellen', en: '← Back to tables' },
schema: { nl: 'Schema', en: 'Schema' },
data: { nl: 'Data', en: 'Data' },
// Search
search: { nl: 'Zoeken', en: 'Search' },
searchPlaceholder: { nl: 'Zoek in alle kolommen...', en: 'Search across all columns...' },
resultsFiltered: { nl: 'resultaten gefilterd', en: 'results filtered' },
clearSearch: { nl: 'Wissen', en: 'Clear' },
// Disconnected state messages
backendRequired: {
nl: 'Server-backend Vereist',
@ -418,11 +423,12 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
const [rowHeights, setRowHeights] = useState<Record<number, number>>({});
const [isFullWidth, setIsFullWidth] = useState(false); // Full-width mode for row detail view
const [showWebArchive, setShowWebArchive] = useState(false); // Web archive viewer mode
const [searchQuery, setSearchQuery] = useState(''); // Search filter for table data
const [webArchiveData, setWebArchiveData] = useState<{
ghcid: string;
url: string;
pages: { title: string; path: string; archived_file: string }[];
claims: { claim_id: string; claim_type: string; text_content: string; xpath: string; hypernym: string }[];
claims: { claim_id: string; claim_type: string; text_content: string; xpath: string; hypernym: string; source_page: string }[];
} | null>(null);
const [selectedWebPage, setSelectedWebPage] = useState<string | null>(null);
const PAGE_SIZE = 50;
@ -592,6 +598,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
setIsLoadingData(true);
setSelectedTable(tableName);
setDataPage(0);
setSearchQuery(''); // Reset search when loading new table
try {
const offset = 0;
// Tables are in the 'heritage' schema within DuckLake
@ -707,6 +714,21 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
return 'Record';
};
// Filter rows based on search query (case-insensitive across all columns)
const filterRowsBySearch = useCallback((rows: unknown[][], _columns: string[], query: string): unknown[][] => {
if (!query.trim()) return rows;
const lowerQuery = query.toLowerCase();
return rows.filter(row => {
return row.some(cell => {
if (cell === null || cell === undefined) return false;
const cellStr = typeof cell === 'object'
? JSON.stringify(cell).toLowerCase()
: String(cell).toLowerCase();
return cellStr.includes(lowerQuery);
});
});
}, []);
// Get GHCID from row data (try common GHCID fields)
const getRowGhcid = (rowObj: Record<string, unknown>): string | null => {
const ghcidFields = ['ghcid', 'ghcid_current', 'file_name'];
@ -746,9 +768,9 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
selectedSnapshot
);
// Get claims
// Get claims (including source_page for filtering)
const claimsResult = await executeQuery(
`SELECT claim_id, claim_type, text_content, xpath, hypernym FROM heritage.web_claims WHERE ghcid = '${ghcid}' LIMIT 100`,
`SELECT claim_id, claim_type, text_content, xpath, hypernym, source_page FROM heritage.web_claims WHERE ghcid = '${ghcid}' LIMIT 200`,
selectedSnapshot
);
@ -765,7 +787,8 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
claim_type: r[1] as string,
text_content: r[2] as string,
xpath: r[3] as string,
hypernym: r[4] as string
hypernym: r[4] as string,
source_page: r[5] as string
}))
});
setShowWebArchive(true);
@ -1174,6 +1197,36 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
</div>
</div>
{/* Search Bar */}
{explorerView === 'data' && !expandedRow && (
<div className="table-search-bar">
<div className="search-input-wrapper">
<span className="search-icon">🔍</span>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="search-input"
/>
{searchQuery && (
<button
className="clear-search-btn"
onClick={() => setSearchQuery('')}
title={t('clearSearch')}
>
</button>
)}
</div>
{searchQuery && tableData && (
<span className="search-results-count">
{filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} / {tableData.rows.length} {t('resultsFiltered')}
</span>
)}
</div>
)}
{explorerView === 'schema' ? (
/* Schema View */
<div className="schema-view">
@ -1265,18 +1318,22 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
)}
</div>
<div className="pages-list">
{webArchiveData.pages.map((page, idx) => (
<div
key={idx}
className={`page-item ${selectedWebPage === page.archived_file ? 'selected' : ''}`}
onClick={() => setSelectedWebPage(
selectedWebPage === page.archived_file ? null : page.archived_file
)}
>
<span className="page-title">{page.title || 'Untitled'}</span>
<span className="page-path">{page.path}</span>
</div>
))}
{webArchiveData.pages.map((page, idx) => {
// Extract filename from archived_file (e.g., "pages/index.html" -> "index.html")
const pageFile = page.archived_file?.split('/').pop() || '';
return (
<div
key={idx}
className={`page-item ${selectedWebPage === pageFile ? 'selected' : ''}`}
onClick={() => setSelectedWebPage(
selectedWebPage === pageFile ? null : pageFile
)}
>
<span className="page-title">{page.title || 'Untitled'}</span>
<span className="page-path">{page.path}</span>
</div>
);
})}
{webArchiveData.pages.length === 0 && (
<div className="no-data">No archived pages found</div>
)}
@ -1285,41 +1342,60 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
{/* Extracted Claims */}
<div className="archive-section claims-section">
<div className="section-header-row">
<h6>
🏷 Extracted Claims ({webArchiveData.claims.length})
{selectedWebPage && (
<span className="filter-indicator"> from main page</span>
)}
</h6>
</div>
<div className="claims-note">
Claims are extracted from the main page (index.html).
Per-page extraction coming soon.
</div>
<div className="claims-list">
{webArchiveData.claims.map((claim, idx) => (
<div key={idx} className="claim-item">
<div className="claim-header">
<span className={`claim-type ${claim.claim_type.toLowerCase()}`}>
{claim.claim_type}
</span>
{claim.hypernym && (
<span className="claim-hypernym">{claim.hypernym}</span>
{(() => {
// Filter claims by selected page
const filteredClaims = selectedWebPage
? webArchiveData.claims.filter(c => c.source_page === selectedWebPage)
: webArchiveData.claims;
return (
<>
<div className="section-header-row">
<h6>
🏷 Extracted Claims ({filteredClaims.length}
{selectedWebPage && ` / ${webArchiveData.claims.length} total`})
{selectedWebPage && (
<span className="filter-indicator"> filtered by page</span>
)}
</h6>
</div>
<div className="claims-note">
{selectedWebPage
? `Showing claims from: ${selectedWebPage}. Click "Clear" above to see all.`
: 'Click a page to filter claims from that page only.'
}
</div>
<div className="claims-list">
{filteredClaims.map((claim, idx) => (
<div key={idx} className="claim-item">
<div className="claim-header">
<span className={`claim-type ${claim.claim_type.toLowerCase()}`}>
{claim.claim_type}
</span>
{claim.hypernym && (
<span className="claim-hypernym">{claim.hypernym}</span>
)}
</div>
<div className="claim-content">{claim.text_content}</div>
{claim.xpath && (
<div className="claim-xpath" title={claim.xpath}>
📍 {claim.xpath.substring(0, 60)}...
</div>
)}
</div>
))}
{filteredClaims.length === 0 && (
<div className="no-data">
{selectedWebPage
? 'No claims extracted from this page'
: 'No claims extracted'
}
</div>
)}
</div>
<div className="claim-content">{claim.text_content}</div>
{claim.xpath && (
<div className="claim-xpath" title={claim.xpath}>
📍 {claim.xpath.substring(0, 60)}...
</div>
)}
</div>
))}
{webArchiveData.claims.length === 0 && (
<div className="no-data">No claims extracted</div>
)}
</div>
</>
);
})()}
</div>
</div>
</div>
@ -1365,41 +1441,46 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
</tr>
</thead>
<tbody>
{tableData.rows.map((row, rowIdx) => {
const rowObj = rowToObject(row, tableData.columns);
const rowHeight = rowHeights[rowIdx];
return (
<tr
key={rowIdx}
className="clickable-row"
style={rowHeight ? { height: rowHeight } : undefined}
onClick={() => setExpandedRow({
rowIndex: dataPage * PAGE_SIZE + rowIdx + 1,
data: rowObj
})}
>
<td className="row-expand-cell">
<span className="expand-arrow"></span>
<div
className="row-resize-handle"
onMouseDown={(e) => handleRowResizeStart(e, rowIdx, rowHeight || 40)}
onClick={(e) => e.stopPropagation()}
/>
</td>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
title={String(cell)}
style={{ maxWidth: columnWidths[tableData.columns[cellIdx]] || 150 }}
>
<div className="cell-content">
{formatCellValue(cell)}
</div>
{(() => {
const filteredRows = filterRowsBySearch(tableData.rows, tableData.columns, searchQuery);
return filteredRows.map((row, rowIdx) => {
const rowObj = rowToObject(row, tableData.columns);
const rowHeight = rowHeights[rowIdx];
// Find original index for accurate row numbering
const originalIdx = tableData.rows.indexOf(row);
return (
<tr
key={rowIdx}
className="clickable-row"
style={rowHeight ? { height: rowHeight } : undefined}
onClick={() => setExpandedRow({
rowIndex: dataPage * PAGE_SIZE + originalIdx + 1,
data: rowObj
})}
>
<td className="row-expand-cell">
<span className="expand-arrow"></span>
<div
className="row-resize-handle"
onMouseDown={(e) => handleRowResizeStart(e, rowIdx, rowHeight || 40)}
onClick={(e) => e.stopPropagation()}
/>
</td>
))}
</tr>
);
})}
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
title={String(cell)}
style={{ maxWidth: columnWidths[tableData.columns[cellIdx]] || 150 }}
>
<div className="cell-content">
{formatCellValue(cell)}
</div>
</td>
))}
</tr>
);
});
})()}
</tbody>
</table>
</div>
@ -1412,7 +1493,10 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) {
{t('previous')}
</button>
<span className="page-info">
{t('showingRows')} {dataPage * PAGE_SIZE + 1} - {dataPage * PAGE_SIZE + tableData.rows.length}
{searchQuery
? `${filterRowsBySearch(tableData.rows, tableData.columns, searchQuery).length} ${t('resultsFiltered')}`
: `${t('showingRows')} ${dataPage * PAGE_SIZE + 1} - ${dataPage * PAGE_SIZE + tableData.rows.length}`
}
</span>
<button
className="page-btn"