improve filtering for listings

This commit is contained in:
orangecoding
2025-11-01 16:29:05 +01:00
parent 60525d4cca
commit 96cd9098f0
3 changed files with 244 additions and 208 deletions

View File

@@ -1,53 +0,0 @@
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
import React from 'react';
import { useSelector } from '../../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
import './ListingsFilter.less';
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
const jobs = useSelector((state) => state.jobs.jobs);
const provider = useSelector((state) => state.provider);
const { Title } = Typography;
return (
<Card className="listingsFilter">
<Title heading={6}>Filter by:</Title>
<Divider />
<br />
<Descriptions row>
<Descriptions.Item itemKey="Watch List">
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Activity status">
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Job Name">
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
{jobs != null &&
jobs.length > 0 &&
jobs.map((job) => {
return (
<Select.Option value={job.id} key={job.id}>
{job.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
<Descriptions.Item itemKey="Provider">
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
{provider != null &&
provider.length > 0 &&
provider.map((prov) => {
return (
<Select.Option value={prov.id} key={prov.id}>
{prov.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
</Descriptions>
</Card>
);
}

View File

@@ -1,4 +0,0 @@
.listingsFilter {
margin-bottom: 1rem;
background: rgb(53, 54, 60);
}

View File

@@ -1,5 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui'; 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 { useActions, useSelector } from '../../../services/state/store.js';
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons'; import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
@@ -10,166 +23,220 @@ import './ListingsTable.less';
import { format } from '../../../services/time/timeService.js'; import { format } from '../../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { xhrDelete, xhrPost } from '../../../services/xhr.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import ListingsFilter from './ListingsFilter.jsx';
const columns = [ const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
{ return [
title: 'Watchlist', {
width: 110, title: 'Watchlist',
dataIndex: 'isWatched', width: 133,
sorter: true, dataIndex: 'isWatched',
render: (id, row) => { sorter: true,
return ( filters: [
<div> {
<Popover text: 'Show only watched listings',
style={{ value: 'watchList',
padding: '.4rem', },
color: 'var(--semi-color-white)', ],
}} render: (id, row) => {
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'} return (
> <div>
<Button <Popover
icon={ style={{
row.isWatched === 1 ? ( padding: '.4rem',
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} /> color: 'var(--semi-color-white)',
) : (
<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');
}
}} }}
/> content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
</Popover> >
<Divider layout="vertical" margin="4px" /> <Button
<Popover icon={
style={{ row.isWatched === 1 ? (
padding: '.4rem', <IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
color: 'var(--semi-color-white)', ) : (
}} <IconStarStroked />
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);
} }
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"
</Popover> >
</div> <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: 'State',
title: 'State', dataIndex: 'is_active',
dataIndex: 'is_active', width: 105,
width: 84, sorter: true,
sorter: true, filters: [
render: (value) => { {
return value ? ( text: 'Show only active listings',
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}> value: 'activityStatus',
<Popover },
style={{ ],
padding: '.4rem', render: (value) => {
color: 'var(--semi-color-white)', return value ? (
}} <div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
content="Listing is still active" <Popover
> style={{
<IconTick /> padding: '.4rem',
</Popover> color: 'var(--semi-color-white)',
</div> }}
) : ( content="Listing is still active"
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}> >
<Popover <IconTick />
style={{ </Popover>
padding: '.4rem', </div>
color: 'var(--semi-color-white)', ) : (
}} <div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
content="Listing is inactive" <Popover
> style={{
<IconClose /> padding: '.4rem',
</Popover> color: 'var(--semi-color-white)',
</div> }}
); content="Listing is inactive"
>
<IconClose />
</Popover>
</div>
);
},
}, },
}, {
{ title: 'Job-Name',
title: 'Job-Name', sorter: true,
sorter: true, ellipsis: true,
ellipsis: true, dataIndex: 'job_name',
dataIndex: 'job_name', width: 150,
width: 150, onFilter: () => true,
}, renderFilterDropdown: () => {
{ return (
title: 'Listing date', <Space vertical style={{ padding: 8 }}>
width: 130, <Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
dataIndex: 'created_at', {jobs != null &&
sorter: true, jobs.length > 0 &&
render: (text) => timeService.format(text, false), jobs.map((job) => {
}, return (
{ <Select.Option value={job.id} key={job.id}>
title: 'Provider', {job.name}
width: 130, </Select.Option>
dataIndex: 'provider', );
sorter: true, })}
render: (text) => text.charAt(0).toUpperCase() + text.slice(1), </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>
);
}, },
}, {
]; 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 = ( const empty = (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />} darkModeImage={<IllustrationNoResultDark />}
description="No listings available." description="No listings found."
/> />
); );
export default function ListingsTable() { export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable); const tableData = useSelector((state) => state.listingsTable);
const provider = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobs.jobs);
const actions = useActions(); const actions = useActions();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
@@ -179,12 +246,14 @@ export default function ListingsTable() {
const [jobNameFilter, setJobNameFilter] = useState(null); const [jobNameFilter, setJobNameFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null); const [activityFilter, setActivityFilter] = useState(null);
const [providerFilter, setProviderFilter] = useState(null); const [providerFilter, setProviderFilter] = useState(null);
const [allFilters, setAllFilters] = useState([]);
const [imageWidth, setImageWidth] = useState('100%'); const [imageWidth, setImageWidth] = useState('100%');
const handlePageChange = (_page) => { const handlePageChange = (_page) => {
setPage(_page); setPage(_page);
}; };
const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
const loadTable = () => { const loadTable = () => {
let sortfield = null; let sortfield = null;
let sortdir = null; let sortdir = null;
@@ -209,6 +278,20 @@ export default function ListingsTable() {
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []); 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(() => { useEffect(() => {
return () => { return () => {
// cleanup debounced handler to avoid memory leaks // cleanup debounced handler to avoid memory leaks
@@ -258,12 +341,6 @@ export default function ListingsTable() {
return ( return (
<div> <div>
<ListingsFilter
onActivityFilter={setActivityFilter}
onWatchListFilter={setWatchListFilter}
onJobNameFilter={setJobNameFilter}
onProviderFilter={setProviderFilter}
/>
<Input <Input
prefix={<IconSearch />} prefix={<IconSearch />}
showClear showClear
@@ -285,7 +362,23 @@ export default function ListingsTable() {
}; };
})} })}
onChange={(changeSet) => { onChange={(changeSet) => {
if (changeSet?.extra?.changeType === 'sorter') { 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({ setSortData({
field: changeSet.sorter.dataIndex, field: changeSet.sorter.dataIndex,
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc', direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',