mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
ability to restore (soft deleted) listings
This commit is contained in:
@@ -22,9 +22,9 @@ import './ListingsGrid.less';
|
||||
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
|
||||
*/
|
||||
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
|
||||
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => {
|
||||
const t = useTranslation();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
@@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isHiddenView ? (
|
||||
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={
|
||||
<span className="listingsGrid__strike" aria-hidden="true">
|
||||
<IconDelete />
|
||||
</span>
|
||||
}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(item.id);
|
||||
}}
|
||||
aria-label={t('listings.tooltipUndelete')}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -139,4 +139,23 @@
|
||||
border-radius: @radius-chip !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__strike {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
transform: rotate(-45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,18 @@ import {
|
||||
parseString,
|
||||
parseNullableBoolean,
|
||||
} from '../../hooks/useSearchParamState.js';
|
||||
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
Button,
|
||||
Pagination,
|
||||
Toast,
|
||||
Input,
|
||||
Select,
|
||||
Empty,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Tooltip,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
||||
@@ -50,9 +61,12 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
|
||||
const [hiddenOnly, setHiddenOnly] = useSearchParamState(sp, 'hidden', false, parseNullableBoolean);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
const isHiddenView = hiddenOnly === true;
|
||||
|
||||
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
|
||||
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
|
||||
|
||||
@@ -66,9 +80,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
filter: {
|
||||
watchListFilter: effectiveWatchListFilter,
|
||||
jobNameFilter,
|
||||
activityFilter,
|
||||
activityFilter: isHiddenView ? null : activityFilter,
|
||||
providerFilter,
|
||||
statusFilter,
|
||||
hiddenOnly: isHiddenView ? true : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -85,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
jobNameFilter,
|
||||
watchListFilter,
|
||||
statusFilter,
|
||||
hiddenOnly,
|
||||
isWatchlistMode,
|
||||
]);
|
||||
|
||||
@@ -138,7 +154,21 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
||||
const handleRestore = async (id) => {
|
||||
try {
|
||||
await actions.listingsData.restoreListings([id]);
|
||||
Toast.success(t('listings.toastRestored'));
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error(t('listings.toastRestoreError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (id) => {
|
||||
if (isHiddenView) return;
|
||||
navigate(`/listings/listing/${id}`);
|
||||
};
|
||||
|
||||
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||
try {
|
||||
@@ -158,118 +188,161 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
|
||||
const listings = listingsData?.result || [];
|
||||
|
||||
const activityRadioValue = isHiddenView ? 'hidden' : activityFilter === null ? 'all' : String(activityFilter);
|
||||
|
||||
return (
|
||||
<div className="listingsOverview">
|
||||
<div className="listingsOverview__topbar">
|
||||
<Input
|
||||
className="listingsOverview__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder={t('listings.searchPlaceholder')}
|
||||
defaultValue={freeTextFilter ?? ''}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<Tooltip content={t('listings.filterSearchHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__search">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder={t('listings.searchPlaceholder')}
|
||||
defaultValue={freeTextFilter ?? ''}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterActive')}</Radio>
|
||||
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
||||
</RadioGroup>
|
||||
<Tooltip content={t('listings.filterActivityHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={activityRadioValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === 'hidden') {
|
||||
setHiddenOnly(true);
|
||||
setActivityFilter(null);
|
||||
} else {
|
||||
setHiddenOnly(false);
|
||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterActive')}</Radio>
|
||||
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
||||
<Radio value="hidden">{t('listings.filterHidden')}</Radio>
|
||||
</RadioGroup>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{!isWatchlistMode && (
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
||||
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
||||
</RadioGroup>
|
||||
<Tooltip content={t('listings.filterWatchHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
||||
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
||||
</RadioGroup>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Select
|
||||
placeholder={t('listings.filterStatusPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
||||
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
||||
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
||||
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
||||
</Select>
|
||||
<Tooltip content={t('listings.filterStatusHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<Select
|
||||
placeholder={t('listings.filterStatusPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
||||
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
||||
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
||||
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
||||
</Select>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
placeholder={t('listings.filterProviderPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setProviderFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={providerFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Tooltip content={t('listings.filterProviderHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<Select
|
||||
placeholder={t('listings.filterProviderPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setProviderFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={providerFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
placeholder={t('listings.filterJobPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setJobNameFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={jobNameFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Tooltip content={t('listings.filterJobHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<Select
|
||||
placeholder={t('listings.filterJobPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setJobNameFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={jobNameFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
prefix={t('listings.sortPrefix')}
|
||||
className="listingsOverview__topbar__sort"
|
||||
style={{ width: 220 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
||||
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
||||
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
||||
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
||||
</Select>
|
||||
<Tooltip content={t('listings.filterSortHelp')} trigger="hover" position="top">
|
||||
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__sort">
|
||||
<Select
|
||||
prefix={t('listings.sortPrefix')}
|
||||
style={{ width: 220 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
||||
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
||||
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
||||
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
||||
</Select>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||
/>
|
||||
<Tooltip
|
||||
content={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||
trigger="hover"
|
||||
position="top"
|
||||
>
|
||||
<span className="listingsOverview__topbar__tooltipWrap">
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
aria-label={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="listingsOverview__topbar__view-toggle">
|
||||
<Tooltip content={t('listings.tooltipGridView')}>
|
||||
@@ -293,6 +366,16 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHiddenView && (
|
||||
<Banner
|
||||
type="info"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
description={t('listings.hiddenViewBanner')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{listings.length === 0 && (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
@@ -307,6 +390,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
onWatch={handleWatch}
|
||||
onNavigate={handleNavigate}
|
||||
onDelete={handleDelete}
|
||||
onRestore={handleRestore}
|
||||
isHiddenView={isHiddenView}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
) : (
|
||||
@@ -315,6 +400,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
onWatch={handleWatch}
|
||||
onNavigate={handleNavigate}
|
||||
onDelete={handleDelete}
|
||||
onRestore={handleRestore}
|
||||
isHiddenView={isHiddenView}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
margin-bottom: @space-4;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__tooltipWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__search {
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
|
||||
@@ -22,9 +22,17 @@ import './ListingsTable.less';
|
||||
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
|
||||
*/
|
||||
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
|
||||
const ListingsTable = ({
|
||||
listings,
|
||||
onWatch,
|
||||
onNavigate,
|
||||
onDelete,
|
||||
onRestore,
|
||||
isHiddenView = false,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
@@ -123,18 +131,38 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isHiddenView ? (
|
||||
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={
|
||||
<span className="listingsTable__strike" aria-hidden="true">
|
||||
<IconDelete />
|
||||
</span>
|
||||
}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(item.id);
|
||||
}}
|
||||
aria-label={t('listings.tooltipUndelete')}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__strike {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
transform: rotate(-45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
|
||||
|
||||
Reference in New Issue
Block a user