From 421b1848299197ae9acd82481cb360af8c33c9ea Mon Sep 17 00:00:00 2001 From: josedario87 Date: Fri, 13 Feb 2026 13:05:35 -0600 Subject: [PATCH] feat: Add advanced data table features to database page - Add cell click-to-copy with visual feedback - Add text filter to search across all columns - Add column visibility toggle (click schema columns to show/hide) - Add row selection with checkboxes - Copy All respects visible columns and selected rows - Auto-reset selections when changing table/page/query --- frontend/src/pages/DatabasePage.vue | 640 ++++++++++++++++++++++++++-- 1 file changed, 614 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/DatabasePage.vue b/frontend/src/pages/DatabasePage.vue index 5d25b7f..4dd040a 100644 --- a/frontend/src/pages/DatabasePage.vue +++ b/frontend/src/pages/DatabasePage.vue @@ -41,8 +41,67 @@ const queryLoading = ref(false) // Active tab const activeTab = ref<'tables' | 'query' | 'stats'>('tables') +// Copy feedback +const copiedCell = ref(null) +const copiedAll = ref(false) + +// Filters +const tableFilter = ref('') +const queryFilter = ref('') + +// Column visibility +const hiddenColumns = ref>(new Set()) +const queryHiddenColumns = ref>(new Set()) + +// Row selection +const selectedRows = ref>(new Set()) +const querySelectedRows = ref>(new Set()) + const totalPages = computed(() => Math.ceil(totalRecords.value / pageSize.value)) +const filteredTableData = computed(() => { + if (!tableFilter.value.trim()) return tableData.value + const search = tableFilter.value.toLowerCase() + return tableData.value.filter(row => + Object.values(row).some(val => + String(val).toLowerCase().includes(search) + ) + ) +}) + +const filteredQueryResult = computed(() => { + if (!queryResult.value) return null + if (!queryFilter.value.trim()) return queryResult.value + const search = queryFilter.value.toLowerCase() + return queryResult.value.filter(row => + Object.values(row).some(val => + String(val).toLowerCase().includes(search) + ) + ) +}) + +// Visible columns (excluding hidden ones) +const visibleTableColumns = computed(() => { + const allKeys = getColumnKeys(tableData.value) + return allKeys.filter(key => !hiddenColumns.value.has(key)) +}) + +const visibleQueryColumns = computed(() => { + const allKeys = getColumnKeys(queryResult.value || []) + return allKeys.filter(key => !queryHiddenColumns.value.has(key)) +}) + +// Check if all visible rows are selected +const allTableRowsSelected = computed(() => { + if (filteredTableData.value.length === 0) return false + return filteredTableData.value.every((_, idx) => selectedRows.value.has(idx)) +}) + +const allQueryRowsSelected = computed(() => { + if (!filteredQueryResult.value || filteredQueryResult.value.length === 0) return false + return filteredQueryResult.value.every((_, idx) => querySelectedRows.value.has(idx)) +}) + async function fetchTables() { loading.value = true error.value = null @@ -70,6 +129,9 @@ async function fetchDbStats() { async function selectTable(tableName: string) { selectedTable.value = tableName currentPage.value = 1 + tableFilter.value = '' + hiddenColumns.value.clear() + selectedRows.value.clear() await Promise.all([fetchTableSchema(tableName), fetchTableData(tableName)]) } @@ -106,6 +168,7 @@ async function fetchTableData(tableName: string) { function changePage(page: number) { if (page >= 1 && page <= totalPages.value && selectedTable.value) { currentPage.value = page + selectedRows.value.clear() fetchTableData(selectedTable.value) } } @@ -116,6 +179,9 @@ async function executeQuery() { queryLoading.value = true queryError.value = null queryResult.value = null + queryFilter.value = '' + queryHiddenColumns.value.clear() + querySelectedRows.value.clear() try { const res = await fetch('http://localhost:4101/api/database/query', { @@ -152,6 +218,83 @@ function getColumnKeys(data: any[]): string[] { return Object.keys(data[0]) } +async function copyCell(value: any, cellId: string) { + const textToCopy = value === null ? 'NULL' : + typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) + + try { + await navigator.clipboard.writeText(textToCopy) + copiedCell.value = cellId + setTimeout(() => { + copiedCell.value = null + }, 1500) + } catch (e) { + console.error('Failed to copy:', e) + } +} + +function toggleColumn(column: string, isQuery = false) { + const set = isQuery ? queryHiddenColumns.value : hiddenColumns.value + if (set.has(column)) { + set.delete(column) + } else { + set.add(column) + } +} + +function toggleRowSelection(idx: number, isQuery = false) { + const set = isQuery ? querySelectedRows.value : selectedRows.value + if (set.has(idx)) { + set.delete(idx) + } else { + set.add(idx) + } +} + +function toggleAllRows(isQuery = false) { + const set = isQuery ? querySelectedRows.value : selectedRows.value + const data = isQuery ? filteredQueryResult.value : filteredTableData.value + const allSelected = isQuery ? allQueryRowsSelected.value : allTableRowsSelected.value + + if (allSelected) { + set.clear() + } else { + data?.forEach((_, idx) => set.add(idx)) + } +} + +function getDataToCopy(data: any[], visibleCols: string[], selectedSet: Set): any[] { + // If rows are selected, only copy those; otherwise copy all + const rowsToUse = selectedSet.size > 0 + ? data.filter((_, idx) => selectedSet.has(idx)) + : data + + // Filter to only visible columns + return rowsToUse.map(row => { + const filtered: Record = {} + visibleCols.forEach(col => { + filtered[col] = row[col] + }) + return filtered + }) +} + +async function copyAllFiltered(data: any[], visibleCols: string[], selectedSet: Set) { + const dataToCopy = getDataToCopy(data, visibleCols, selectedSet) + if (dataToCopy.length === 0) return + + try { + const jsonText = JSON.stringify(dataToCopy, null, 2) + await navigator.clipboard.writeText(jsonText) + copiedAll.value = true + setTimeout(() => { + copiedAll.value = false + }, 2000) + } catch (e) { + console.error('Failed to copy:', e) + } +} + onMounted(() => { fetchTables() fetchDbStats() @@ -249,36 +392,127 @@ onMounted(() => {
-

{{ selectedTable }}

+
+

{{ selectedTable }}

+ Click columns to show/hide +
+ + + + + + + + {{ col.name }} {{ col.type }}
+ +
+
+ + + + + + +
+
+ + + + + + +
+
+
- +
- + + - - + +
{{ key }} + + {{ key }}
- {{ formatValue(row[key]) }} +
+ + + {{ formatValue(row[key]) }} + Copied!
+
All columns hidden
+
No matching records
No records found
@@ -329,23 +563,127 @@ onMounted(() => {
{{ queryError }}
-
- - - - - - - - - - - -
{{ key }}
- {{ formatValue(row[key]) }} -
-
Query returned no results
-
{{ queryResult.length }} row(s)
+
+ +
+ Columns: +
+ + + + + + + + + + {{ col }} + +
+
+ + +
+
+ + + + + + +
+
+ + + + + + +
+
+ +
+ + + + + + + + + + + + + +
+ + {{ key }}
+ + + {{ formatValue(row[key]) }} + Copied! +
+
All columns hidden
+
No matching records
+
Query returned no results
+
@@ -605,12 +943,24 @@ onMounted(() => { border-bottom: 1px solid var(--border-color); } +.schema-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + .schema-info h3 { - margin: 0 0 0.75rem 0; + margin: 0; font-size: 1rem; color: var(--text-primary); } +.schema-hint { + font-size: 0.75rem; + color: var(--text-muted); +} + .schema-columns { display: flex; flex-wrap: wrap; @@ -628,16 +978,187 @@ onMounted(() => { color: var(--text-secondary); } +.schema-col.clickable { + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.schema-col.clickable:hover { + background: var(--bg-hover); +} + +.schema-col.hidden { + opacity: 0.5; + text-decoration: line-through; +} + .schema-col.pk { background: var(--accent-muted); color: var(--accent); } +.schema-col.pk.hidden { + background: var(--bg-tertiary); +} + .schema-col small { color: var(--text-muted); font-size: 0.7rem; } +/* Query columns bar */ +.query-columns-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + overflow-x: auto; +} + +.columns-label { + font-size: 0.75rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.query-columns-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.query-col-toggle { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 4px; + font-size: 0.75rem; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.query-col-toggle:hover { + background: var(--bg-hover); +} + +.query-col-toggle.hidden { + opacity: 0.5; + text-decoration: line-through; +} + +/* Filter bar */ +.filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.filter-input-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + max-width: 400px; + padding: 0.5rem 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); +} + +.filter-input-wrapper:focus-within { + border-color: var(--accent); +} + +.filter-input { + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.875rem; + outline: none; +} + +.filter-input::placeholder { + color: var(--text-muted); +} + +.clear-filter { + padding: 0.25rem; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; +} + +.clear-filter:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.filter-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.filter-count { + font-size: 0.8rem; + color: var(--text-muted); +} + +.btn-copy-all { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: var(--bg-hover); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btn-copy-all:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-copy-all:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-copy-all.copied { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.query-results-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + .data-table-container { flex: 1; overflow: auto; @@ -670,6 +1191,7 @@ onMounted(() => { letter-spacing: 0.05em; position: sticky; top: 0; + z-index: 1; } .data-table td { @@ -680,6 +1202,72 @@ onMounted(() => { background: var(--bg-hover); } +/* Checkbox column */ +.checkbox-col { + width: 40px; + text-align: center; + padding: 0.5rem !important; +} + +.checkbox-col input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent); +} + +/* Selected row */ +.data-table tr.row-selected td { + background: var(--accent-muted); +} + +.data-table tr.row-selected:hover td { + background: var(--accent-muted); + filter: brightness(0.95); +} + +.data-table td.copyable { + cursor: pointer; + position: relative; + transition: background 0.15s; +} + +.data-table td.copyable:hover { + background: var(--accent-muted); +} + +.data-table td.copyable:active { + background: var(--accent); + color: white; +} + +.data-table td.copied { + background: var(--success-bg, #d4edda); +} + +.cell-content { + display: block; +} + +.copied-badge { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + padding: 0.125rem 0.375rem; + background: var(--accent); + color: white; + font-size: 0.65rem; + font-weight: 600; + border-radius: 4px; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-50%) scale(0.9); } + to { opacity: 1; transform: translateY(-50%) scale(1); } +} + .no-data { text-align: center; padding: 2rem;