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

@@ -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;

View 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);
}

View 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;

View 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;
}
}