Redesigning listing table (#248)

* redesigning listing table

* getting rid of old listing table view

* improving listing grid
This commit is contained in:
Christian Kellner
2025-12-23 08:47:51 +01:00
committed by GitHub
parent 398259ff20
commit 3c209a8f97
25 changed files with 1142 additions and 773 deletions

View File

@@ -50,6 +50,46 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
jobRouter.get('/data', async (req, res) => {
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
// normalize booleans
const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const queryResult = jobStorage.queryJobs({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser,
isAdmin: isAdmin(req),
});
const isUserAdmin = isAdmin(req);
// Map result to include runtime status
queryResult.result = queryResult.result.map((job) => {
return {
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
job.userId !== req.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser),
};
});
res.body = queryResult;
res.send();
});
// Server-Sent Events for job status updates
jobRouter.get('/events', async (req, res) => {
const userId = req.session.currentUser;

View File

@@ -28,10 +28,14 @@ listingsRouter.get('/table', async (req, res) => {
freeTextFilter,
} = req.query || {};
// normalize booleans (accept true, 'true', 1, '1')
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
const normalizedActivity = toBool(activityFilter) ? true : null;
const normalizedWatch = toBool(watchListFilter) ? true : null;
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
let jobFilter = null;
let jobIdFilter = null;

View File

@@ -104,7 +104,11 @@ export default async function execute(url, waitForSelector, options) {
result = pageSource || (await page.content());
}
} catch (error) {
logger.warn('Error executing with puppeteer executor', error);
if (error?.message?.includes('Timeout')) {
logger.debug('Error executing with puppeteer executor', error);
} else {
logger.warn('Error executing with puppeteer executor', error);
}
result = null;
} finally {
try {

View File

@@ -40,11 +40,3 @@ export function markRunning(jobId) {
export function markFinished(jobId) {
running.delete(jobId);
}
/**
* Retrieve all currently running job IDs.
* @returns {string[]}
*/
export function getRunningJobIds() {
return Array.from(running);
}

View File

@@ -163,3 +163,109 @@ export const getJobs = () => {
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
};
/**
* Query jobs with pagination, filtering and sorting.
*
* @param {Object} params
* @param {number} [params.pageSize=50]
* @param {number} [params.page=1]
* @param {string} [params.freeTextFilter]
* @param {object} [params.activityFilter]
* @param {string|null} [params.sortField=null]
* @param {('asc'|'desc')} [params.sortDir='asc']
* @param {string} [params.userId] - Current user id used to scope jobs (ignored for admins).
* @param {boolean} [params.isAdmin=false] - When true, returns all jobs.
* @returns {{ totalNumber:number, page:number, result:Object[] }}
*/
export const queryJobs = ({
pageSize = 50,
page = 1,
activityFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
userId = null,
isAdmin = false,
} = {}) => {
// sanitize inputs
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
const offset = (safePage - 1) * safePageSize;
// build WHERE filter
const whereParts = [];
const params = { limit: safePageSize, offset };
params.userId = userId || '__NO_USER__';
if (!isAdmin) {
whereParts.push(
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
);
}
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(j.name LIKE @filter)`);
}
if (activityFilter === true) {
whereParts.push('(j.enabled = 1)');
} else if (activityFilter === false) {
whereParts.push('(j.enabled = 0)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
// whitelist sortable fields
const sortable = new Set(['name', 'numberOfFoundListings', 'enabled']);
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
let orderSql = 'ORDER BY j.name IS NULL, j.name ASC';
if (safeSortField) {
if (safeSortField === 'numberOfFoundListings') {
orderSql = `ORDER BY numberOfFoundListings ${safeSortDir}`;
} else {
orderSql = `ORDER BY j.${safeSortField} ${safeSortDir}`;
}
}
// count total
const countRow = SqliteConnection.query(
`SELECT COUNT(1) as cnt
FROM jobs j
${whereSql}`,
params,
);
const totalNumber = countRow?.[0]?.cnt ?? 0;
// fetch page
const rows = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
j.enabled,
j.name,
j.blacklist,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
${whereSql}
${orderSql}
LIMIT @limit OFFSET @offset`,
params,
);
const result = rows.map((row) => ({
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
return { totalNumber, page: safePage, result };
};

View File

@@ -277,9 +277,11 @@ export const queryListings = ({
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
}
// activityFilter: when true -> only active listings (is_active = 1)
// activityFilter: when true -> only active listings (is_active = 1), false -> only inactive
if (activityFilter === true) {
whereParts.push('(is_active = 1)');
} else if (activityFilter === false) {
whereParts.push('(is_active = 0)');
}
// Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
@@ -295,9 +297,11 @@ export const queryListings = ({
params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)');
}
// watchListFilter: when true -> only watched listings
// watchListFilter: when true -> only watched listings, false -> only unwatched
if (watchListFilter === true) {
whereParts.push('(wl.id IS NOT NULL)');
} else if (watchListFilter === false) {
whereParts.push('(wl.id IS NULL)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';