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:
parent
f82dd57903
commit
810022d524
2 changed files with 169 additions and 85 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generated": "2025-12-07T16:47:16.823Z",
|
||||
"generated": "2025-12-07T18:19:27.338Z",
|
||||
"version": "1.0.0",
|
||||
"categories": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue