Server-side sorting on Records page
Clicking a column header reloads from page 1 with ORDER BY col ASC/DESC NULLS LAST passed to the view query. Sort column is validated against information_schema.columns to prevent injection. Pagination preserves the active sort across prev/next. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2aa9e0fcdd
commit
21388b7646
@ -284,7 +284,7 @@ module.exports = (pool) => {
|
||||
|
||||
router.get('/:name/view-data', async (req, res, next) => {
|
||||
try {
|
||||
const { limit = 100, offset = 0 } = req.query;
|
||||
const { limit = 100, offset = 0, sort_col, sort_dir } = req.query;
|
||||
const viewName = `dfv.${req.params.name}`;
|
||||
|
||||
// Check view exists
|
||||
@ -298,8 +298,23 @@ module.exports = (pool) => {
|
||||
return res.json({ exists: false, rows: [] });
|
||||
}
|
||||
|
||||
// Validate sort_col against actual view columns to prevent injection
|
||||
let orderClause = '';
|
||||
if (sort_col) {
|
||||
const cols = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'dfv' AND table_name = $1`,
|
||||
[req.params.name]
|
||||
);
|
||||
const validCols = cols.rows.map(r => r.column_name);
|
||||
if (validCols.includes(sort_col)) {
|
||||
const dir = sort_dir === 'desc' ? 'DESC' : 'ASC';
|
||||
orderClause = ` ORDER BY "${sort_col}" ${dir} NULLS LAST`;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM ${viewName} LIMIT $1 OFFSET $2`,
|
||||
`SELECT * FROM ${viewName}${orderClause} LIMIT $1 OFFSET $2`,
|
||||
[parseInt(limit), parseInt(offset)]
|
||||
);
|
||||
|
||||
|
||||
@ -63,7 +63,11 @@ export const api = {
|
||||
reprocess: (name) => request('POST', `/sources/${name}/reprocess`),
|
||||
generateView: (name) => request('POST', `/sources/${name}/view`),
|
||||
getFields: (name) => request('GET', `/sources/${name}/fields`),
|
||||
getViewData: (name, limit = 100, offset = 0) => request('GET', `/sources/${name}/view-data?limit=${limit}&offset=${offset}`),
|
||||
getViewData: (name, limit = 100, offset = 0, sortCol = null, sortDir = 'asc') => {
|
||||
const params = new URLSearchParams({ limit, offset })
|
||||
if (sortCol) { params.set('sort_col', sortCol); params.set('sort_dir', sortDir) }
|
||||
return request('GET', `/sources/${name}/view-data?${params}`)
|
||||
},
|
||||
|
||||
// Rules
|
||||
getRules: (source) => request('GET', `/rules/source/${source}`),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
|
||||
@ -30,13 +30,13 @@ export default function Records({ source }) {
|
||||
if (!source) return
|
||||
setOffset(0)
|
||||
setSort({ col: null, dir: 'asc' })
|
||||
load(0)
|
||||
load(0, null, 'asc')
|
||||
}, [source])
|
||||
|
||||
async function load(off) {
|
||||
async function load(off, col, dir) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.getViewData(source, LIMIT, off)
|
||||
const res = await api.getViewData(source, LIMIT, off, col, dir)
|
||||
setExists(res.exists)
|
||||
setRows(res.rows)
|
||||
} catch (err) {
|
||||
@ -47,24 +47,16 @@ export default function Records({ source }) {
|
||||
}
|
||||
|
||||
function toggleSort(col) {
|
||||
setSort(s => s.col === col
|
||||
? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||||
const next = sort.col === col
|
||||
? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { col, dir: 'asc' }
|
||||
)
|
||||
setSort(next)
|
||||
setOffset(0)
|
||||
load(0, next.col, next.dir)
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort.col) return rows
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = a[sort.col] ?? ''
|
||||
const bv = b[sort.col] ?? ''
|
||||
const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true })
|
||||
return sort.dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [rows, sort])
|
||||
|
||||
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o) }
|
||||
function next() { const o = offset + LIMIT; setOffset(o); load(o) }
|
||||
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir) }
|
||||
function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir) }
|
||||
|
||||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
@ -115,7 +107,7 @@ export default function Records({ source }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, i) => (
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
|
||||
{cols.map((col, j) => {
|
||||
const formatted = formatVal(row[col])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user