mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2926ee7e08 | ||
|
|
9506d1a9db | ||
|
|
feaa06c132 | ||
|
|
ad46500d4e | ||
|
|
3c209a8f97 |
2
LICENSE
2
LICENSE
@@ -210,5 +210,5 @@ different name or branding without the explicit written permission of the
|
|||||||
original copyright holder.
|
original copyright holder.
|
||||||
|
|
||||||
|
|
||||||
Copyright (c) 2025 Christian Kellner
|
Copyright (c) 2026 Christian Kellner
|
||||||
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 4.7 MiB |
@@ -50,6 +50,46 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
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
|
// Server-Sent Events for job status updates
|
||||||
jobRouter.get('/events', async (req, res) => {
|
jobRouter.get('/events', async (req, res) => {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ listingsRouter.get('/table', async (req, res) => {
|
|||||||
freeTextFilter,
|
freeTextFilter,
|
||||||
} = req.query || {};
|
} = req.query || {};
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1')
|
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
||||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
const toBool = (v) => {
|
||||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
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 jobFilter = null;
|
||||||
let jobIdFilter = null;
|
let jobIdFilter = null;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const init = (sourceConfig, blacklist) => {
|
|||||||
|
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'OhneMakler',
|
name: 'OhneMakler',
|
||||||
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
baseUrl: 'https://www.ohne-makler.net',
|
||||||
id: 'ohneMakler',
|
id: 'ohneMakler',
|
||||||
};
|
};
|
||||||
export { config };
|
export { config };
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,11 +40,3 @@ export function markRunning(jobId) {
|
|||||||
export function markFinished(jobId) {
|
export function markFinished(jobId) {
|
||||||
running.delete(jobId);
|
running.delete(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve all currently running job IDs.
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function getRunningJobIds() {
|
|
||||||
return Array.from(running);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -163,3 +163,109 @@ export const getJobs = () => {
|
|||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -277,9 +277,11 @@ export const queryListings = ({
|
|||||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
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) {
|
if (activityFilter === true) {
|
||||||
whereParts.push('(is_active = 1)');
|
whereParts.push('(is_active = 1)');
|
||||||
|
} else if (activityFilter === false) {
|
||||||
|
whereParts.push('(is_active = 0)');
|
||||||
}
|
}
|
||||||
// Prefer filtering by job id when provided (unambiguous and robust)
|
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||||
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||||
@@ -295,9 +297,11 @@ export const queryListings = ({
|
|||||||
params.providerName = String(providerFilter).trim();
|
params.providerName = String(providerFilter).trim();
|
||||||
whereParts.push('(provider = @providerName)');
|
whereParts.push('(provider = @providerName)');
|
||||||
}
|
}
|
||||||
// watchListFilter: when true -> only watched listings
|
// watchListFilter: when true -> only watched listings, false -> only unwatched
|
||||||
if (watchListFilter === true) {
|
if (watchListFilter === true) {
|
||||||
whereParts.push('(wl.id IS NOT NULL)');
|
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 ')}` : '';
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "16.3.0",
|
"version": "17.0.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"format": "prettier --write \"**/*.js\"",
|
"format": "prettier --write \"**/*.js\"",
|
||||||
"format:check": "prettier --check \"**/*.js\"",
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"@douyinfe/semi-icons": "^2.89.1",
|
"@douyinfe/semi-icons": "^2.90.3",
|
||||||
"@douyinfe/semi-ui": "2.89.1",
|
"@douyinfe/semi-ui": "2.90.3",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.33.1",
|
"puppeteer": "^24.34.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
"@babel/eslint-parser": "7.28.5",
|
"@babel/eslint-parser": "7.28.5",
|
||||||
"@babel/preset-env": "7.28.5",
|
"@babel/preset-env": "7.28.5",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"chai": "6.2.1",
|
"chai": "6.2.2",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export default function FredyApp() {
|
|||||||
if (!needsLogin()) {
|
if (!needsLogin()) {
|
||||||
await actions.features.getFeatures();
|
await actions.features.getFeatures();
|
||||||
await actions.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.jobs.getSharableUserList();
|
await actions.jobsData.getSharableUserList();
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
await actions.versionUpdate.getVersionUpdate();
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
@color-blue-text: #60a5fa;
|
@color-blue-text: #60a5fa;
|
||||||
|
|
||||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
||||||
@color-orange-border: #d33601;
|
@color-orange-border: #992f0c;
|
||||||
@color-orange-text: #FB923CFF;
|
@color-orange-text: #FB923CFF;
|
||||||
|
|
||||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
@color-green-bg: rgba(38, 250, 5, 0.12);
|
||||||
@color-green-border: #00c316;
|
@color-green-border: #278832;
|
||||||
@color-green-text: #33f308;
|
@color-green-text: #33f308;
|
||||||
|
|
||||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
||||||
|
|||||||
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
Popover,
|
||||||
|
Tag,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Pagination,
|
||||||
|
Toast,
|
||||||
|
Empty,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconDelete,
|
||||||
|
IconDescend2,
|
||||||
|
IconEdit,
|
||||||
|
IconPlayCircle,
|
||||||
|
IconBriefcase,
|
||||||
|
IconBell,
|
||||||
|
IconSearch,
|
||||||
|
IconFilter,
|
||||||
|
IconPlusCircle,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
|
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
import './JobGrid.less';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
|
const JobGrid = () => {
|
||||||
|
const jobsData = useSelector((state) => state.jobsData);
|
||||||
|
const actions = useActions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useState('name');
|
||||||
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const pendingJobIdRef = useRef(null);
|
||||||
|
const evtSourceRef = useRef(null);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
actions.jobsData.getJobsData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield: sortField,
|
||||||
|
sortdir: sortDir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { activityFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [page, sortField, sortDir, freeTextFilter, activityFilter]);
|
||||||
|
|
||||||
|
// SSE connection for live job status updates
|
||||||
|
useEffect(() => {
|
||||||
|
// establish SSE connection
|
||||||
|
const src = new EventSource('/api/jobs/events');
|
||||||
|
evtSourceRef.current = src;
|
||||||
|
|
||||||
|
const onJobStatus = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data || '{}');
|
||||||
|
if (data && data.jobId) {
|
||||||
|
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
||||||
|
// notify finish if it was triggered by this view
|
||||||
|
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||||
|
Toast.success('Job finished');
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
src.addEventListener('jobStatus', onJobStatus);
|
||||||
|
src.onerror = () => {
|
||||||
|
// Let browser auto-reconnect
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
src.removeEventListener('jobStatus', onJobStatus);
|
||||||
|
src.close();
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
evtSourceRef.current = null;
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
};
|
||||||
|
}, [actions.jobsData]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
|
const onJobRemoval = async (jobId) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
|
Toast.success('Job successfully removed');
|
||||||
|
loadData();
|
||||||
|
actions.jobsData.getJobs(); // refresh select list too
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onListingRemoval = async (jobId) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/job', { jobId });
|
||||||
|
Toast.success('Listings successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onJobStatusChanged = async (jobId, status) => {
|
||||||
|
try {
|
||||||
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||||
|
Toast.success('Job status successfully changed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onJobRun = async (jobId) => {
|
||||||
|
try {
|
||||||
|
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||||
|
if (response.status === 202) {
|
||||||
|
Toast.success('Job run started');
|
||||||
|
} else {
|
||||||
|
Toast.info('Job run requested');
|
||||||
|
}
|
||||||
|
pendingJobIdRef.current = jobId;
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 409) {
|
||||||
|
Toast.warning(error?.json?.message || 'Job is already running');
|
||||||
|
} else if (error?.status === 403) {
|
||||||
|
Toast.error('You are not allowed to run this job');
|
||||||
|
} else if (error?.status === 404) {
|
||||||
|
Toast.error('Job not found');
|
||||||
|
} else {
|
||||||
|
Toast.error('Failed to trigger job');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (_page) => {
|
||||||
|
setPage(_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="jobGrid">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Button
|
||||||
|
style={{ width: '7rem', margin: 0 }}
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlusCircle />}
|
||||||
|
className="jobs__newButton"
|
||||||
|
onClick={() => navigate('/jobs/new')}
|
||||||
|
>
|
||||||
|
New Job
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="jobGrid__searchbar">
|
||||||
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
|
<Button
|
||||||
|
icon={<IconFilter />}
|
||||||
|
onClick={() => {
|
||||||
|
setShowFilterBar(!showFilterBar);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilterBar && (
|
||||||
|
<div className="jobGrid__toolbar">
|
||||||
|
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="jobGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Filter by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setActivityFilter(val)}
|
||||||
|
value={activityFilter}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
>
|
||||||
|
<Select.Option value={true}>Active</Select.Option>
|
||||||
|
<Select.Option value={false}>Not Active</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider layout="vertical" />
|
||||||
|
<div className="jobGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Sort by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Sort By"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={sortField}
|
||||||
|
onChange={(val) => setSortField(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="name">Name</Select.Option>
|
||||||
|
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||||
|
<Select.Option value="enabled">Status</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Direction"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={sortDir}
|
||||||
|
onChange={(val) => setSortDir(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="asc">Ascending</Select.Option>
|
||||||
|
<Select.Option value="desc">Descending</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(jobsData?.result || []).length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No jobs available yet..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{(jobsData?.result || []).map((job) => (
|
||||||
|
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||||
|
<Card
|
||||||
|
className="jobGrid__card"
|
||||||
|
bodyStyle={{ padding: '16px' }}
|
||||||
|
headerLine={true}
|
||||||
|
title={
|
||||||
|
<div className="jobGrid__header">
|
||||||
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
|
{job.name}
|
||||||
|
</Title>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{job.isOnlyShared && (
|
||||||
|
<Popover
|
||||||
|
content={getPopoverContent(
|
||||||
|
'This job has been shared with you by another user, therefor it is read-only.',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{job.running && (
|
||||||
|
<Tag color="green" variant="light" size="small">
|
||||||
|
RUNNING
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="jobGrid__content">
|
||||||
|
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||||
|
Is active:
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||||
|
Listings:
|
||||||
|
</Text>
|
||||||
|
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.numberOfFoundListings || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconBriefcase />} size="small">
|
||||||
|
Providers:
|
||||||
|
</Text>
|
||||||
|
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.provider.length || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconBell />} size="small">
|
||||||
|
Adapters:
|
||||||
|
</Text>
|
||||||
|
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.notificationAdapter.length || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider margin="12px" />
|
||||||
|
|
||||||
|
<div className="jobGrid__actions">
|
||||||
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onJobRun(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
|
||||||
|
<div className="jobGrid__pagination">
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={jobsData?.totalNumber || 0}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobGrid;
|
||||||
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.jobGrid {
|
||||||
|
&__card {
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__searchbar {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
&__card {
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
background: #232429;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__infoItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.semi-typography {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobPopoverContent {
|
||||||
|
padding: .4rem;
|
||||||
|
color: var(--semi-color-white);
|
||||||
|
}
|
||||||
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal file
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
Image,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Toast,
|
||||||
|
Divider,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Popover,
|
||||||
|
Empty,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconBriefcase,
|
||||||
|
IconCart,
|
||||||
|
IconClock,
|
||||||
|
IconDelete,
|
||||||
|
IconLink,
|
||||||
|
IconMapPin,
|
||||||
|
IconStar,
|
||||||
|
IconStarStroked,
|
||||||
|
IconSearch,
|
||||||
|
IconFilter,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
import './ListingsGrid.less';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const ListingsGrid = () => {
|
||||||
|
const listingsData = useSelector((state) => state.listingsData);
|
||||||
|
const providers = useSelector((state) => state.provider);
|
||||||
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
|
const actions = useActions();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 40;
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useState('created_at');
|
||||||
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
|
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||||
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||||
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
actions.listingsData.getListingsData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield: sortField,
|
||||||
|
sortdir: sortDir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// cleanup debounced handler to avoid memory leaks
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
|
const handleWatch = async (e, item) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/watch', { listingId: item.id });
|
||||||
|
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to operate Watchlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (_page) => {
|
||||||
|
setPage(_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="listingsGrid">
|
||||||
|
<div className="listingsGrid__searchbar">
|
||||||
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
|
<Button
|
||||||
|
icon={<IconFilter />}
|
||||||
|
onClick={() => {
|
||||||
|
setShowFilterBar(!showFilterBar);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
{showFilterBar && (
|
||||||
|
<div className="listingsGrid__toolbar">
|
||||||
|
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="listingsGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Filter by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setActivityFilter(val)}
|
||||||
|
value={activityFilter}
|
||||||
|
>
|
||||||
|
<Select.Option value={true}>Active</Select.Option>
|
||||||
|
<Select.Option value={false}>Not Active</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Watchlist"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setWatchListFilter(val)}
|
||||||
|
value={watchListFilter}
|
||||||
|
>
|
||||||
|
<Select.Option value={true}>Watched</Select.Option>
|
||||||
|
<Select.Option value={false}>Not Watched</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Provider"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setProviderFilter(val)}
|
||||||
|
value={providerFilter}
|
||||||
|
>
|
||||||
|
{providers?.map((p) => (
|
||||||
|
<Select.Option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Job Name"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setJobNameFilter(val)}
|
||||||
|
value={jobNameFilter}
|
||||||
|
>
|
||||||
|
{jobs?.map((j) => (
|
||||||
|
<Select.Option key={j.id} value={j.id}>
|
||||||
|
{j.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider layout="vertical" />
|
||||||
|
|
||||||
|
<div className="listingsGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Sort by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Sort By"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={sortField}
|
||||||
|
onChange={(val) => setSortField(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="job_name">Job Name</Select.Option>
|
||||||
|
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||||
|
<Select.Option value="price">Price</Select.Option>
|
||||||
|
<Select.Option value="provider">Provider</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Direction"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={sortDir}
|
||||||
|
onChange={(val) => setSortDir(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="asc">Ascending</Select.Option>
|
||||||
|
<Select.Option value="desc">Descending</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(listingsData?.result || []).length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No listings available yet..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{(listingsData?.result || []).map((item) => (
|
||||||
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||||
|
<Card
|
||||||
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||||
|
cover={
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div className="listingsGrid__imageContainer">
|
||||||
|
<Image
|
||||||
|
src={item.image_url || no_image}
|
||||||
|
fallback={no_image}
|
||||||
|
width="100%"
|
||||||
|
height={180}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
preview={false}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
item.isWatched === 1 ? (
|
||||||
|
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||||
|
) : (
|
||||||
|
<IconStarStroked />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
theme="light"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
className="listingsGrid__watchButton"
|
||||||
|
onClick={(e) => handleWatch(e, item)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: '12px' }}
|
||||||
|
>
|
||||||
|
<div className="listingsGrid__content">
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
|
||||||
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</a>
|
||||||
|
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
||||||
|
<Text type="secondary" icon={<IconCart />} size="small">
|
||||||
|
{item.price} €
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconMapPin />}
|
||||||
|
size="small"
|
||||||
|
ellipsis={{ showTooltip: true }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{item.address || 'No address provided'}
|
||||||
|
</Text>
|
||||||
|
<Text type="tertiary" size="small" icon={<IconClock />}>
|
||||||
|
{timeService.format(item.created_at, false)}
|
||||||
|
</Text>
|
||||||
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
||||||
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Divider margin=".6rem" />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Button
|
||||||
|
title="Link to listing"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
window.open(item.link);
|
||||||
|
}}
|
||||||
|
icon={<IconLink />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Remove"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [item.id] });
|
||||||
|
Toast.success('Listing(s) successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<IconDelete />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
{(listingsData?.result || []).length > 0 && (
|
||||||
|
<div className="listingsGrid__pagination">
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={listingsData?.totalNumber || 0}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingsGrid;
|
||||||
106
ui/src/components/grid/listings/ListingsGrid.less
Normal file
106
ui/src/components/grid/listings/ListingsGrid.less
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
.listingsGrid {
|
||||||
|
&__imageContainer {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__searchbar {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__watchButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background-color: white !important;
|
||||||
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--semi-color-fill-0) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statusTag {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--inactive {
|
||||||
|
.listingsGrid__imageContainer,
|
||||||
|
.listingsGrid__content {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__inactiveOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 70px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
color: var(--semi-color-danger);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transform: rotate(-30deg);
|
||||||
|
padding: 5px;
|
||||||
|
max-height: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titleLink {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--semi-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: block;
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
background: #232429;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__setupButton {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
|
||||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconPlayCircle } from '@douyinfe/semi-icons';
|
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
||||||
|
|
||||||
import './JobTable.less';
|
|
||||||
|
|
||||||
const empty = (
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description="No jobs available. Why don't you create one? ;)"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
|
||||||
|
|
||||||
export default function JobTable({
|
|
||||||
jobs = {},
|
|
||||||
onJobRemoval,
|
|
||||||
onJobStatusChanged,
|
|
||||||
onJobEdit,
|
|
||||||
onListingRemoval,
|
|
||||||
onJobRun,
|
|
||||||
} = {}) {
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
pagination={false}
|
|
||||||
empty={empty}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
dataIndex: '',
|
|
||||||
render: (job) => {
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
|
||||||
checked={job.enabled}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Name',
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (name, job) => {
|
|
||||||
if (job.isOnlyShared) {
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
content={getPopoverContent(
|
|
||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
|
||||||
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
|
||||||
<IconAlertTriangle />
|
|
||||||
</div>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Listings',
|
|
||||||
dataIndex: 'numberOfFoundListings',
|
|
||||||
render: (value) => {
|
|
||||||
return value || 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Provider',
|
|
||||||
dataIndex: 'provider',
|
|
||||||
render: (value) => {
|
|
||||||
return value.length || 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Notification Adapter',
|
|
||||||
dataIndex: 'notificationAdapter',
|
|
||||||
render: (value) => {
|
|
||||||
return value.length || 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
dataIndex: 'tools',
|
|
||||||
render: (_, job) => {
|
|
||||||
return (
|
|
||||||
<div className="interactions">
|
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<IconPlayCircle />}
|
|
||||||
disabled={job.isOnlyShared || job.running}
|
|
||||||
onClick={() => onJobRun && onJobRun(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
|
||||||
<Button
|
|
||||||
type="secondary"
|
|
||||||
icon={<IconEdit />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobEdit(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
icon={<IconDescend2 />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onListingRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
icon={<IconDelete />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
dataSource={jobs}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.interactions {
|
|
||||||
float: right;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jobPopoverContent {
|
|
||||||
padding: 1rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.interactions {
|
|
||||||
flex-direction: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Popover,
|
|
||||||
Input,
|
|
||||||
Descriptions,
|
|
||||||
Tag,
|
|
||||||
Image,
|
|
||||||
Empty,
|
|
||||||
Button,
|
|
||||||
Toast,
|
|
||||||
Divider,
|
|
||||||
Space,
|
|
||||||
Select,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
|
||||||
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
|
||||||
|
|
||||||
import './ListingsTable.less';
|
|
||||||
import { format } from '../../../services/time/timeService.js';
|
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useFeature } from '../../../hooks/featureHook.js';
|
|
||||||
|
|
||||||
const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: 'Watchlist',
|
|
||||||
width: 133,
|
|
||||||
dataIndex: 'isWatched',
|
|
||||||
sorter: true,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
text: 'Show only watched listings',
|
|
||||||
value: 'watchList',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
render: (id, row) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
row.isWatched === 1 ? (
|
|
||||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
|
||||||
) : (
|
|
||||||
<IconStarStroked />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
|
||||||
Toast.success(
|
|
||||||
row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
|
|
||||||
);
|
|
||||||
row.reloadTable();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
Toast.error('Failed to operate Watchlist');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Divider layout="vertical" margin="4px" />
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content="Delete Listing"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
|
||||||
Toast.success('Listing(s) successfully removed');
|
|
||||||
row.reloadTable();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Active',
|
|
||||||
dataIndex: 'is_active',
|
|
||||||
width: 110,
|
|
||||||
sorter: true,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
text: 'Show only active listings',
|
|
||||||
value: 'activityStatus',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
render: (value) => {
|
|
||||||
return value ? (
|
|
||||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content="Listing is still active"
|
|
||||||
>
|
|
||||||
<IconTick />
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content="Listing is inactive"
|
|
||||||
>
|
|
||||||
<IconClose />
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Job-Name',
|
|
||||||
sorter: true,
|
|
||||||
ellipsis: true,
|
|
||||||
dataIndex: 'job_name',
|
|
||||||
width: 150,
|
|
||||||
onFilter: () => true,
|
|
||||||
renderFilterDropdown: () => {
|
|
||||||
return (
|
|
||||||
<Space vertical style={{ padding: 8 }}>
|
|
||||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
|
|
||||||
{jobs != null &&
|
|
||||||
jobs.length > 0 &&
|
|
||||||
jobs.map((job) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={job.id} key={job.id}>
|
|
||||||
{job.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Listing date',
|
|
||||||
width: 130,
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
sorter: true,
|
|
||||||
render: (text) => timeService.format(text, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Provider',
|
|
||||||
width: 130,
|
|
||||||
dataIndex: 'provider',
|
|
||||||
sorter: true,
|
|
||||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
|
||||||
onFilter: () => true,
|
|
||||||
renderFilterDropdown: () => {
|
|
||||||
return (
|
|
||||||
<Space vertical style={{ padding: 8 }}>
|
|
||||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => setProviderFilter(val)}>
|
|
||||||
{provider != null &&
|
|
||||||
provider.length > 0 &&
|
|
||||||
provider.map((prov) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={prov.id} key={prov.id}>
|
|
||||||
{prov.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Price',
|
|
||||||
width: 110,
|
|
||||||
dataIndex: 'price',
|
|
||||||
sorter: true,
|
|
||||||
render: (text) => text + ' €',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Address',
|
|
||||||
width: 150,
|
|
||||||
dataIndex: 'address',
|
|
||||||
sorter: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Title',
|
|
||||||
dataIndex: 'title',
|
|
||||||
sorter: true,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (text, row) => {
|
|
||||||
return (
|
|
||||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{text}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const empty = (
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description="No listings found."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ListingsTable() {
|
|
||||||
const tableData = useSelector((state) => state.listingsTable);
|
|
||||||
const provider = useSelector((state) => state.provider);
|
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
|
||||||
const actions = useActions();
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const pageSize = 10;
|
|
||||||
const [sortData, setSortData] = useState({});
|
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
|
||||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
|
||||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
|
||||||
const [allFilters, setAllFilters] = useState([]);
|
|
||||||
|
|
||||||
const [imageWidth, setImageWidth] = useState('100%');
|
|
||||||
const handlePageChange = (_page) => {
|
|
||||||
setPage(_page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
|
|
||||||
const loadTable = () => {
|
|
||||||
let sortfield = null;
|
|
||||||
let sortdir = null;
|
|
||||||
|
|
||||||
if (sortData != null && Object.keys(sortData).length > 0) {
|
|
||||||
sortfield = sortData.field;
|
|
||||||
sortdir = sortData.direction;
|
|
||||||
}
|
|
||||||
actions.listingsTable.getListingsTable({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
sortfield,
|
|
||||||
sortdir,
|
|
||||||
freeTextFilter,
|
|
||||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTable();
|
|
||||||
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
|
||||||
|
|
||||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
|
||||||
|
|
||||||
const diffArrays = (primary, secondary) => {
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
for (const item of secondary) {
|
|
||||||
if (!primary.includes(item)) result[item] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of primary) {
|
|
||||||
if (!secondary.includes(item)) result[item] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [result];
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// cleanup debounced handler to avoid memory leaks
|
|
||||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
|
||||||
};
|
|
||||||
}, [handleFilterChange]);
|
|
||||||
|
|
||||||
const expandRowRender = (record) => {
|
|
||||||
return (
|
|
||||||
<div className="listingsTable__expanded">
|
|
||||||
<div>
|
|
||||||
{record.image_url == null ? (
|
|
||||||
<Image height={200} width={180} src={no_image} />
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
height={200}
|
|
||||||
width={imageWidth}
|
|
||||||
src={record.image_url}
|
|
||||||
onError={() => {
|
|
||||||
setImageWidth('180px');
|
|
||||||
}}
|
|
||||||
fallback={<Image height={200} src={no_image} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Descriptions align="justify">
|
|
||||||
<Descriptions.Item itemKey="Listing still online">
|
|
||||||
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
|
||||||
{record.is_active ? 'Yes' : 'No'}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Link">
|
|
||||||
<a href={record.link} target="_blank" rel="noopener noreferrer">
|
|
||||||
Link to Listing
|
|
||||||
</a>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
<b>{record.title}</b>
|
|
||||||
<p>{record.description == null ? 'No description available' : record.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
showClear
|
|
||||||
className="listingsTable__search"
|
|
||||||
placeholder="Search"
|
|
||||||
onChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
{watchlistFeature && (
|
|
||||||
<Button
|
|
||||||
className="listingsTable__setupButton"
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/watchlistManagement');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Setup notifications on watchlist changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
empty={empty}
|
|
||||||
hideExpandedColumn={false}
|
|
||||||
sticky={{ top: 5 }}
|
|
||||||
columns={columns}
|
|
||||||
expandedRowRender={expandRowRender}
|
|
||||||
dataSource={(tableData?.result || []).map((row) => {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
reloadTable: loadTable,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(changeSet) => {
|
|
||||||
if (changeSet?.extra?.changeType === 'filter') {
|
|
||||||
const transformed = changeSet.filters.map((f) => f.dataIndex);
|
|
||||||
const diff = diffArrays(allFilters, transformed);
|
|
||||||
setAllFilters(transformed);
|
|
||||||
diff.forEach((filter) => {
|
|
||||||
switch (Object.keys(filter)[0]) {
|
|
||||||
case 'isWatched':
|
|
||||||
setWatchListFilter(Object.values(filter)[0]);
|
|
||||||
break;
|
|
||||||
case 'is_active':
|
|
||||||
setActivityFilter(Object.values(filter)[0]);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.error('Unknown filter: ', filter.dataIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (changeSet?.extra?.changeType === 'sorter') {
|
|
||||||
setSortData({
|
|
||||||
field: changeSet.sorter.dataIndex,
|
|
||||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
pagination={{
|
|
||||||
currentPage: page,
|
|
||||||
//for now fixed
|
|
||||||
pageSize,
|
|
||||||
total: tableData?.totalNumber || 0,
|
|
||||||
onPageChange: handlePageChange,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
.listingsTable {
|
|
||||||
&__search {
|
|
||||||
margin-bottom: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__expanded {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__toolbar {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__setupButton {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,19 +83,44 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
jobs: {
|
jobsData: {
|
||||||
async getJobs() {
|
async getJobs() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs');
|
const response = await xhrGet('/api/jobs');
|
||||||
set((state) => ({ jobs: { ...state.jobs, jobs: Object.freeze(response.json) } }));
|
set((state) => ({ jobsData: { ...state.jobsData, jobs: Object.freeze(response.json) } }));
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getJobsData({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
freeTextFilter = null,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
filter,
|
||||||
|
} = {}) {
|
||||||
|
try {
|
||||||
|
const qryString = queryString.stringify({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
freeTextFilter,
|
||||||
|
sortfield,
|
||||||
|
sortdir,
|
||||||
|
...filter,
|
||||||
|
});
|
||||||
|
const response = await xhrGet(`/api/jobs/data?${qryString}`);
|
||||||
|
set((state) => ({
|
||||||
|
jobsData: { ...state.jobsData, ...response.json },
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for api/jobs/data. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
async getSharableUserList() {
|
async getSharableUserList() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs/shareableUserList');
|
const response = await xhrGet('/api/jobs/shareableUserList');
|
||||||
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
|
set((state) => ({ jobsData: { ...state.jobsData, shareableUserList: Object.freeze(response.json) } }));
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
}
|
}
|
||||||
@@ -103,9 +128,12 @@ export const useFredyState = create(
|
|||||||
setJobRunning(jobId, running) {
|
setJobRunning(jobId, running) {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.jobs.jobs || [];
|
const list = state.jobsData.jobs || [];
|
||||||
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
|
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
|
||||||
return { jobs: { ...state.jobs, jobs: Object.freeze(updated) } };
|
const result = (state.jobsData.result || []).map((j) =>
|
||||||
|
j.id === jobId ? { ...j, running: !!running } : j,
|
||||||
|
);
|
||||||
|
return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -151,8 +179,8 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
listingsTable: {
|
listingsData: {
|
||||||
async getListingsTable({
|
async getListingsData({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
freeTextFilter = null,
|
freeTextFilter = null,
|
||||||
@@ -171,7 +199,7 @@ export const useFredyState = create(
|
|||||||
});
|
});
|
||||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
listingsTable: { ...state.listingsTable, ...response.json },
|
listingsData: { ...state.listingsData, ...response.json },
|
||||||
}));
|
}));
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||||
@@ -184,7 +212,7 @@ export const useFredyState = create(
|
|||||||
const initial = {
|
const initial = {
|
||||||
dashboard: { data: null },
|
dashboard: { data: null },
|
||||||
notificationAdapter: [],
|
notificationAdapter: [],
|
||||||
listingsTable: {
|
listingsData: {
|
||||||
totalNumber: 0,
|
totalNumber: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
result: [],
|
result: [],
|
||||||
@@ -194,7 +222,13 @@ export const useFredyState = create(
|
|||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
versionUpdate: {},
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], shareableUserList: [] },
|
jobsData: {
|
||||||
|
jobs: [],
|
||||||
|
shareableUserList: [],
|
||||||
|
totalNumber: 0,
|
||||||
|
page: 1,
|
||||||
|
result: [],
|
||||||
|
},
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,10 +239,10 @@ export const useFredyState = create(
|
|||||||
generalSettings: { ...effects.generalSettings },
|
generalSettings: { ...effects.generalSettings },
|
||||||
demoMode: { ...effects.demoMode },
|
demoMode: { ...effects.demoMode },
|
||||||
versionUpdate: { ...effects.versionUpdate },
|
versionUpdate: { ...effects.versionUpdate },
|
||||||
listingsTable: { ...effects.listingsTable },
|
listingsData: { ...effects.listingsData },
|
||||||
provider: { ...effects.provider },
|
provider: { ...effects.provider },
|
||||||
features: { ...effects.features },
|
features: { ...effects.features },
|
||||||
jobs: { ...effects.jobs },
|
jobsData: { ...effects.jobsData },
|
||||||
user: { ...effects.user },
|
user: { ...effects.user },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,136 +5,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import JobTable from '../../components/table/JobTable';
|
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||||
import { useSelector, useActions } from '../../services/state/store';
|
|
||||||
import { xhrDelete, xhrPut, xhrPost } from '../../services/xhr';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const actions = useActions();
|
|
||||||
const pendingJobIdRef = React.useRef(null);
|
|
||||||
const evtSourceRef = React.useRef(null);
|
|
||||||
|
|
||||||
// SSE connection for live job status updates
|
|
||||||
React.useEffect(() => {
|
|
||||||
// establish SSE connection
|
|
||||||
const src = new EventSource('/api/jobs/events');
|
|
||||||
evtSourceRef.current = src;
|
|
||||||
|
|
||||||
const onJobStatus = (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data || '{}');
|
|
||||||
if (data && data.jobId) {
|
|
||||||
actions.jobs.setJobRunning(data.jobId, !!data.running);
|
|
||||||
// notify finish if it was triggered by this view
|
|
||||||
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
|
||||||
Toast.success('Job finished');
|
|
||||||
pendingJobIdRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed events
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
src.addEventListener('jobStatus', onJobStatus);
|
|
||||||
src.onerror = () => {
|
|
||||||
// Let browser auto-reconnect; optionally log
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
src.removeEventListener('jobStatus', onJobStatus);
|
|
||||||
src.close();
|
|
||||||
} catch {
|
|
||||||
//noop
|
|
||||||
}
|
|
||||||
evtSourceRef.current = null;
|
|
||||||
pendingJobIdRef.current = null;
|
|
||||||
};
|
|
||||||
}, [actions.jobs]);
|
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
|
||||||
Toast.success('Job successfully removed');
|
|
||||||
await actions.jobs.getJobs();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onListingRemoval = async (jobId) => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/job', { jobId });
|
|
||||||
Toast.success('Listings successfully removed');
|
|
||||||
await actions.jobs.getJobs();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onJobStatusChanged = async (jobId, status) => {
|
|
||||||
try {
|
|
||||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
|
||||||
Toast.success('Job status successfully changed');
|
|
||||||
await actions.jobs.getJobs();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onJobRun = async (jobId) => {
|
|
||||||
try {
|
|
||||||
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
|
||||||
if (response.status === 202) {
|
|
||||||
Toast.success('Job run started');
|
|
||||||
} else {
|
|
||||||
Toast.info('Job run requested');
|
|
||||||
}
|
|
||||||
// remember so we can show a finish toast when SSE says it's done
|
|
||||||
pendingJobIdRef.current = jobId;
|
|
||||||
// optional: one initial refresh in case SSE arrives late
|
|
||||||
await actions.jobs.getJobs();
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.status === 409) {
|
|
||||||
Toast.warning(error?.json?.message || 'Job is already running');
|
|
||||||
} else if (error?.status === 403) {
|
|
||||||
Toast.error('You are not allowed to run this job');
|
|
||||||
} else if (error?.status === 404) {
|
|
||||||
Toast.error('Job not found');
|
|
||||||
} else {
|
|
||||||
Toast.error('Failed to trigger job');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="jobs">
|
||||||
<div>
|
<JobGrid />
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<IconPlusCircle />}
|
|
||||||
className="jobs__newButton"
|
|
||||||
onClick={() => navigate('/jobs/new')}
|
|
||||||
>
|
|
||||||
New Job
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<JobTable
|
|
||||||
jobs={jobs || []}
|
|
||||||
onJobRemoval={onJobRemoval}
|
|
||||||
onListingRemoval={onListingRemoval}
|
|
||||||
onJobStatusChanged={onJobStatusChanged}
|
|
||||||
onJobRun={onJobRun}
|
|
||||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||||
@@ -73,7 +73,7 @@ export default function JobMutator() {
|
|||||||
enabled,
|
enabled,
|
||||||
jobId: jobToBeEdit?.id || null,
|
jobId: jobToBeEdit?.id || null,
|
||||||
});
|
});
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
Toast.success('Job successfully saved...');
|
Toast.success('Job successfully saved...');
|
||||||
navigate('/jobs');
|
navigate('/jobs');
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||||
|
|
||||||
export default function Listings() {
|
export default function Listings() {
|
||||||
return <ListingsTable />;
|
return <ListingsGrid />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const Users = function Users() {
|
|||||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||||
Toast.success('User successfully remove');
|
Toast.success('User successfully remove');
|
||||||
setUserIdToBeRemoved(null);
|
setUserIdToBeRemoved(null);
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.user.getUsers();
|
await actions.user.getUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error);
|
||||||
|
|||||||
118
yarn.lock
118
yarn.lock
@@ -997,34 +997,34 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-react@2.89.1":
|
"@douyinfe/semi-animation-react@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.1.tgz#cbbf6f5cfdfb870c20418441ab5f0ce6a5844146"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.90.3.tgz#dc55e082febfdae38c42caefb9928b1bb853647a"
|
||||||
integrity sha512-SNEy7kujmLp7jUNzxsjEurkYmnd54OQRxedWJ8Taq3R/AhOGq6x1mP6E8EXHcBl3DgWMDdLv7YF9or/6Pom4XQ==
|
integrity sha512-iivpAVysJqKUmpRp4gqXb/+x7A4DOWXNv8lNM841CC2EVpCnFh4yOgXvxYCELD1V70RcXcXMTOGwAukCO1HkCA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.1"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-animation-styled" "2.89.1"
|
"@douyinfe/semi-animation-styled" "2.90.3"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-styled@2.89.1":
|
"@douyinfe/semi-animation-styled@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.1.tgz#9a8b5b66a0065f1b15573b85c911aaaa1e05dcf3"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.90.3.tgz#6a5758af4540b482fd9a5db4960cac0511fb1f27"
|
||||||
integrity sha512-Zwl4EHn8IXWiqTAl3IIfbAWtmxH82B/BH2YqX88ASXqja8omJqMQ5ho1yYxzVFiurswz45RtoVm5tdt6M0d+MA==
|
integrity sha512-PQfNfOWOKiKHcViJOQipPRYYLFQNhzdFn9O84Rky9KzhrkH5/6NsNPMKIPfIUNN15qNI1gQMPP2mQxRAXJQTNg==
|
||||||
|
|
||||||
"@douyinfe/semi-animation@2.89.1":
|
"@douyinfe/semi-animation@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.1.tgz#bb8c341895a7439838f654a9b43a65ba256e2a04"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.90.3.tgz#197a12e1b100e8d15dbf7c7e858ce828938837cc"
|
||||||
integrity sha512-7hKxLO//Ggxr7lYxIwJSQx+WQt9uYfuUnybn7Td/80/Jk3eIs+OJF9rN/8zmECANcxzZxHIuWjX5lfcQDDGhoA==
|
integrity sha512-NcLXV2KDpgxHbqS+/d1KAkYJVmSlV0n1gRwB3diM6EgOaGLZHGNXMCVBuN7mss9f2sF6GTTJRwcjH3VdcV5E9g==
|
||||||
dependencies:
|
dependencies:
|
||||||
bezier-easing "^2.1.0"
|
bezier-easing "^2.1.0"
|
||||||
|
|
||||||
"@douyinfe/semi-foundation@2.89.1":
|
"@douyinfe/semi-foundation@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.1.tgz#9bce2d137974da975c7e44cf3620924efd10bee4"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.90.3.tgz#f75b51bd6f7bf3d8da3d9a3ed99aa0e44a2d3626"
|
||||||
integrity sha512-LP8vLQ/IyKGkzUoUX5/15A2SjTmCEWl/kiouvBeRpPvVyy/worTZe5XVd6ojlbFl0VIYWv4Jwy1w3kJHq4Ltvg==
|
integrity sha512-p9dtZbvkLtMKQturVwxtlJqfIOzHHN8ujaVgR/T9bS1ojzW+3Nua22yTkl7baLKUk/fvS/841BP3aHNXUmk71A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.1"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-json-viewer-core" "2.89.1"
|
"@douyinfe/semi-json-viewer-core" "2.90.3"
|
||||||
"@mdx-js/mdx" "^3.0.1"
|
"@mdx-js/mdx" "^3.0.1"
|
||||||
async-validator "^3.5.0"
|
async-validator "^3.5.0"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
@@ -1038,44 +1038,44 @@
|
|||||||
remark-gfm "^4.0.0"
|
remark-gfm "^4.0.0"
|
||||||
scroll-into-view-if-needed "^2.2.24"
|
scroll-into-view-if-needed "^2.2.24"
|
||||||
|
|
||||||
"@douyinfe/semi-icons@2.89.1", "@douyinfe/semi-icons@^2.89.1":
|
"@douyinfe/semi-icons@2.90.3", "@douyinfe/semi-icons@^2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.1.tgz#34635b5d515750ea281ec78190de71522847e2a4"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.90.3.tgz#9dc40a743951aed7ea5a709adc248f89a97ec3d9"
|
||||||
integrity sha512-K1xNbscHkGdx91xP5I6M/JKks/Xd2UzKGhLxBmyQ2D1Oboj95mJ+Dt15A8OGCcx2/n8shaJa2Ldw9JBPiXbNJg==
|
integrity sha512-1zeBiBtpnRTblJo1OWTRv1W0ULYdAb4XAF9K4AF7YSnYw+jvHn0zulaQyBqGsuCKeJlfOaCKSui/5HjDt1RgJg==
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-illustrations@2.89.1":
|
"@douyinfe/semi-illustrations@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.1.tgz#9ca1508af325d5362dbf802035022cb54e424c9c"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.90.3.tgz#03cb5f2edb909fe0f11ef907f4061597053ca7d3"
|
||||||
integrity sha512-3XEm3JIlCLZCKl/RmysYtIlqe6itbWf6u9nxaE6TWpSKJtSfA+wbyO2F3W2fvXkCJZopjk65BFLIFx/CAEqc5g==
|
integrity sha512-RXTVyAwRNiFEa9jXSGANCpm8aN2K4iU1knffmVNaRirGmMc5h6b8Uq7ZkVezizegSW6ETtucpFa2NmJX53MpPg==
|
||||||
|
|
||||||
"@douyinfe/semi-json-viewer-core@2.89.1":
|
"@douyinfe/semi-json-viewer-core@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.1.tgz#582a3c49a06913c287be531670e87120a2220af2"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.90.3.tgz#eb38c59377dd2954194f8474938bcea106e6e267"
|
||||||
integrity sha512-MSkbeoADyD1d4mERdmMNeqL0SBI2d8b9j/em7LDQp6tLzmfQEIHgPjjwvPthefHBPjUiikPSiSc9Iv/gPayhIQ==
|
integrity sha512-18d2TuNdwXPt08b+ODiBSehyuKrvdZuF+2hVYnL3R7KRDCdd/BMLbhqPmr9IA92aP3u/TQKjRjIKhgpqw6XCQw==
|
||||||
dependencies:
|
dependencies:
|
||||||
jsonc-parser "^3.3.1"
|
jsonc-parser "^3.3.1"
|
||||||
|
|
||||||
"@douyinfe/semi-theme-default@2.89.1":
|
"@douyinfe/semi-theme-default@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.1.tgz#57f596e610269d31e021fe6472440970305f9750"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.90.3.tgz#a2ebde9d37bccb8ceec3ed9fdde0d18b573515f7"
|
||||||
integrity sha512-8GGPU4mfhUg9dwyVS+xEhXp7iNEaqEslyRwbaiUImjOwLZmpEbYST/gvYpXkpRltaWGZ7n82P3uVPyQZkIBLAg==
|
integrity sha512-DfHJ/3jHaTMJfcuer8LTcrh84Vxixberpk0DyvzgwVD1T+zAOppElwWq0tGDc5lnDHMgCY4R9W4b0aUcLCB2qA==
|
||||||
|
|
||||||
"@douyinfe/semi-ui@2.89.1":
|
"@douyinfe/semi-ui@2.90.3":
|
||||||
version "2.89.1"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.1.tgz#c0573f2531fe24d4830db9331e99295d75af2207"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.90.3.tgz#465b0d94ae5316a57c8dd12a0c8d4c47061e2329"
|
||||||
integrity sha512-1jh9c1NwPEHYIPt+vCSF/TeXrhJ6g6/5745h8x2VZMA6+Epexv61eucTiMEz/HZzi9GC/u+K0GuwSS3CjJdEFA==
|
integrity sha512-Ojrj2y6G/79A85khqP8IinZaPFIIC3kWO263ppYQm/3Vb+yvg1ih1XIFNkPrhqha4Hybs1g+1LxKhjdvkhURRg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@dnd-kit/core" "^6.0.8"
|
"@dnd-kit/core" "^6.0.8"
|
||||||
"@dnd-kit/sortable" "^7.0.2"
|
"@dnd-kit/sortable" "^7.0.2"
|
||||||
"@dnd-kit/utilities" "^3.2.1"
|
"@dnd-kit/utilities" "^3.2.1"
|
||||||
"@douyinfe/semi-animation" "2.89.1"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-animation-react" "2.89.1"
|
"@douyinfe/semi-animation-react" "2.90.3"
|
||||||
"@douyinfe/semi-foundation" "2.89.1"
|
"@douyinfe/semi-foundation" "2.90.3"
|
||||||
"@douyinfe/semi-icons" "2.89.1"
|
"@douyinfe/semi-icons" "2.90.3"
|
||||||
"@douyinfe/semi-illustrations" "2.89.1"
|
"@douyinfe/semi-illustrations" "2.90.3"
|
||||||
"@douyinfe/semi-theme-default" "2.89.1"
|
"@douyinfe/semi-theme-default" "2.90.3"
|
||||||
"@tiptap/core" "^3.10.7"
|
"@tiptap/core" "^3.10.7"
|
||||||
"@tiptap/extension-document" "^3.10.7"
|
"@tiptap/extension-document" "^3.10.7"
|
||||||
"@tiptap/extension-hard-break" "^3.10.7"
|
"@tiptap/extension-hard-break" "^3.10.7"
|
||||||
@@ -2267,10 +2267,10 @@ ccount@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||||
|
|
||||||
chai@6.2.1:
|
chai@6.2.2:
|
||||||
version "6.2.1"
|
version "6.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.1.tgz#d1e64bc42433fbee6175ad5346799682060b5b6a"
|
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
|
||||||
integrity sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==
|
integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
|
||||||
|
|
||||||
chalk@^4.0.0, chalk@^4.1.0:
|
chalk@^4.0.0, chalk@^4.1.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
@@ -5874,10 +5874,10 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.33.1:
|
puppeteer-core@24.34.0:
|
||||||
version "24.33.1"
|
version "24.34.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.1.tgz#80b9d79dd0597fd2b1de265aae4dd0d95df81c66"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.34.0.tgz#00c7f63b4a83d4ca2ec5ea3a234588fb2ce7c994"
|
||||||
integrity sha512-MZjFLeGMBFbSkc1xKfcv6hjFlfNi1bmQly++HyqxGPYzLIMY0mSYyjqkAzT1PtomTYHq7SEonciIKkeyHExA1g==
|
integrity sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "12.0.1"
|
chromium-bidi "12.0.1"
|
||||||
@@ -5934,16 +5934,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.33.1:
|
puppeteer@^24.34.0:
|
||||||
version "24.33.1"
|
version "24.34.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.1.tgz#c84c9545633bc731b92caead463e77928dcf183e"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.34.0.tgz#061f6e97ce9511863ec83cd6f17a27253c68b5e9"
|
||||||
integrity sha512-2KiSIXk+zFzmYsScv+hx/I3TODFGPcNpyJsWMQk1EQ2y8KZ2X6225/NingyqYxekzceSUnq5qX39dUezVDZ9EQ==
|
integrity sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "12.0.1"
|
chromium-bidi "12.0.1"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1534754"
|
devtools-protocol "0.0.1534754"
|
||||||
puppeteer-core "24.33.1"
|
puppeteer-core "24.34.0"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user